14 Commits

Author SHA1 Message Date
Yamen Ahmad
fa6f4976c3 chore(ops): add scripts/backup-db.sh — Odoo-format DB backup
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Failing after 4s
CI / Backend — Odoo HttpCase (pull_request) Failing after 1s
Produces an Odoo-compatible <db>.zip restorable via /web/database/manager
on the live VPS (or curl -F backup_file=@... /web/database/restore).

Why this script instead of POST /web/database/backup:
the Odoo HTTP backup endpoint shells out to pg_dump from $PATH and on
this host that returns "Command pg_dump not found" because pg_dump
lives in the conda env that Odoo wasn't launched from. This script
calls pg_dump directly with PGBIN, copies the filestore, builds a
correct manifest.json (odoo_dump, db_name, version, pg_version,
modules), and zips the three artefacts in the exact layout Odoo's
restore code expects.

Layout matches Odoo's own backup output:
  <db>.zip
  |- dump.sql       (pg_dump --no-owner --format=p)
  |- manifest.json  (odoo_dump=1, version, pg_version, installed modules)
  |- filestore/     (data_dir/filestore/<db>/ contents)

Defaults are encoach_v2 / yamenahmad / /tmp/odoo-data, all overridable
via env vars (DB, DB_USER, DATA_DIR, PGBIN, OUT_DIR). Output filename
is encoach_v2_YYYYMMDD_HHMMSS.zip in ./backups/.

Verified: latest run produced encoach_v2_20260425_031949.zip — 30 MB
compressed, 815 entries, 633 tables x 633 COPY data blocks, 575
filestore files, 94 modules in manifest.

Made-with: Cursor
2026-04-25 03:20:41 +04:00
Yamen Ahmad
e2aa8031ff feat(ai): LangGraph as core runtime + AI Agents/Tools console + full-demo seed
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Failing after 1m20s
CI / Backend — Odoo HttpCase (pull_request) Failing after 1s
Core AI runtime
- New encoach.ai.agent + encoach.ai.tool models with M2M tool binding,
  graph topology (simple|plan_review_revise|rag|react), model + fallback,
  temperature, max_tokens, response_format, max_revisions, quality checks
  and system prompt fields.
- services/agent_runtime.py compiles a langgraph.StateGraph per agent
  and caches the build per (key, write_date). Emits a structured trace
  (output, tool_calls, retrieval_hits, revisions, quality_issues,
  ms, model_used, fallback_used) and auto-falls-back on rate-limit/5xx.
- services/agent_tools.py registers 11 tool handlers wrapping existing
  services: resources.search, rubric.fetch, outcomes.fetch,
  student.profile, quality.cefr_check, quality.ai_detect,
  quality.content_gate, course_plan.save (mutates),
  course_plan.save_materials (mutates), scoring.grade_writing,
  scoring.grade_speaking.
- 7 default agents seeded via data/agents_defaults.xml: course_planner,
  course_week_materials, exam_generator, exercise_generator, lms_tutor,
  writing_grader, speaking_grader.
- Feature flag encoach_ai.use_langgraph_runtime (default True).
- encoach_ai_course pipeline now routes through AgentRuntime when on,
  legacy SDK path kept as fallback.

Admin UI
- /admin/ai/prompts is now a tabbed Agents | Tools | Prompts console.
- AIAgentsPanel: card grid + config dialog (model/temp/graph/tools/
  system prompt) + built-in Test Runner showing live trace.
- AIToolsPanel: registry table with category badges, mutates flag,
  schema viewer, edit dialog.
- New /api/ai/agents* and /api/ai/tools* controller (list/get/update/
  test, list-tools, toggle-tool).
- Sidebar label nav.aiPrompts -> nav.aiAgents (AI Agents and Tools).
- EN + AR (RTL) translations for ~80 new keys.

Smart Wizard pages
- /admin/quick-setup hub + CourseWizard, CoursePlanWizard,
  RubricWizard, ExamStructureWizard step-by-step flows.
- /admin/course-plans list + detail pages.
- /teacher/quick-setup mirror.

Full demo seed + 8-role E2E
- seed_full_demo.py adds the 5 missing user_types (approver, corporate,
  mastercorporate, agent, developer), activates a 2-stage exam-approval
  workflow with one pending request, creates a GE1-aligned 12-week B1
  course plan with 6 detailed Week-1 materials (reading 400w, writing,
  listening 4-min script, speaking, grammar present simple vs continuous,
  vocabulary), and inserts sample ai.log + ai.feedback rows.
- reset_demo_passwords.py forces every demo login back to canonical
  passwords (admin123/teacher123/student123/approver123/corporate123/
  master123/agent123/dev123).
- e2e_full_scenario.py: 46/46 PASS read-only API smoke across all
  8 roles, including a live LangGraph round-trip on writing_grader.
- e2e_approval_chain.py: 6/6 PASS mutation E2E - approver approves
  stage 1, admin approves stage 2, linked encoach.exam.custom flips
  to status=published, verified via psql.

Docs
- docs/PROJECT_SUMMARY.md updated to 2026-04-25: new Latest events
  bullets, refreshed credentials table, full sections 22 (LangGraph
  runtime) and 23 (full demo seed + 8-role E2E).
- docs/ENCOACH_FULL_DEMO_QA_REPORT.md added with credentials,
  per-endpoint PASS/FAIL, mutation chain proof, LangGraph live output.
- backend/GE1 Course Outline_ Fall AY25-26.pdf vendored as the
  reference outline the GE1 plan/materials are aligned to.

Dependencies
- requirements.txt: langgraph>=0.2.0, langchain-core>=0.3.0.
- encoach_ai/__manifest__.py: external_dependencies updated.

Made-with: Cursor
2026-04-25 03:14:22 +04:00
Yamen Ahmad
1223074bde docs: add VPS restore runbook
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
Explains why /web/database/manager restores appear to succeed but the
live site keeps serving the old data (target DB name mismatch / Odoo
not restarted / dbfilter pinned), and ships a 6-step SSH-based
recovery procedure with safety-net backup, filestore handling, and
verification checklist tied to the known QA fixtures.

Made-with: Cursor
2026-04-21 09:35:11 +04:00
Yamen Ahmad
75ee0f1fe0 fix(ui): actionable 413/504/401 toasts + VPS nginx deploy template
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
Production QA reported "Submit failed 413" on /generation and plain
"Upload failed" (no detail) on resource upload. Root cause is the
outer reverse-proxy nginx on the VPS — still on default
`client_max_body_size 1m` — rejecting requests before they reach
Odoo. The inner docker-nginx and Odoo limits were already raised in
earlier commits; only the VPS proxy was left unpatched.

UI side
- Add `describeApiError(err, fallback, context)` helper in
  lib/api-client.ts. Maps common HTTP codes to human-readable,
  actionable toast descriptions (413 → "ask ops to raise nginx
  client_max_body_size", 504 → "server took too long, retry", 502
  → "Odoo is restarting", 401 → "sign in again", 4xx/5xx → server
  error body + status code).
- Use it in GenerationPage submit, ResourceManager upload,
  TeacherLibrary upload, CustomExamCreate save/publish. Replaces
  four slightly-different ad-hoc mappings with one source of truth.

Deploy side
- Add deploy/nginx-vps.conf.example: full TLS reverse-proxy template
  with the body/timeout knobs that must match Odoo's limit_request
  (128 MB) and limit_time_real (900 s).
- Add docs/DEPLOY_413_504_FIX.md: step-by-step remediation guide for
  the team lead covering the 3 layers that can block large bodies
  (outer nginx, Cloudflare, Odoo worker) and how to verify each.

Made-with: Cursor
2026-04-21 09:09:23 +04:00
Yamen Ahmad
d34180e107 fix(assignments): resolve op.student → res.users + student search
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
E2E QA surfaced a silent-drop bug in the exam-schedule endpoint: the
frontend's student picker sends `op.student.id` values (that's what
/api/students returns), but `encoach.exam.schedule.student_ids` is a
Many2many → res.users. The controller was writing the op.student id
straight through, so schedules linked to whichever res.users row
happened to share that integer id (OdooBot, other staff, etc.) — the
target student got 0 assignments and never saw the exam.

- exam_schedules.py: add `_resolve_student_user_ids` and use it in the
  create and update endpoints. Falls back to treating unmatched ids as
  direct res.users ids for back-office callers.
- AssignmentsPage.tsx: add a search input + larger list to the
  Individual Students picker; long lists were leaving Sarah unreachable
  inside the inner scroll container during QA.

Made-with: Cursor
2026-04-20 18:29:16 +04:00
Yamen Ahmad
eef3edf7e8 fix(i18n): default startup language to English
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
First-visit users now always land on the English UI regardless of
navigator.language. Arabic remains one click away via the toggle, and
the user's explicit pick is persisted to localStorage (encoach-lang)
and honoured on every subsequent load.

- src/i18n/index.ts: set lng: "en", drop navigator/htmlTag from the
  detector order so an Arabic-locale browser no longer silently boots
  the UI in Arabic before the user has chosen.
- src/lib/api-client.ts: mirror the same policy for the Accept-Language
  header sent to the backend, so AI-generated content stays English on
  first visit instead of echoing the browser locale.

Made-with: Cursor
2026-04-20 17:19:49 +04:00
Yamen Ahmad
a554ef5d42 fix(qa): approval queue, rubric UX, structure enforcement, upload/publish limits
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
Addresses the QA notes on the end-to-end approval flow. Highlights:

Backend
- encoach_ai/generation_submit: create encoach.approval.request on submit so
  submitted exams actually reach the approver queue (previously only the
  workflow id was stored on the exam, leaving approvers with an empty inbox).
  Initial exam status flips to pending_approval instead of draft.
- encoach_exam_template/approval_workflows:
  * /api/approval-users filters out students (user_type='student',
    op_student_id set, share=True) so the approver dropdown only lists staff.
  * _ser_request enriches requests with target_name / target_status and the
    current stage approver for UI badges.
  * list_requests supports mine=1 / requester=1 so approvers only see queue
    items awaiting their action.
  * approve_request / reject_request now transition the underlying
    encoach.exam.custom to published / rejected on final approval.
- encoach.exam.custom.status: add pending_approval and rejected states to
  match the approval workflow transitions.

Frontend UX
- GenerationPage: rubric field shows "Auto-graded — no rubric" for Listening
  and Reading (rubrics only apply to Writing / Speaking). Divider dropdowns
  now carry explicit help text explaining None / Line / Space / Page Break
  and where to write prompts. Selecting an official exam structure
  auto-populates the required tasks/sections/parts, the delete button is
  hidden for essential tasks, and submit is blocked if the user dropped
  below the structure's required count.
- RubricsPage: "Add Criterion" is now a dropdown with IELTS-specific
  presets (Task Achievement, Coherence & Cohesion, Fluency & Coherence, …)
  keyed off the selected skill, plus a "Custom (blank row)" fallback.
- AdminCourses: added tooltips and helper copy clarifying Difficulty vs
  CEFR Level in the Create Course dialog.
- CustomExamCreate: validate the whole form before Save/Publish, surface
  backend error messages (413/504/401) instead of a generic toast, read the
  exam id from the correct response field, and avoid marking Publish as
  failed when only the optional Save-as-Template step errors.
- ResourceManager / TeacherLibrary: upload toast now shows a concrete
  reason (HTTP 413 / 504 / 401) instead of a generic "Upload failed".

Config (504 / 413 / resource upload fixes)
- odoo.conf + odoo-docker.conf: raise limit_time_cpu to 600s,
  limit_time_real to 900s, limit_request to 128 MB, and memory caps so
  /api/exam/generation/submit and multipart resource uploads stop hitting
  504 / 413 during QA.
- frontend/Dockerfile (nginx): add client_max_body_size 128m, bump
  proxy_read_timeout / proxy_send_timeout to 900s, and disable request
  buffering so large AI/resource payloads stream through the proxy.

Made-with: Cursor
2026-04-20 17:14:39 +04:00
Yamen Ahmad
bab588b9da fix(deploy): vendor OpenEduCat Community 19.0 under backend/
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
Database dumps list 12 openeducat_* modules as installed (openeducat_core,
openeducat_admission, openeducat_assignment, openeducat_attendance,
openeducat_activity, openeducat_classroom, openeducat_exam, openeducat_facility,
openeducat_fees, openeducat_library, openeducat_parent, openeducat_timetable),
but the `backend/openeducat_erp-19.0/` folder was previously in .gitignore.
When a teammate restored the dump on the VPS Odoo failed with "module not
found" on every openeducat_* row in ir_module_module.

- Remove `backend/openeducat_erp-19.0/` from .gitignore and vendor the full
  LGPL-3 OpenEduCat Community 19.0 tree (14 modules: 12 core + theme + erp
  meta-module). `odoo.conf` and `odoo-docker.conf` already reference this
  path in `addons_path`, so restores now succeed on a fresh VPS with no
  extra bootstrap step.
- Strip the nested .git metadata that came with the upstream download so
  the tree is vendored flat (not as a submodule pointer).

Made-with: Cursor
2026-04-20 09:52:32 +04:00
Yamen Ahmad
e1f059069f feat(i18n,rtl): full Arabic localization + RTL sweep across all layouts
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
Frontend
- i18n: install tailwindcss-rtl, Cairo font, RTL-aware direction in index.css.
- Language toggle: localize aria-label / menu label, persist choice, update
  document dir synchronously.
- Sidebar: add `side` prop so the drawer pins to the right in RTL; wire up
  AdminLmsLayout, RoleLayout (student/teacher) and AppSidebar to pass
  side = i18n.dir() === 'rtl' ? 'right' : 'left'.
- AdminLmsLayout: convert every nav item from hard-coded title to titleKey,
  translate group labels (incl. the collapsible Training), breadcrumbs,
  user menu (Profile / Settings / Logout), help button and toggle aria
  labels; replace physical mr-/right- utilities with logical me-/end-.
- AI components (AiTipBanner, AiInsightsPanel, AiAlertBanner, AiSearchBar,
  AiAssistantDrawer): apply dir="auto" at the container level, localize
  titles, loading / error / empty states.
- Dashboards (admin / student / teacher): wrap numeric values in <bdi>,
  localize dates via ar-EG, fix flex direction for KPI and assignment cards.
- UI primitives (breadcrumb, calendar, carousel, dropdown-menu, menubar,
  context-menu, pagination, sidebar): flip chevrons in RTL via a scoped
  CSS rule, swap pl-/pr-/ml-/mr- for ps-/pe-/ms-/me-.
- Add logical-direction helpers and bidirectional isolation classes.

Locales
- Expand en.ts and ar.ts with full `nav`, `sidebarGroup`, `breadcrumb`,
  `userMenu`, `chrome`, `ai`, and dashboard key sets; keep key parity.

API client
- `api-client.ts` reads the active language from localStorage/i18n and sends
  `Accept-Language` on every request so the backend can localize AI output.

Backend (encoach_ai)
- openai_service: add _LANGUAGE_NAMES, normalize_language, language-aware
  system prompt injection for every OpenAI call.
- coach_service + controllers (coach_controller, ai_controller): thread
  the requested language from headers / user locale down to OpenAIService.
- ai_feedback: fix latent registry error by pointing course_id at op.course
  instead of the non-existent encoach.course.

Other
- .gitignore: ignore runtime odoo logs and local caches.

Made-with: Cursor
2026-04-19 18:13:16 +04:00
Yamen Ahmad
6712d1d551 docs: add WORK_REPORT.md and USER_MANUAL.md
- docs/WORK_REPORT.md: recap of the Phase 0/1/2/3 hardening sprint —
  what shipped per phase, branding update, repo migration to the new
  full_encoach_platform repo, per-commit file-churn, verification
  results (tsc, build, Playwright), known gaps, and an operator
  checklist for production promotion.
- docs/USER_MANUAL.md: end-user guide for every role (Student, Teacher,
  Admin, Corporate, Agent, Developer). Walks through every portal URL,
  the AI coach / IELTS / adaptive flows, human-in-the-loop exam review,
  AI prompt editor, feedback triage, GDPR Privacy Center, language +
  theme toggles, and troubleshooting.

Made-with: Cursor
2026-04-19 14:41:19 +04:00
Yamen Ahmad
93c530eef2 chore(ci,docs): GitHub Actions, ADRs, README overhaul, §21 Hardening Release
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
- .github/workflows/ci.yml: two jobs — frontend (tsc --noEmit, lint, build,
  Playwright) and backend (Postgres 16 + odoo:19 --test-enable
  --test-tags encoach_api) — catches regressions before merge.
- docs/adr/: start an Architecture Decision Record trail with
  0001 canonical directory layout, 0002 JWT refresh flow,
  0003 paginated response envelope, 0004 RAG metadata + chunking.
- docs/PROJECT_SUMMARY.md §21 Hardening Release: full recap of the AI
  quality loop, compliance, Paymob, i18n, and CI work shipped in this
  drop, plus new DB tables, REST routes, frontend routes, verification
  results, and operator-facing configuration.
- README.md refreshed for the v4 split-repo doctrine and the new feature
  surface.
- new_project/DEPRECATED.md: formal retirement notice pointing at
  backend/ as the canonical tree.

Made-with: Cursor
2026-04-19 14:16:47 +04:00
Yamen Ahmad
e70a2854f4 feat(frontend): Phase 2/3 hardening release
Roadmap P0
- Ship /logo.svg fallback and rewire asset references (superseded later by
  the project-manager PNG in a separate commit).

Roadmap P1
- Response envelope alignment ({items,total,page,size}) across services
  and query hooks.
- Approval/ticket UX updates tied to the new backend rollback semantics.

Roadmap P2
- React.lazy + Vite manualChunks to split vendor-radix/charts/icons/forms
  from the main bundle and cut initial JS.
- Fix the 181 tsc errors (PaginationParams class + per-service generics).
- Auto-refresh flow for JWT refresh tokens wired into api-client.ts.

Roadmap P3
- i18n: i18next + language detector, en/ar locales, RTL auto-switch,
  LanguageToggle component in RoleLayout and AdminLmsLayout.
- GDPR: PrivacyCenter page for data export and right-to-erasure, backed
  by gdpr.service.ts; logout via AuthContext after erasure.
- Human-in-the-loop exam review UI: admin ExamReviewQueue + ExamReviewDetail
  pages, useExamReview hooks, exam-review.service.ts.
- AI quality loop: AIPromptEditor (versioning + render preview),
  AIFeedbackButtons for students, AIFeedbackTriage admin page, dedicated
  services, hooks, and types.
- Dark mode: ThemeProvider + ThemeToggle + chart color tokens.
- Accessibility: DialogDescription on every DialogContent; alert-dialog
  and sheet updates.
- Retire dead pages: ExamPage marketing, Index, ProfilePage import.
- Playwright e2e scaffolding: playwright.config.ts + e2e/login.spec.ts
  smoke tests, npm scripts test:e2e / test:e2e:install.

Made-with: Cursor
2026-04-19 14:16:32 +04:00
Yamen Ahmad
dcf5ea6941 feat(backend): Phase 2/3 hardening release
Roadmap P0 — platform safety & ops
- Merge duplicate encoach.student.attempt/answer models into encoach_scoring
  and drop the stale encoach_exam_template copies.
- Remove duplicate /api/exam/* routes; canonicalize on one controller tree.
- Gate raw-SQL seeds in seed_demo_data.py behind an explicit env flag.
- Add /api/health and /api/health/ready (DB + LLM reachability) endpoints.
- Fix docker-compose + ship odoo-docker.conf for container-local runs.
- Enforce OpenAI request_timeout=30s and @jwt_required on all AI/coach routes.
- Promote canonical cefr_mapper to encoach_ai.services.cefr_mapper.
- JWT cache TTL=30s + invalidation hook on user mutation.

Roadmap P1 — exam correctness & data provenance
- Wire QualityChecker + IeltsValidator into exam submit with a
  pending_review gate (encoach_ai.services.question_validator).
- Populate RAG metadata (course_id, subject_id, entity_id, taxonomy) on
  encoach_vector embeddings and add a chunking pipeline (>2000 chars).
- Add provenance fields on encoach.question (model, prompt_hash, log_id)
  and validate LLM output with schema before DB insert.
- Unify response envelope to {items,total,page,size}.
- Approval reject rollback with savepoint atomicity.
- Ticket notifications on status/assignee change.

Roadmap P2 — performance & observability
- Reports: replace Python loops with SQL read_group aggregations.
- X-Request-ID middleware + structured JSON logs.
- In-process/Prometheus counters and openapi.py controller exporting a
  spec by scanning @http.route decorators.
- Paymob real checkout + HMAC-SHA512 webhook verification, backed by a
  new encoach.paymob.order model and ir.config_parameter credentials.
- JWT refresh tokens + revocation table.
- Composite DB indexes on hot report/ticket/attempt paths.

Roadmap P3 — human-in-the-loop & compliance
- Human-in-the-loop exam review workflow (pending_review → publish) with
  new review controller and status transitions.
- encoach.ai.prompt model + versioning + admin editor endpoints (one
  active version per key, render-preview dry run).
- Student feedback loop → encoach.ai.feedback (upsert per user/subject,
  admin triage + resolve endpoints).
- GDPR export (/api/gdpr/export) and right-to-erasure (/api/gdpr/delete)
  with anonymization, tombstone record, and admin-self-erasure guard.
- HttpCase smoke tests for /api/health and /api/health/ready.

Made-with: Cursor
2026-04-19 14:16:09 +04:00
Yamen Ahmad
47d09a3ce5 chore(branding): adopt project-manager EnCoach logo across login and sidebars
- Replace public/logo-icon.png with the approved EnCoach wordmark
  (icon + "EnCoach" + tagline "Unlock your potential with AI powered platform").
- Login page shows the full branded logo at h-36 and drops the duplicate
  text heading since the wordmark is now baked into the asset.
- Student/Teacher/Admin sidebars show a small rounded icon when collapsed
  and the full wordmark at h-14 when expanded, removing the previous
  duplicated "EnCoach" text span.
- og:image in index.html already points at /logo-icon.png so social cards
  pick up the new branding automatically.

Made-with: Cursor
2026-04-19 14:15:41 +04:00
1583 changed files with 401460 additions and 1925 deletions

109
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,109 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# --------------------------------------------------------------------
# Frontend: type-check, lint, build, and run Playwright smoke tests.
# --------------------------------------------------------------------
frontend:
name: Frontend — lint + build + e2e
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit -p tsconfig.app.json
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright smoke tests
run: npm run test:e2e
env:
CI: "true"
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: frontend/playwright-report
retention-days: 7
# --------------------------------------------------------------------
# Backend: run the Odoo HttpCase smoke suite via the official image.
#
# The image mounts our custom_addons tree and runs the test-tagged
# subset so we don't pay for a full install on every PR.
# --------------------------------------------------------------------
backend:
name: Backend — Odoo HttpCase
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U odoo"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Run Odoo HttpCase tests in a throwaway container
run: |
docker run --rm \
--network host \
-v "$GITHUB_WORKSPACE/backend/custom_addons:/mnt/extra-addons" \
-e HOST=localhost \
-e USER=odoo \
-e PASSWORD=odoo \
odoo:19 \
--stop-after-init \
--test-enable \
--test-tags "encoach_api" \
--log-level=warn \
--without-demo=all \
--init=encoach_api,encoach_core \
-d encoach_ci || {
echo "::warning::Odoo HttpCase suite failed — see logs above."
exit 1
}
env:
PGHOST: localhost
PGUSER: odoo
PGPASSWORD: odoo

11
.gitignore vendored
View File

@@ -88,9 +88,13 @@ addons_enterprise/
addons_extra/
new_project/enterprise-17/
# Third-party modules (downloaded separately)
# Third-party modules
# OpenEduCat Community (LGPL-3) is vendored under `backend/openeducat_erp-19.0/`
# and must be tracked — `addons_path` in odoo.conf / odoo-docker.conf relies on
# it, and database dumps mark 12 openeducat_* modules as installed. Excluding
# this folder previously caused restores on the VPS to fail with "module not
# found" errors.
new_project/openeducat_erp-19.0/
backend/openeducat_erp-19.0/
new_project/openeducat_erp-19.0.zip
new_project/openeducate_enterprise-17.zip
new_project/encoach_frontend_new_v1-main.zip
@@ -105,3 +109,6 @@ htmlcov/
# Poetry
poetry.lock
# Odoo DB backups (local only, not source-controlled)
backups/

View File

@@ -3,8 +3,11 @@ FROM odoo:19.0
USER root
# Install all Python dependencies required by EnCoach custom addons.
# --ignore-installed: skip uninstalling system packages that lack RECORD files (e.g. Debian's typing_extensions).
COPY new_project/requirements.txt /tmp/requirements.txt
# --ignore-installed: skip uninstalling system packages that lack RECORD files
# (e.g. Debian's typing_extensions).
# Canonical source: repo-root requirements.txt (the legacy copy under
# new_project/ is no longer authoritative — see new_project/DEPRECATED.md).
COPY requirements.txt /tmp/requirements.txt
RUN pip install -r /tmp/requirements.txt --break-system-packages --no-warn-script-location --ignore-installed
USER odoo

267
README.md
View File

@@ -1,123 +1,190 @@
# EnCoach Backend v2 — Odoo 19 Adaptive Learning Platform
# EnCoach Adaptive AI Learning Platform
The Odoo 19 backend for the **EnCoach** adaptive learning and institutional LMS platform. Provides ~377 REST API endpoints serving a React 18 SPA frontend.
EnCoach is a smart learning environment for individual and collaborative learning,
fully integrated with AI and equipped with intelligent, professional-grade
exercises, assessments, and e-exams.
## System Overview
This repository hosts the full stack:
| Component | Detail |
|-----------|--------|
| **Framework** | Odoo 19 Community Edition |
| **Language** | Python 3.12+ |
| **Database** | PostgreSQL 16 |
| **LMS Foundation** | OpenEduCat Community (14 modules, ported to v19) |
| **Custom Modules** | 27 `encoach_*` modules |
| **AI/ML** | OpenAI GPT-4o, Whisper (local), AWS Polly, ELAI, GPTZero, FAISS |
| **Authentication** | JWT (PyJWT) |
| **Deployment** | Docker Compose |
- **Backend** — Odoo 19 + ~30 `encoach_*` modules (Python 3.12, PostgreSQL 16)
- **Frontend** — React 18 + Vite + TypeScript single-page app
- **AI layer** — OpenAI + pgvector RAG, quality-gate validation, IELTS validator
- **Ops** — Docker Compose, JWT auth with refresh tokens, Prometheus-compatible
metrics, dynamic OpenAPI 3.0 spec
## Module Architecture
> **Canonical trees:** `backend/` (all server code) and `frontend/` (all client
> code). The legacy `new_project/` directory is deprecated — see
> [`new_project/DEPRECATED.md`](new_project/DEPRECATED.md).
```
new_project/
custom_addons/ # 27 custom EnCoach modules
encoach_api/ # REST API controllers (~120 routes)
encoach_lms_api/ # OpenEduCat bridge API (~209 routes)
encoach_adaptive_api/ # Adaptive learning API (~41 routes)
encoach_resources/ # Resource library API (~7 routes)
encoach_core/ # Users, entities, roles, permissions
encoach_taxonomy/ # Subject > Domain > Topic taxonomy
encoach_adaptive/ # Proficiency, learning plans, mastery
encoach_adaptive_ai/ # AI for diagnostics, coaching, content
encoach_exam/ # AI-powered exam engine
encoach_ai/ # OpenAI service wrapper
encoach_ai_generation/ # AI exam content generation
encoach_ai_grading/ # AI grading (writing, speaking, math, IT)
encoach_ai_media/ # TTS (Polly), STT (Whisper), ELAI avatars
encoach_courseware/ # Chapters, materials, AI workbench
encoach_communication/ # Discussion boards, announcements, messaging
encoach_notification/ # Notification engine, rules, preferences
encoach_faq/ # FAQ categories and items
encoach_approval/ # Approval workflows, stages
encoach_assignment/ # Exam-wrapper assignments
encoach_classroom/ # Virtual classroom groups
encoach_stats/ # Session tracking, statistics
encoach_training/ # Training tips, walkthroughs, FAISS
encoach_subscription/ # Packages, Stripe/PayPal/Paymob
encoach_registration/ # User registration, batch import
encoach_ticket/ # Support tickets
encoach_sis/ # UTAS SIS integration
encoach_branding/ # Whitelabeling
openeducat_erp-19.0/ # 14 OpenEduCat community modules
openeducat_core/ # Courses, batches, students, faculty
openeducat_timetable/ # Timetable sessions
openeducat_attendance/ # Attendance management
openeducat_exam/ # Institutional exams, marksheets
openeducat_assignment/ # Course assignments, submissions
openeducat_admission/ # Admission registers, applications
openeducat_classroom/ # Physical classrooms
openeducat_facility/ # Facilities and assets
openeducat_fees/ # Fee plans, student fees
openeducat_library/ # Library media, movements, cards
openeducat_activity/ # Student activities
openeducat_parent/ # Parent-student relationships
openeducat_erp/ # ERP connector
theme_web_openeducat/ # Web theme
```
---
## Staging
## 1. Quickstart
| Service | URL |
|---------|-----|
| **Odoo Backend** | `http://5.189.151.117:8069` |
| **React Frontend** | `http://5.189.151.117:3000` |
## Branching Workflow
This repo is connected to the staging server via a Git post-receive hook.
**All deployment is automatic after code review approval.**
1. Never push directly to `main` -- branch protection will block it.
2. Push your changes to a feature branch (e.g. `feature/full-backend-v1`).
3. Open a Pull Request on Gitea targeting `main`.
4. Request review from **devops (Talal)**.
5. Once approved and merged, the staging server rebuilds and redeploys automatically.
The `.env` file is **not committed**. It lives only on the staging server at `/opt/encoach/backend-v2/.env`.
## Local Development
### Option A: Docker (easiest)
### Docker (recommended)
```bash
docker compose up -d
# Odoo: http://localhost:8069
# Frontend: cd frontend && npm install && npm run dev (http://localhost:8080)
```
Open `http://localhost:8069`. First run: create a new database.
Create a new Odoo database on first visit, then install the `encoach_api`
module to pull in every `encoach_*` dependency.
Stop: `docker compose down`
### Source
### Option B: Source (venv + PostgreSQL)
**Prerequisites:** macOS, Homebrew, Python 3.11+, PostgreSQL 15+
Prerequisites: Python 3.12, PostgreSQL 16, Node 20, npm 10.
```bash
brew install postgresql@15 wkhtmltopdf
brew services start postgresql@15
createuser -s $(whoami)
# Backend
./setup.sh # creates venv, installs requirements.txt
./run.sh # starts Odoo on :8069
chmod +x setup.sh
./setup.sh
./run.sh
# Frontend
cd frontend
npm install
npm run dev # starts Vite on :8080, proxies /api → :8069
```
Open `http://localhost:8069`.
See [`MANUAL-RUN.md`](MANUAL-RUN.md) for a step-by-step walkthrough and
[`CONNECT-POSTGRES.md`](CONNECT-POSTGRES.md) for database wiring.
See [MANUAL-RUN.md](MANUAL-RUN.md) for step-by-step commands.
---
## Related Documents
## 2. Repository layout
```
odoo19/
├── backend/
│ └── custom_addons/
│ ├── encoach_api/ REST base + JWT + OpenAPI + metrics
│ ├── encoach_core/ Users, entities, roles, permissions
│ ├── encoach_taxonomy/ Subject → Domain → Topic
│ ├── encoach_ai/ OpenAI wrapper, cefr_mapper, validator
│ ├── encoach_ai_course/ AI course/exam generation pipelines
│ ├── encoach_ai_grading/ AI grading (writing/speaking/math/IT)
│ ├── encoach_ai_media/ TTS (Polly), STT (Whisper), ELAI
│ ├── encoach_vector/ pgvector store + RAG embeddings
│ ├── encoach_exam_template/ Canonical exam + student attempt models
│ ├── encoach_scoring/ Score computation, CEFR mapping
│ ├── encoach_quality_gate/ Automated content-quality checks
│ ├── encoach_ielts_validation/ IELTS-specific validators
│ ├── encoach_adaptive/ Adaptive engine, style matcher
│ ├── encoach_lms_api/ OpenEduCat bridge + LMS endpoints
│ ├── encoach_branding/ White-label config per entity
│ └── … (other encoach_* modules)
├── frontend/
│ ├── src/
│ │ ├── pages/ Route pages (React.lazy code-split)
│ │ ├── components/ Shared UI (shadcn/ui + custom)
│ │ ├── services/ Thin API wrappers over fetch
│ │ ├── hooks/queries/ React Query hooks + keys
│ │ ├── lib/api-client.ts Fetch + auto JWT refresh
│ │ └── types/ Shared DTO types
│ └── vite.config.ts manualChunks: react, query, charts, radix…
├── docs/
│ ├── PROJECT_SUMMARY.md Release notes & architecture history
│ ├── adr/ Architecture Decision Records
│ └── ENCOACH_*.md SRS, workflows, user stories
├── docker-compose.yml
├── Dockerfile
├── requirements.txt Python deps (pgvector, textstat, etc.)
└── README.md You are here
```
---
## 3. Architecture at a glance
```
┌────────────────────┐ HTTPS/JWT ┌────────────────────┐
│ React SPA (Vite) │ ───────────────► │ Odoo 19 + FastAPI-style │
│ - React Query │ ◄─────────────── │ controllers (encoach_*) │
│ - next-themes │ X-Request-Id └──────┬─────────────┘
│ - shadcn/ui │ │
└────────────────────┘ ▼
┌──────────────┐
│ PostgreSQL │
│ + pgvector │
└──────┬───────┘
┌──────────────┐
│ OpenAI, │
│ Whisper, │
│ Polly… │
└──────────────┘
```
Every request carries an `X-Request-Id` and emits structured JSON logs.
Prometheus-compatible counters are exposed at `/api/metrics`, and the live
OpenAPI 3.0 spec is at `/api/openapi.json`.
---
## 4. Key conventions
- **Canonical response envelope** — list endpoints return
`{ items: T[], total, page, size, data: T[] }` (see `encoach_api.controllers.base.paginated_envelope`).
- **CEFR mapping** — only `encoach_ai.services.cefr_mapper` is canonical. Do
not reintroduce local `band_to_cefr` copies.
- **JWT tokens** — short-lived access tokens (1h) + revocable refresh tokens
(7d). Only `access` tokens are accepted as Bearer credentials; refresh tokens
must go through `/api/auth/refresh`. See [`docs/adr/0002-jwt-refresh-token-flow.md`](docs/adr/0002-jwt-refresh-token-flow.md).
- **RAG metadata** — vector embeddings carry `course_id`, `subject_id`,
`entity_id`, `taxonomy`, `content_hash`. Chunking kicks in above 2 000 chars.
- **Frontend pagination** — `PaginatedResponse<T>` exposes both `items` and
`data`. Read from `items` in new code.
- **Frontend theming** — tokens live in `frontend/src/index.css` (`:root` and
`.dark`). Always use `hsl(var(--token))` instead of raw hex.
---
## 5. Health, observability, docs
| Endpoint | Purpose |
|---|---|
| `GET /api/health` | Liveness (always 200 when server is up) |
| `GET /api/health/ready` | Readiness (DB + required config) |
| `GET /api/openapi.json` | Dynamic OpenAPI 3.0 spec generated from `@http.route` |
| `GET /api/metrics` | Prometheus-format counters per route |
---
## 6. Deployment
Staging and production both use Docker Compose. The staging server rebuilds
automatically from `main`; never force-push. See
[`INSTALL-ODOO-SUMMARY.md`](INSTALL-ODOO-SUMMARY.md) for bootstrap notes.
| Service | Staging URL |
|---|---|
| Odoo backend | `http://5.189.151.117:8069` |
| React frontend | `http://5.189.151.117:3000` |
The `.env` file is **never** committed. On staging it lives at
`/opt/encoach/backend-v2/.env`.
---
## 7. Further reading
| Document | Description |
|----------|-------------|
| `ENCOACH_ODOO19_BACKEND_SRS.md` | Backend SRS v3.0 (definitive) |
| `ENCOACH_UNIFIED_SRS.md` | Unified frontend + backend SRS v2.0 |
| `ENCOACH_PRODUCT_DESCRIPTION.md` | Non-technical product description |
|---|---|
| [`docs/PROJECT_SUMMARY.md`](docs/PROJECT_SUMMARY.md) | Release notes + architecture history |
| [`docs/adr/`](docs/adr/) | Architecture Decision Records (why we built it this way) |
| [`docs/ENCOACH_UNIFIED_SRS.md`](docs/ENCOACH_UNIFIED_SRS.md) | Unified frontend + backend SRS |
| [`docs/ENCOACH_ODOO19_BACKEND_SRS.md`](docs/ENCOACH_ODOO19_BACKEND_SRS.md) | Backend SRS v3.0 |
| [`docs/ENCOACH_WORKFLOWS_BACKEND_SRS.md`](docs/ENCOACH_WORKFLOWS_BACKEND_SRS.md) | Backend workflows |
| [`docs/ENCOACH_WORKFLOWS_FRONTEND_SRS.md`](docs/ENCOACH_WORKFLOWS_FRONTEND_SRS.md) | Frontend workflows |
---
## 8. Contributing
1. Branch from `main` — never push direct. Branch protection enforces it.
2. Run `npx tsc --noEmit -p tsconfig.app.json` (frontend) and the module smoke
tests before opening a PR.
3. Every architectural decision should be captured as an ADR under
[`docs/adr/`](docs/adr/). Copy `0000-template.md` to start one.
4. Open the PR against `main` and request review from devops (Talal).

Binary file not shown.

View File

@@ -15,14 +15,15 @@
- AI Search, Insights, Report Narrative
""",
"author": "EnCoach",
"depends": ["base", "encoach_core"],
"depends": ["base", "encoach_core", "encoach_api"],
"external_dependencies": {
"python": ["openai", "boto3"],
"python": ["openai", "boto3", "langgraph", "langchain_core"],
},
"data": [
"security/ir.model.access.csv",
"views/ai_settings_views.xml",
"data/ai_defaults.xml",
"data/agents_defaults.xml",
],
"installable": True,
"application": True,

View File

@@ -1,3 +1,6 @@
from . import ai_controller
from . import coach_controller
from . import media_controller
from . import prompt_controller
from . import feedback_controller
from . import agents_controller

View File

@@ -0,0 +1,244 @@
"""Admin endpoints for configuring and exercising AI agents.
Design notes:
* Read endpoints require a valid JWT but not admin. The "AI Agents"
tab needs to be reachable by anyone who can see ``/admin/ai/prompts``
today (analysts, teachers auditing prompt changes, etc.).
* Write endpoints — ``PATCH /api/ai/agents/<id>`` and
``POST /api/ai/agents/<id>/test`` — additionally require admin
privileges (``base.group_system``), matching the existing prompt
controller's policy.
* ``/test`` is deliberately synchronous and uncached: admins use it to
quickly verify a config change produces sane output. It caps the
LLM at 500 tokens to keep iteration cheap.
"""
from __future__ import annotations
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
_error_response,
_get_json_body,
_json_response,
jwt_required,
)
_logger = logging.getLogger(__name__)
def _require_admin():
if not request.env.user.has_group("base.group_system"):
return _error_response("Admin privileges required", 403)
return None
class EncoachAIAgentsController(http.Controller):
# ------------------------------------------------------------------
# GET /api/ai/agents
# ------------------------------------------------------------------
@http.route("/api/ai/agents", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def list_agents(self, **kw):
try:
search = (kw.get("search") or "").strip()
domain = []
if search:
domain = [
"|", "|",
("key", "ilike", search),
("name", "ilike", search),
("description", "ilike", search),
]
Agent = request.env["encoach.ai.agent"].sudo()
records = Agent.search(domain, order="sequence, name")
items = [r.to_api_dict(include_prompt=False) for r in records]
return _json_response({
"items": items,
"data": items,
"total": len(items),
})
except Exception as exc:
_logger.exception("list agents failed")
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# GET /api/ai/agents/<id>
# ------------------------------------------------------------------
@http.route(
"/api/ai/agents/<int:agent_id>",
type="http", auth="none", methods=["GET"], csrf=False,
)
@jwt_required
def get_agent(self, agent_id, **kw):
try:
agent = request.env["encoach.ai.agent"].sudo().browse(int(agent_id))
if not agent.exists():
return _error_response("Agent not found", 404)
data = agent.to_api_dict(include_prompt=True)
data["tools"] = [t.to_api_dict() for t in agent.tool_ids]
return _json_response(data)
except Exception as exc:
_logger.exception("get agent failed")
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# PATCH /api/ai/agents/<id> (admin-only)
# ------------------------------------------------------------------
@http.route(
"/api/ai/agents/<int:agent_id>",
type="http", auth="none", methods=["PATCH", "PUT"], csrf=False,
)
@jwt_required
def update_agent(self, agent_id, **kw):
err = _require_admin()
if err is not None:
return err
try:
agent = request.env["encoach.ai.agent"].sudo().browse(int(agent_id))
if not agent.exists():
return _error_response("Agent not found", 404)
body = _get_json_body() or {}
vals: dict = {}
# Whitelist every settable field so callers can't flip `active` or
# rewrite `key` without knowing they're allowed to.
for f in (
"name", "description", "system_prompt", "prompt_key",
"model", "fallback_model", "response_format",
"graph_type", "quality_checks",
):
if f in body:
vals[f] = body[f] or ""
for f in ("temperature",):
if f in body:
try:
vals[f] = float(body[f])
except (TypeError, ValueError):
pass
for f in ("max_tokens", "max_revisions", "sequence"):
if f in body:
try:
vals[f] = int(body[f])
except (TypeError, ValueError):
pass
if "active" in body:
vals["active"] = bool(body["active"])
if "tool_keys" in body and isinstance(body["tool_keys"], list):
tool_ids = request.env["encoach.ai.tool"].sudo().search(
[("key", "in", [str(k) for k in body["tool_keys"]])]
).ids
vals["tool_ids"] = [(6, 0, tool_ids)]
with request.env.cr.savepoint():
agent.write(vals)
return _json_response(agent.to_api_dict(include_prompt=True))
except Exception as exc:
_logger.exception("update agent failed")
return _error_response(str(exc), 400)
# ------------------------------------------------------------------
# POST /api/ai/agents/<id>/test (admin-only)
# ------------------------------------------------------------------
@http.route(
"/api/ai/agents/<int:agent_id>/test",
type="http", auth="none", methods=["POST"], csrf=False,
)
@jwt_required
def test_agent(self, agent_id, **kw):
err = _require_admin()
if err is not None:
return err
try:
agent = request.env["encoach.ai.agent"].sudo().browse(int(agent_id))
if not agent.exists():
return _error_response("Agent not found", 404)
body = _get_json_body() or {}
variables = body.get("variables") or {}
payload = body.get("payload")
language = body.get("language") or request.env.user.lang or "en"
from odoo.addons.encoach_ai.services.agent_runtime import AgentRuntime
runtime = AgentRuntime(request.env, agent, language=language)
# Small-budget test: cap max_tokens so iteration stays cheap.
original_max = agent.max_tokens
if original_max > 800:
agent.sudo().write({"max_tokens": 800})
try:
final = runtime.invoke(variables=variables, payload=payload)
finally:
if agent.max_tokens != original_max:
agent.sudo().write({"max_tokens": original_max})
output = final.get("output")
return _json_response({
"error": final.get("error") or "",
"output": output,
"output_raw": (final.get("output_raw") or "")[:6000],
"tool_results": (final.get("tool_results") or [])[:20],
"retrieval_hits": len(final.get("retrieval") or []),
"revisions_used": final.get("revisions_used") or 0,
"quality_issues": final.get("quality_issues") or [],
"iterations": final.get("iterations") or 0,
})
except Exception as exc:
_logger.exception("test agent failed")
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# GET /api/ai/agents/tools
# ------------------------------------------------------------------
@http.route(
"/api/ai/agents/tools",
type="http", auth="none", methods=["GET"], csrf=False,
)
@jwt_required
def list_tools(self, **kw):
try:
tools = request.env["encoach.ai.tool"].sudo().search([], order="category, sequence, name")
items = [t.to_api_dict() for t in tools]
return _json_response({"items": items, "data": items, "total": len(items)})
except Exception as exc:
_logger.exception("list tools failed")
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# PATCH /api/ai/agents/tools/<id> (admin-only; currently toggle active)
# ------------------------------------------------------------------
@http.route(
"/api/ai/agents/tools/<int:tool_id>",
type="http", auth="none", methods=["PATCH", "PUT"], csrf=False,
)
@jwt_required
def update_tool(self, tool_id, **kw):
err = _require_admin()
if err is not None:
return err
try:
tool = request.env["encoach.ai.tool"].sudo().browse(int(tool_id))
if not tool.exists():
return _error_response("Tool not found", 404)
body = _get_json_body() or {}
vals: dict = {}
if "active" in body:
vals["active"] = bool(body["active"])
for f in ("name", "description", "category"):
if f in body:
vals[f] = body[f] or ""
if "schema" in body:
# Accept a parsed dict OR raw JSON string.
raw = body["schema"]
if isinstance(raw, (dict, list)):
vals["schema_json"] = json.dumps(raw)
else:
vals["schema_json"] = str(raw)
with request.env.cr.savepoint():
tool.write(vals)
return _json_response(tool.to_api_dict())
except Exception as exc:
_logger.exception("update tool failed")
return _error_response(str(exc), 400)

View File

@@ -2,8 +2,9 @@
import json
import logging
from odoo import http
from odoo import fields, http
from odoo.http import request, Response
from odoo.addons.encoach_api.controllers.base import jwt_required
_logger = logging.getLogger(__name__)
@@ -23,11 +24,31 @@ def _get_json():
return {}
def _request_language():
"""Return the caller's UI language from ``Accept-Language``.
The frontend ``api-client`` forwards the active i18n language (e.g. ``ar``
or ``en``) via this header so AI-generated natural-language strings can
be returned in the same language as the UI chrome.
"""
try:
return request.httprequest.headers.get("Accept-Language", "") or ""
except Exception:
return ""
def _openai_for_request():
"""Construct an OpenAIService bound to the caller's UI language."""
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
return OpenAIService(request.env, language=_request_language())
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="public", methods=["POST"], csrf=False)
@http.route("/api/ai/search", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_search(self, **kw):
body = _get_json()
query = body.get("query", "")
@@ -35,7 +56,7 @@ class AIController(http.Controller):
return _json_response({"answer": "", "suggestions": []})
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
result = ai.search_with_rag(query, context=body.get("context", ""))
return _json_response(result)
except Exception as e:
@@ -43,7 +64,8 @@ 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="public", methods=["GET"], csrf=False)
@http.route("/api/ai/vector-search", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def ai_vector_search(self, **kw):
query = request.params.get("q", "")
content_type = request.params.get("content_type")
@@ -60,12 +82,13 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/ai/insights", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_insights(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
result = ai.generate_insights(
body.get("data", {}),
insight_type=body.get("type", "general"),
@@ -76,11 +99,12 @@ 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="public", methods=["GET"], csrf=False)
@http.route("/api/ai/alerts", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def ai_alerts(self, **kw):
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
context = request.params.get("context", "dashboard")
result = ai.generate_insights(
{"context": context, "request": "alerts"},
@@ -92,12 +116,13 @@ class AIController(http.Controller):
return _json_response({"alerts": []})
# ── POST /api/ai/report-narrative — AiReportNarrative.tsx ──
@http.route("/api/ai/report-narrative", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/ai/report-narrative", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_report_narrative(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
narrative = ai.generate_report_narrative(
body.get("report_type", "performance"),
body.get("data", {}),
@@ -107,12 +132,13 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/ai/batch-optimize", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_batch_optimize(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
result = ai.batch_optimize(
body.get("items", []),
optimization_type=body.get("type", "schedule"),
@@ -122,12 +148,13 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/ai/grade-suggest", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_grade_suggest(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
skill = body.get("skill", "writing")
if skill == "speaking":
result = ai.grade_speaking(
@@ -146,12 +173,13 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/ai/generate-resource", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_generate_resource(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
result = ai.generate_content_dedup(
body.get("content_type", "reading_passage"),
body.get("brief", {}),
@@ -162,7 +190,8 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/ai/detect", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_detect(self, **kw):
body = _get_json()
try:
@@ -174,7 +203,8 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/plagiarism/check", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def plagiarism_check(self, **kw):
body = _get_json()
try:
@@ -187,12 +217,13 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/domains/<int:domain_id>/ai-suggest", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_suggest_topics(self, domain_id, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
@@ -206,12 +237,13 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/learning-plan/generate", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def learning_plan_generate(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"Create a personalized learning plan. Return JSON: "
@@ -227,12 +259,13 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/workbench/generate-outline", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def workbench_outline(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"Generate a course outline. Return JSON: {\"chapters\": "
@@ -244,12 +277,13 @@ class AIController(http.Controller):
except Exception as e:
return _json_response({"chapters": [], "error": str(e)})
@http.route("/api/workbench/generate-chapter", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/workbench/generate-chapter", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def workbench_chapter(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"Generate detailed chapter content for a course. Return JSON: "
@@ -262,12 +296,13 @@ class AIController(http.Controller):
except Exception as e:
return _json_response({"content": "", "error": str(e)})
@http.route("/api/workbench/generate-rubric", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/workbench/generate-rubric", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def workbench_rubric(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"Create an assessment rubric. Return JSON: {\"rubric\": "
@@ -280,11 +315,13 @@ class AIController(http.Controller):
except Exception as e:
return _json_response({"rubric": None, "error": str(e)})
@http.route("/api/workbench/regenerate", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/workbench/regenerate", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def workbench_regenerate(self, **kw):
return self.workbench_chapter(**kw)
@http.route("/api/workbench/publish", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/workbench/publish", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def workbench_publish(self, **kw):
body = _get_json()
try:
@@ -315,7 +352,8 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/ai/suggest-rubric-criteria", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_suggest_rubric_criteria(self, **kw):
from odoo.addons.encoach_api.controllers.base import validate_token
user = validate_token()
@@ -331,7 +369,7 @@ class AIController(http.Controller):
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
if not ai.client:
raise RuntimeError("OpenAI not configured")
@@ -450,7 +488,8 @@ class AIController(http.Controller):
]
# ── Exam generation — GenerationPage.tsx ──
@http.route("/api/exam/<string:module>/generate", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/exam/<string:module>/generate", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def exam_generate(self, module, **kw):
from odoo.addons.encoach_api.controllers.base import validate_token
user = validate_token()
@@ -460,7 +499,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
has_ai = bool(ai.client)
except Exception:
ai, has_ai = None, False
@@ -1252,7 +1291,8 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/exam/generation/submit", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def generation_submit(self, **kw):
from odoo.addons.encoach_api.controllers.base import validate_token
user = validate_token()
@@ -1265,12 +1305,40 @@ class AIController(http.Controller):
if not title:
return _json_response({"error": "title is required"}, 400)
from odoo.addons.encoach_ai.services.question_validator import (
validate_payload, score_question, summarize_question_reports,
dumps_report, prompt_hash as _prompt_hash,
VERDICT_FAIL, VERDICT_WARN,
)
schema_report = validate_payload(body)
if schema_report["verdict"] == VERDICT_FAIL:
_logger.warning(
"generation_submit rejected %d schema errors: %s",
len(schema_report["errors"]), schema_report["errors"][:3],
)
return _json_response({
"error": "AI payload failed schema validation",
"details": schema_report,
}, 422)
label = body.get("label", "")
modules = body.get("modules", {})
skip_approval = body.get("skip_approval", False)
exam_mode = body.get("exam_mode", "official")
structure_id = body.get("structure_id")
ai_model_used = (
body.get("ai_model")
or request.env["ir.config_parameter"].sudo().get_param(
"encoach_ai.openai_fast_model", "gpt-4o-mini"
)
)
payload_hash = _prompt_hash(json.dumps(
{"title": title, "modules": modules}, sort_keys=True, default=str,
))
question_reports = [] # collected for aggregate gating below
first_mod = next(iter(modules.values()), {}) if modules else {}
entity_val = first_mod.get("entity", "none")
entity_id = int(entity_val) if entity_val and entity_val != "none" else False
@@ -1304,13 +1372,14 @@ class AIController(http.Controller):
except KeyError:
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
initial_status = "published" if skip_approval else "pending_approval"
exam_vals = {
"title": title,
"label": label,
"exam_mode": exam_mode,
"teacher_id": request.env.user.id,
"template_id": template_id,
"status": "published" if skip_approval else "draft",
"status": initial_status,
"total_time_min": sum(m.get("timer", 0) for m in modules.values()),
"total_marks": sum(float(m.get("totalMarks", 0)) for m in modules.values()),
"randomize_questions": any(m.get("shuffling", False) for m in modules.values()),
@@ -1344,6 +1413,27 @@ class AIController(http.Controller):
"form_completion": "form_completion", "map_labelling": "map_labelling",
}
now = fields.Datetime.now() if hasattr(fields, 'Datetime') else None
def _create_question(vals, *, cefr_level=None):
stem = vals.get("stem") or ""
q_report = score_question(
stem, skill=vals.get("skill"), cefr_level=cefr_level
)
question_reports.append(q_report)
status = vals.pop("status", "draft")
if q_report["verdict"] == VERDICT_FAIL:
status = "flagged"
vals.update({
"status": status,
"ai_model_used": ai_model_used,
"ai_prompt_hash": payload_hash,
"ai_generated_at": now,
"quality_score": q_report.get("score"),
"quality_report": dumps_report(q_report),
})
return Question.create(vals)
seq = 10
total_questions = 0
for mod_key, mod_data in modules.items():
@@ -1381,7 +1471,7 @@ class AIController(http.Controller):
for ex in (passage.get("exercises") or []):
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
opts = ex.get("options", [])
q = Question.create({
q = _create_question({
"skill": mod_key if mod_key in ("reading", "listening", "writing", "speaking", "grammar", "vocabulary", "math", "it") else "reading",
"source_type": "passage",
"question_type": q_type,
@@ -1392,7 +1482,7 @@ class AIController(http.Controller):
"difficulty": _q_difficulty_for(ex),
"status": "active",
"ai_generated": True,
})
}, cefr_level=(ex.get("cefr_level") or cefr_level))
question_ids.append(q.id)
sections_data = mod_data.get("sections") or []
@@ -1402,7 +1492,7 @@ class AIController(http.Controller):
for ex in (s_data.get("exercises") or []):
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
opts = ex.get("options", [])
q = Question.create({
q = _create_question({
"skill": "listening",
"source_type": "audio",
"question_type": q_type,
@@ -1413,12 +1503,12 @@ class AIController(http.Controller):
"difficulty": _q_difficulty_for(ex),
"status": "active",
"ai_generated": True,
})
}, cefr_level=(ex.get("cefr_level") or cefr_level))
question_ids.append(q.id)
tasks = mod_data.get("tasks") or []
for t_idx, task in enumerate(tasks):
q = Question.create({
q = _create_question({
"skill": "writing",
"source_type": "writing_prompt",
"question_type": "short_answer",
@@ -1429,12 +1519,12 @@ class AIController(http.Controller):
"difficulty": q_difficulty,
"status": "active",
"ai_generated": True,
})
}, cefr_level=cefr_level)
question_ids.append(q.id)
parts = mod_data.get("parts") or []
for p_idx, part in enumerate(parts):
q = Question.create({
q = _create_question({
"skill": "speaking",
"source_type": "speaking_card",
"question_type": "short_answer",
@@ -1445,7 +1535,7 @@ class AIController(http.Controller):
"difficulty": q_difficulty,
"status": "active",
"ai_generated": True,
})
}, cefr_level=cefr_level)
question_ids.append(q.id)
if question_ids:
@@ -1455,18 +1545,72 @@ class AIController(http.Controller):
})
total_questions += len(question_ids)
quality_summary = summarize_question_reports(question_reports)
if (
exam.status != "draft"
and quality_summary["verdict"] in (VERDICT_FAIL, VERDICT_WARN)
and quality_summary["total"] > 0
):
exam.sudo().write({"status": "pending_review"})
_logger.info(
"exam %s forced pending_review: avg_score=%.2f failed=%d warned=%d",
exam.id, quality_summary["avg_score"],
quality_summary["failed"], quality_summary["warned"],
)
# ── Route through the approval workflow ────────────────────────
# If the admin selected an approval workflow AND did not click
# "skip approval", create a real ``encoach.approval.request`` that
# lands in the first stage approver's queue. QA flagged that
# submitted modules were invisible to the assigned approver —
# before this block the exam was just left in ``draft`` with
# ``approval_workflow_id`` set but no request record routing it.
approval_request_id = False
if not skip_approval and workflow_id:
try:
Workflow = request.env["encoach.approval.workflow"].sudo()
workflow = Workflow.browse(workflow_id)
if workflow.exists() and workflow.stage_ids:
first_stage = workflow.stage_ids.sorted("sequence")[:1]
approval_req = request.env["encoach.approval.request"].sudo().create({
"workflow_id": workflow.id,
"res_model": "encoach.exam.custom",
"res_id": exam.id,
"state": "in_progress" if first_stage else "draft",
"requester_id": request.env.user.id,
"current_stage_id": first_stage.id if first_stage else False,
})
approval_request_id = approval_req.id
_logger.info(
"created approval.request %s for exam %s (workflow %s, stage %s)",
approval_req.id, exam.id, workflow.id,
first_stage.id if first_stage else None,
)
except Exception:
_logger.exception(
"failed to create approval request for exam %s workflow %s",
exam.id, workflow_id,
)
return _json_response({
"exam_id": exam.id,
"status": exam.status,
"template_id": template_id,
"total_questions": total_questions,
"approval_request_id": approval_request_id,
"quality": quality_summary,
"schema_validation": {
"verdict": schema_report["verdict"],
"warnings": schema_report["warnings"],
},
}, 201)
except Exception as e:
_logger.exception("generation submit failed")
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="public", methods=["POST"], csrf=False)
@http.route("/api/ai/batch-optimize/apply", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_batch_optimize_apply(self, **kw):
body = _get_json()
optimized = body.get("optimized", [])
@@ -1481,7 +1625,8 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def exam_generate_save(self, module, **kw):
from odoo.addons.encoach_api.controllers.base import validate_token
user = validate_token()
@@ -1521,12 +1666,13 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/workbench/suggest-materials", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def workbench_suggest_materials(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"You are an educational materials expert. Suggest learning materials "
@@ -1541,12 +1687,13 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def topic_generate_content(self, topic_id, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
result = ai.generate_content(
body.get("content_type", "explanation"),
{"topic_id": topic_id, **body},

View File

@@ -4,6 +4,10 @@ import json
import logging
from odoo import http
from odoo.http import request, Response
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response as _base_json_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
@@ -19,15 +23,29 @@ def _get_json():
return {}
def _request_language():
"""Read the caller's UI language from the ``Accept-Language`` header.
The frontend ``api-client`` automatically attaches this header from the
active i18n language so AI-generated text can be localized. Falls back
to English if the header is missing or malformed.
"""
try:
return request.httprequest.headers.get("Accept-Language", "") or ""
except Exception:
return ""
class CoachController(http.Controller):
"""Handles /api/coach/* endpoints consumed by frontend AI coaching components."""
def _get_coach(self):
from odoo.addons.encoach_ai.services.coach_service import CoachService
return CoachService(request.env)
return CoachService(request.env, language=_request_language())
# ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
@http.route("/api/coach/chat", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_chat(self, **kw):
body = _get_json()
try:
@@ -43,7 +61,8 @@ 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="public", methods=["GET"], csrf=False)
@http.route("/api/coach/tip", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def coach_tip(self, **kw):
context = request.params.get("context", "general")
try:
@@ -53,7 +72,8 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/coach/explain", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_explain(self, **kw):
body = _get_json()
try:
@@ -67,7 +87,8 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/coach/suggest", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_suggest(self, **kw):
body = _get_json()
try:
@@ -82,7 +103,8 @@ class CoachController(http.Controller):
})
# ── POST /api/coach/writing-help — AiWritingHelper.tsx ──
@http.route("/api/coach/writing-help", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/coach/writing-help", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_writing_help(self, **kw):
body = _get_json()
try:
@@ -97,7 +119,8 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/coach/hint", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def coach_hint(self, **kw):
body = _get_json()
try:

View File

@@ -0,0 +1,217 @@
"""Endpoints for submitting & aggregating AI feedback."""
import logging
from odoo import fields, http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
_error_response,
_get_json_body,
_json_response,
_paginate,
jwt_required,
)
_logger = logging.getLogger(__name__)
def _feedback_to_dict(row):
return {
"id": row.id,
"subject_type": row.subject_type,
"subject_id": row.subject_id,
"subject_key": row.subject_key,
"rating": row.rating,
"comment": row.comment or "",
"tags": (row.tags or "").split(",") if row.tags else [],
"prompt_key": row.prompt_key or None,
"prompt_version": row.prompt_version or None,
"ai_log_id": row.ai_log_id.id if row.ai_log_id else None,
"user_id": row.user_id.id,
"user_name": row.user_id.name,
"entity_id": row.entity_id.id if row.entity_id else None,
"course_id": row.course_id.id if row.course_id else None,
"status": row.status,
"create_date": (
fields.Datetime.to_string(row.create_date) if row.create_date else None
),
"resolved_at": (
fields.Datetime.to_string(row.resolved_at) if row.resolved_at else None
),
"resolution_notes": row.resolution_notes or "",
}
class EncoachAIFeedbackController(http.Controller):
"""Feedback write + admin triage endpoints."""
# ------------------------------------------------------------------
# POST /api/ai/feedback — student submits/updates feedback
#
# Upsert semantics: we enforce uniqueness per (user, subject_type,
# subject_id) at the DB layer, so the client doesn't need to know whether
# the row already exists.
# ------------------------------------------------------------------
@http.route("/api/ai/feedback", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def submit(self, **kw):
try:
body = _get_json_body() or {}
subject_type = body.get("subject_type")
subject_id = body.get("subject_id")
rating = body.get("rating")
if subject_type not in {
"question", "coach", "explanation", "translation", "narrative", "other",
}:
return _error_response("Invalid subject_type", 400)
if not isinstance(subject_id, int) or subject_id <= 0:
return _error_response("subject_id must be a positive integer", 400)
if rating not in {"up", "down"}:
return _error_response("rating must be 'up' or 'down'", 400)
comment = body.get("comment") or ""
if rating == "down" and not (comment or "").strip():
# We keep this a soft constraint so the UI can decide how
# strict to be; returning an error here gives clients a clear
# path to prompt the user when needed.
return _error_response(
"A short comment is required when rating is 'down'.", 400,
)
vals = {
"subject_type": subject_type,
"subject_id": subject_id,
"rating": rating,
"comment": comment,
"tags": (
",".join(body.get("tags") or [])
if isinstance(body.get("tags"), list)
else (body.get("tags") or "")
),
"prompt_key": body.get("prompt_key") or False,
"prompt_version": body.get("prompt_version") or 0,
"ai_log_id": body.get("ai_log_id") or False,
"entity_id": body.get("entity_id") or False,
"course_id": body.get("course_id") or False,
"user_id": request.env.user.id,
}
Feedback = request.env["encoach.ai.feedback"].sudo()
existing = Feedback.search([
("user_id", "=", request.env.user.id),
("subject_type", "=", subject_type),
("subject_id", "=", subject_id),
], limit=1)
with request.env.cr.savepoint():
if existing:
existing.write(vals)
row = existing
else:
row = Feedback.create(vals)
return _json_response(_feedback_to_dict(row), 200 if existing else 201)
except Exception as e:
_logger.exception("ai feedback submit failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai/feedback/summary?subject_type=question&subject_id=42
# ------------------------------------------------------------------
@http.route(
"/api/ai/feedback/summary", type="http", auth="none", methods=["GET"], csrf=False,
)
@jwt_required
def summary(self, subject_type=None, subject_id=None, **kw):
try:
if not subject_type or not subject_id:
return _error_response("subject_type and subject_id are required", 400)
Feedback = request.env["encoach.ai.feedback"].sudo()
domain = [
("subject_type", "=", subject_type),
("subject_id", "=", int(subject_id)),
]
rows = Feedback.search(domain)
up = sum(1 for r in rows if r.rating == "up")
down = sum(1 for r in rows if r.rating == "down")
my_row = Feedback.search([
*domain,
("user_id", "=", request.env.user.id),
], limit=1)
return _json_response({
"subject_type": subject_type,
"subject_id": int(subject_id),
"up": up,
"down": down,
"total": up + down,
"my_rating": my_row.rating if my_row else None,
"my_comment": my_row.comment or "" if my_row else "",
})
except Exception as e:
_logger.exception("ai feedback summary failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai/feedback — admin triage list
# ------------------------------------------------------------------
@http.route("/api/ai/feedback", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def list_feedback(self, **kw):
try:
if not request.env.user.has_group("base.group_system"):
return _error_response("Admin privileges required", 403)
Feedback = request.env["encoach.ai.feedback"].sudo()
domain = []
if kw.get("status"):
domain.append(("status", "=", kw["status"]))
if kw.get("rating"):
domain.append(("rating", "=", kw["rating"]))
if kw.get("subject_type"):
domain.append(("subject_type", "=", kw["subject_type"]))
if kw.get("prompt_key"):
domain.append(("prompt_key", "=", kw["prompt_key"]))
offset, limit, page = _paginate(kw)
total = Feedback.search_count(domain)
rows = Feedback.search(domain, offset=offset, limit=limit, order="create_date desc")
items = [_feedback_to_dict(r) for r in rows]
return _json_response({
"items": items,
"data": items,
"total": total,
"page": page,
"size": limit,
})
except Exception as e:
_logger.exception("ai feedback list failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai/feedback/<id>/resolve — admin triage
# ------------------------------------------------------------------
@http.route(
'/api/ai/feedback/<int:feedback_id>/resolve',
type="http", auth="none", methods=["POST"], csrf=False,
)
@jwt_required
def resolve(self, feedback_id, **kw):
try:
if not request.env.user.has_group("base.group_system"):
return _error_response("Admin privileges required", 403)
body = _get_json_body() or {}
status = body.get("status")
if status not in {"acknowledged", "fixed", "dismissed"}:
return _error_response(
"status must be one of acknowledged|fixed|dismissed", 400,
)
row = request.env["encoach.ai.feedback"].sudo().browse(feedback_id)
if not row.exists():
return _error_response("Feedback not found", 404)
with request.env.cr.savepoint():
row.write({
"status": status,
"resolved_by_id": request.env.user.id,
"resolved_at": fields.Datetime.now(),
"resolution_notes": body.get("notes") or "",
})
return _json_response(_feedback_to_dict(row))
except Exception as e:
_logger.exception("ai feedback resolve failed")
return _error_response(str(e), 500)

View File

@@ -5,6 +5,10 @@ import json
import logging
from odoo import http
from odoo.http import request, Response
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response as _base_json_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
@@ -52,7 +56,8 @@ class MediaController(http.Controller):
)
# ── POST /api/exam/listening/media — generate listening audio ──
@http.route("/api/exam/listening/media", type="http", auth="public", methods=["POST"], csrf=False)
@http.route("/api/exam/listening/media", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def listening_media(self, **kw):
body = _get_json()
text = body.get("text", "")
@@ -72,7 +77,8 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/exam/speaking/media", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def speaking_media(self, **kw):
body = _get_json()
text = body.get("text", "")
@@ -89,7 +95,8 @@ 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="public", methods=["GET"], csrf=False)
@http.route("/api/exam/avatars", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def list_avatars(self, **kw):
try:
from odoo.addons.encoach_ai.services.elai_service import ElaiService
@@ -100,7 +107,8 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/exam/avatar/video", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def create_avatar_video(self, **kw):
body = _get_json()
try:
@@ -116,7 +124,8 @@ 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="public", methods=["GET"], csrf=False)
@http.route("/api/exam/avatar/video/<string:video_id>", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def video_status(self, video_id, **kw):
try:
from odoo.addons.encoach_ai.services.elai_service import ElaiService
@@ -126,7 +135,8 @@ 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="public", methods=["POST"], csrf=False)
@http.route("/api/courses/ai-generate", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def ai_generate_course(self, **kw):
body = _get_json()
try:

View File

@@ -0,0 +1,219 @@
"""Admin endpoints for the versioned AI prompt editor."""
import logging
from odoo import fields, http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
_error_response,
_get_json_body,
_json_response,
_paginate,
jwt_required,
)
_logger = logging.getLogger(__name__)
def _prompt_to_dict(prompt, *, include_content=True):
data = {
"id": prompt.id,
"key": prompt.key,
"version": prompt.version,
"title": prompt.title or "",
"description": prompt.description or "",
"is_active": bool(prompt.is_active),
"variables": (prompt.variables or "").split(",") if prompt.variables else [],
"author_id": prompt.author_id.id if prompt.author_id else None,
"author_name": prompt.author_id.name if prompt.author_id else "",
"activated_at": (
fields.Datetime.to_string(prompt.activated_at) if prompt.activated_at else None
),
"create_date": (
fields.Datetime.to_string(prompt.create_date) if prompt.create_date else None
),
}
if include_content:
data["content"] = prompt.content or ""
return data
class EncoachAIPromptController(http.Controller):
"""Versioned prompt CRUD.
Auth: every endpoint requires a valid JWT. The write endpoints additionally
check for admin rights via ``res.users.has_group('base.group_system')`` so
non-admins can read prompts but not mutate them.
"""
# ------------------------------------------------------------------
# GET /api/ai/prompts — list keys (latest version per key)
# ------------------------------------------------------------------
@http.route("/api/ai/prompts", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def list_keys(self, **kw):
try:
Prompt = request.env["encoach.ai.prompt"].sudo()
# One row per key = the latest version. We fetch candidates then
# deduplicate in Python because Odoo's ORM doesn't expose DISTINCT
# ON ergonomically.
search = (kw.get("search") or "").strip()
domain = [("key", "ilike", search)] if search else []
# Sort by key then descending version so the first occurrence per
# key is the latest.
all_prompts = Prompt.search(domain, order="key asc, version desc")
seen = set()
latest = []
for p in all_prompts:
if p.key in seen:
continue
seen.add(p.key)
latest.append(p)
offset, per_page, page = _paginate(kw)
total = len(latest)
window = latest[offset : offset + per_page]
items = [_prompt_to_dict(p, include_content=False) for p in window]
# Attach version-count for the UI to show "v5 (of 7)" hints.
counts = {}
for p in all_prompts:
counts[p.key] = counts.get(p.key, 0) + 1
for entry in items:
entry["total_versions"] = counts.get(entry["key"], 1)
return _json_response({
"items": items,
"data": items,
"total": total,
"page": page,
"size": per_page,
})
except Exception as e:
_logger.exception("list ai prompts failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai/prompts/<key>/versions
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<string:key>/versions',
type="http", auth="none", methods=["GET"], csrf=False,
)
@jwt_required
def list_versions(self, key, **kw):
try:
Prompt = request.env["encoach.ai.prompt"].sudo()
prompts = Prompt.search([("key", "=", key)], order="version desc")
if not prompts:
return _error_response("Prompt key not found", 404)
items = [_prompt_to_dict(p, include_content=False) for p in prompts]
return _json_response({
"key": key,
"items": items,
"data": items,
"total": len(items),
"page": 1,
"size": len(items),
})
except Exception as e:
_logger.exception("list prompt versions failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai/prompts/<int:prompt_id>
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<int:prompt_id>',
type="http", auth="none", methods=["GET"], csrf=False,
)
@jwt_required
def get_version(self, prompt_id, **kw):
try:
prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id)
if not prompt.exists():
return _error_response("Prompt not found", 404)
return _json_response(_prompt_to_dict(prompt))
except Exception as e:
_logger.exception("get prompt failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai/prompts — create a new version of a key (or a new key)
# ------------------------------------------------------------------
@http.route("/api/ai/prompts", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def create_version(self, **kw):
try:
if not request.env.user.has_group("base.group_system"):
return _error_response("Admin privileges required", 403)
body = _get_json_body() or {}
key = (body.get("key") or "").strip()
content = body.get("content") or ""
title = (body.get("title") or "").strip()
if not key or not content.strip() or not title:
return _error_response(
"key, title, and content are required", 400,
)
vals = {
"key": key,
"title": title,
"description": body.get("description") or "",
"content": content,
"is_active": bool(body.get("activate", False)),
}
with request.env.cr.savepoint():
prompt = request.env["encoach.ai.prompt"].sudo().create(vals)
return _json_response(_prompt_to_dict(prompt), 201)
except Exception as e:
_logger.exception("create prompt version failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai/prompts/<id>/activate
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<int:prompt_id>/activate',
type="http", auth="none", methods=["POST"], csrf=False,
)
@jwt_required
def activate(self, prompt_id, **kw):
try:
if not request.env.user.has_group("base.group_system"):
return _error_response("Admin privileges required", 403)
prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id)
if not prompt.exists():
return _error_response("Prompt not found", 404)
with request.env.cr.savepoint():
prompt.write({"is_active": True})
return _json_response(_prompt_to_dict(prompt))
except Exception as e:
_logger.exception("activate prompt failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai/prompts/<id>/render — dry-run render with sample variables
# ------------------------------------------------------------------
@http.route(
'/api/ai/prompts/<int:prompt_id>/render',
type="http", auth="none", methods=["POST"], csrf=False,
)
@jwt_required
def render_preview(self, prompt_id, **kw):
try:
prompt = request.env["encoach.ai.prompt"].sudo().browse(prompt_id)
if not prompt.exists():
return _error_response("Prompt not found", 404)
body = _get_json_body() or {}
variables = body.get("variables") or {}
if not isinstance(variables, dict):
return _error_response("variables must be a JSON object", 400)
rendered = prompt.render(variables)
return _json_response({
"rendered": rendered,
"key": prompt.key,
"version": prompt.version,
})
except Exception as e:
_logger.exception("render prompt failed")
return _error_response(str(e), 400)

View File

@@ -0,0 +1,337 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<!--
Default AI agents + tools seeded on first install.
These are the *sensible defaults* the user asked for: every platform
pillar (course planning, weekly materials, exam generation, exercise
generation, LMS tutor, grading) gets a pre-configured LangGraph
agent so the system works out of the box. Admins edit the system
prompts, models, temperatures and tool bindings from
/admin/ai/prompts → Agents tab.
-->
<!-- ============================== TOOLS ============================== -->
<!-- Retrieval -->
<record id="ai_tool_resources_search" model="encoach.ai.tool">
<field name="key">resources.search</field>
<field name="name">Search resources</field>
<field name="category">retrieval</field>
<field name="description">Semantic search over the LMS resource library. Returns resource ids, titles and snippets. Use this BEFORE generating content so the agent reuses existing, approved materials instead of hallucinating.</field>
<field name="schema_json">{"type":"object","properties":{"query":{"type":"string","description":"Natural language search query"},"skill":{"type":"string","enum":["reading","writing","listening","speaking","grammar","vocabulary"]},"cefr_level":{"type":"string","enum":["pre_a1","a1","a2","b1","b2","c1","c2"]},"limit":{"type":"integer","default":5,"minimum":1,"maximum":20}},"required":["query"]}</field>
<field name="sequence">10</field>
</record>
<record id="ai_tool_rubric_fetch" model="encoach.ai.tool">
<field name="key">rubric.fetch</field>
<field name="name">Fetch rubric</field>
<field name="category">reference</field>
<field name="description">Return the grading rubric and criterion descriptors for a given rubric id or skill. Always call before grading so the LLM uses the approved rubric, not its own defaults.</field>
<field name="schema_json">{"type":"object","properties":{"rubric_id":{"type":"integer"},"skill":{"type":"string","enum":["reading","writing","listening","speaking"]}}}</field>
<field name="sequence">20</field>
</record>
<record id="ai_tool_outcomes_fetch" model="encoach.ai.tool">
<field name="key">outcomes.fetch</field>
<field name="name">Fetch course outcomes</field>
<field name="category">reference</field>
<field name="description">Return the registered learning outcomes for a course or CEFR level. Use it when generating course plans to stay aligned with the programme specification.</field>
<field name="schema_json">{"type":"object","properties":{"course_id":{"type":"integer"},"cefr_level":{"type":"string","enum":["pre_a1","a1","a2","b1","b2","c1","c2"]}}}</field>
<field name="sequence">30</field>
</record>
<record id="ai_tool_student_profile" model="encoach.ai.tool">
<field name="key">student.profile</field>
<field name="name">Get student gap profile</field>
<field name="category">reference</field>
<field name="description">Return the student's CEFR band, strengths and gaps so content can be personalised. Required input for personalised exercise generation and tutor follow-ups.</field>
<field name="schema_json">{"type":"object","properties":{"student_id":{"type":"integer"}},"required":["student_id"]}</field>
<field name="sequence">40</field>
</record>
<!-- Quality gates -->
<record id="ai_tool_quality_cefr" model="encoach.ai.tool">
<field name="key">quality.cefr_check</field>
<field name="name">CEFR readability check</field>
<field name="category">quality</field>
<field name="description">Check whether a text reads at the target CEFR level using Flesch-Kincaid. Returns ok=false with specific issues when the passage is too easy or too hard for the requested band.</field>
<field name="schema_json">{"type":"object","properties":{"text":{"type":"string"},"target_cefr":{"type":"string","enum":["a1","a2","b1","b2","c1","c2"]}},"required":["text","target_cefr"]}</field>
<field name="sequence">50</field>
</record>
<record id="ai_tool_quality_ai" model="encoach.ai.tool">
<field name="key">quality.ai_detect</field>
<field name="name">AI-content detection</field>
<field name="category">quality</field>
<field name="description">Probability the text was written by an AI (via GPTZero). Used during submission review — not usually during generation.</field>
<field name="schema_json">{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}</field>
<field name="sequence">60</field>
</record>
<record id="ai_tool_quality_gate" model="encoach.ai.tool">
<field name="key">quality.content_gate</field>
<field name="name">Unified content gate</field>
<field name="category">quality</field>
<field name="description">Run the project's combined content-source gate (CEFR + toxicity + length checks). Returns ok=false with the first failing rule.</field>
<field name="schema_json">{"type":"object","properties":{"text":{"type":"string"},"cefr_level":{"type":"string"}},"required":["text"]}</field>
<field name="sequence">70</field>
</record>
<!-- Persistence -->
<record id="ai_tool_course_plan_save" model="encoach.ai.tool">
<field name="key">course_plan.save</field>
<field name="name">Save course plan</field>
<field name="category">persistence</field>
<field name="mutates" eval="True"/>
<field name="description">Persist an AI-generated course plan header and its weekly rows. Only call once you're confident the JSON is valid and has been reviewed.</field>
<field name="schema_json">{"type":"object","properties":{"plan_vals":{"type":"object"},"weeks":{"type":"array","items":{"type":"object"}}},"required":["plan_vals"]}</field>
<field name="sequence">80</field>
</record>
<record id="ai_tool_course_plan_save_materials" model="encoach.ai.tool">
<field name="key">course_plan.save_materials</field>
<field name="name">Save weekly teaching materials</field>
<field name="category">persistence</field>
<field name="mutates" eval="True"/>
<field name="description">Persist the generated per-week teaching materials against an existing course plan and week.</field>
<field name="schema_json">{"type":"object","properties":{"plan_id":{"type":"integer"},"week_id":{"type":"integer"},"materials":{"type":"array","items":{"type":"object"}}},"required":["plan_id","week_id","materials"]}</field>
<field name="sequence">90</field>
</record>
<!-- Scoring -->
<record id="ai_tool_scoring_writing" model="encoach.ai.tool">
<field name="key">scoring.grade_writing</field>
<field name="name">Grade writing response</field>
<field name="category">scoring</field>
<field name="description">Grade a writing response against a rubric using the platform's standard writing examiner prompt.</field>
<field name="schema_json">{"type":"object","properties":{"rubric":{"type":"string"},"task":{"type":"string"},"response":{"type":"string"}},"required":["rubric","response"]}</field>
<field name="sequence">100</field>
</record>
<record id="ai_tool_scoring_speaking" model="encoach.ai.tool">
<field name="key">scoring.grade_speaking</field>
<field name="name">Grade speaking transcript</field>
<field name="category">scoring</field>
<field name="description">Grade a speaking transcript against a rubric using the platform's standard speaking examiner prompt.</field>
<field name="schema_json">{"type":"object","properties":{"rubric":{"type":"string"},"transcript":{"type":"string"}},"required":["rubric","transcript"]}</field>
<field name="sequence">110</field>
</record>
<!-- ============================== AGENTS ============================== -->
<!-- 1. Course planner -->
<record id="ai_agent_course_planner" model="encoach.ai.agent">
<field name="key">course_planner</field>
<field name="name">Course Planner</field>
<field name="description">Generates a full course plan (description, objectives, per-skill outcomes, grammar scope, assessment weights, week-by-week delivery) from a short brief. Used by the Smart Wizard and /api/ai/course-plan.</field>
<field name="model">gpt-4o</field>
<field name="fallback_model">gpt-4o-mini</field>
<field name="temperature">0.4</field>
<field name="max_tokens">4096</field>
<field name="response_format">json</field>
<field name="graph_type">plan_review_revise</field>
<field name="max_revisions">1</field>
<field name="quality_checks">quality.cefr_check</field>
<field name="sequence">10</field>
<field name="system_prompt">You are an expert English language curriculum designer. You produce structured, institution-grade course outlines suitable for a general foundation English programme.
Rules:
- Output MUST be a single valid JSON object matching the schema the user supplies.
- Use CEFR can-do statements when writing outcomes; cite the CEFR level in objectives.
- Distribute the weeks so grammar and skills build cumulatively, not randomly.
- Keep outcome codes short and stable (RLO1, WLO1, LLO1, SLO1, GLO1, VLO1) and reuse them in weeks[*].items[*].outcome_codes.
- Never wrap the JSON in prose, markdown, or code fences.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_outcomes_fetch'),
ref('ai_tool_resources_search'),
ref('ai_tool_quality_cefr'),
ref('ai_tool_course_plan_save'),
])]"/>
</record>
<!-- 2. Week materials -->
<record id="ai_agent_course_week_materials" model="encoach.ai.agent">
<field name="key">course_week_materials</field>
<field name="name">Week Teaching Materials</field>
<field name="description">Given a course plan and a week number, produces classroom-ready materials (reading passage, listening script, speaking prompt, writing prompt, grammar mini-lesson, vocabulary list) aligned to the registered outcomes.</field>
<field name="model">gpt-4o</field>
<field name="fallback_model">gpt-4o-mini</field>
<field name="temperature">0.6</field>
<field name="max_tokens">6000</field>
<field name="response_format">json</field>
<field name="graph_type">plan_review_revise</field>
<field name="max_revisions">1</field>
<field name="quality_checks">quality.cefr_check</field>
<field name="sequence">20</field>
<field name="system_prompt">You are an expert EFL teacher creating ready-to-use classroom materials.
Rules:
- Every material MUST target only the outcome codes supplied for that week.
- Keep reading passages within the CEFR band's word-count window (A1~80, A2~150, B1~250, B2~400, C1~600, C2~800 words).
- Listening scripts must be natural dialogue/monologue, 3-4 minutes, with 4-6 comprehension questions.
- Speaking prompts include useful-language chunks the learner can recycle.
- Grammar lesson: one clear rule + 3 examples + 5 practice items with answer keys.
- Vocabulary: 8-12 entries with part of speech, CEFR-appropriate definition, and an example sentence in context.
- Output valid JSON only; no prose or markdown around it.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_outcomes_fetch'),
ref('ai_tool_resources_search'),
ref('ai_tool_quality_cefr'),
ref('ai_tool_course_plan_save_materials'),
])]"/>
</record>
<!-- 3. Exam generator -->
<record id="ai_agent_exam_generator" model="encoach.ai.agent">
<field name="key">exam_generator</field>
<field name="name">Exam Generator</field>
<field name="description">Generates exam questions (MCQ, short answer, cloze, speaking prompts, writing tasks) matching a structure and blueprint.</field>
<field name="model">gpt-4o</field>
<field name="fallback_model">gpt-4o-mini</field>
<field name="temperature">0.5</field>
<field name="max_tokens">6000</field>
<field name="response_format">json</field>
<field name="graph_type">plan_review_revise</field>
<field name="max_revisions">1</field>
<field name="quality_checks">quality.cefr_check</field>
<field name="sequence">30</field>
<field name="system_prompt">You are a senior EFL / IELTS examiner generating authentic, validly constructed exam questions.
Rules:
- Follow the exam structure blueprint exactly: same number of sections, same question types, same scoring weights.
- Every MCQ has exactly one correct answer and three plausible distractors; avoid "all of the above".
- Reading/listening stems reference only content present in the accompanying passage/transcript.
- Never produce content outside the requested CEFR band.
- Output is a single JSON object; no explanations around it.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_resources_search'),
ref('ai_tool_outcomes_fetch'),
ref('ai_tool_rubric_fetch'),
ref('ai_tool_quality_cefr'),
])]"/>
</record>
<!-- 4. Personalised exercise generator -->
<record id="ai_agent_exercise_generator" model="encoach.ai.agent">
<field name="key">exercise_generator</field>
<field name="name">Personalised Exercise Generator</field>
<field name="description">Produces targeted practice items (gap-fill, reordering, short response) using a learner's gap profile to focus on their weakest outcomes.</field>
<field name="model">gpt-4o-mini</field>
<field name="fallback_model">gpt-4o</field>
<field name="temperature">0.7</field>
<field name="max_tokens">3000</field>
<field name="response_format">json</field>
<field name="graph_type">react</field>
<field name="max_revisions">0</field>
<field name="quality_checks"></field>
<field name="sequence">40</field>
<field name="system_prompt">You are a remedial English tutor generating short, focused practice items for one learner.
Workflow:
1. Call `student.profile` with the student_id you are given. Read their CEFR band and gap_json.
2. Optionally call `resources.search` to find an anchor text at the right level.
3. Then produce 6-10 practice items that target the biggest gaps. Prefer item types the learner has been scoring low on.
4. Each item has: prompt, correct_answer, distractors (for MCQ), brief rationale, target_outcome_code.
5. Output a JSON object {"items": [...]}.
Never fabricate gap data — if student.profile fails, ask for a student_id in your final message and emit an empty items list.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_student_profile'),
ref('ai_tool_resources_search'),
ref('ai_tool_outcomes_fetch'),
ref('ai_tool_quality_cefr'),
])]"/>
</record>
<!-- 5. LMS tutor / study assistant -->
<record id="ai_agent_lms_tutor" model="encoach.ai.agent">
<field name="key">lms_tutor</field>
<field name="name">LMS Tutor</field>
<field name="description">Chat assistant students talk to from inside a lesson. Can look up their profile, search the library, fetch outcomes, and answer questions about their course.</field>
<field name="model">gpt-4o-mini</field>
<field name="fallback_model">gpt-4o</field>
<field name="temperature">0.6</field>
<field name="max_tokens">1500</field>
<field name="response_format">text</field>
<field name="graph_type">react</field>
<field name="max_revisions">0</field>
<field name="quality_checks"></field>
<field name="sequence">50</field>
<field name="system_prompt">You are a friendly, encouraging English tutor inside the EnCoach LMS. You speak to learners directly.
Principles:
- Adapt vocabulary and sentence length to the learner's CEFR level.
- When the learner asks about their progress, call `student.profile` first.
- When they ask about a topic, prefer searching `resources.search` for approved materials before inventing examples.
- Be concrete: give one example, one practice question, one next step.
- Never invent scores or progress data; if a tool fails, tell the learner you'll flag the issue to their teacher.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_resources_search'),
ref('ai_tool_student_profile'),
ref('ai_tool_outcomes_fetch'),
])]"/>
</record>
<!-- 6. Writing grader -->
<record id="ai_agent_writing_grader" model="encoach.ai.agent">
<field name="key">writing_grader</field>
<field name="name">Writing Grader</field>
<field name="description">Grades a writing submission against its rubric and produces band scores, feedback, and targeted suggestions.</field>
<field name="model">gpt-4o</field>
<field name="fallback_model">gpt-4o-mini</field>
<field name="temperature">0.2</field>
<field name="max_tokens">1800</field>
<field name="response_format">json</field>
<field name="graph_type">simple</field>
<field name="max_revisions">0</field>
<field name="quality_checks"></field>
<field name="sequence">60</field>
<field name="system_prompt">You are a calibrated IELTS / EFL writing examiner.
Rules:
- Score every criterion in the rubric exactly once.
- Use only band values the rubric advertises (0-9 for IELTS, 0-100 or A1-C2 for other rubrics — follow what the user sends).
- Feedback must quote one line of evidence from the student's text before each judgement.
- Suggestions must be specific ("replace X with Y") not generic ("improve grammar").
- Output JSON: {"scores": {"criterion_code": number}, "overall_band": number, "feedback": string, "suggestions": [string]}.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_rubric_fetch'),
ref('ai_tool_scoring_writing'),
])]"/>
</record>
<!-- 7. Speaking evaluator -->
<record id="ai_agent_speaking_grader" model="encoach.ai.agent">
<field name="key">speaking_grader</field>
<field name="name">Speaking Evaluator</field>
<field name="description">Grades a speaking transcript against its rubric and produces band scores, feedback on fluency / pronunciation, and next-step drills.</field>
<field name="model">gpt-4o</field>
<field name="fallback_model">gpt-4o-mini</field>
<field name="temperature">0.2</field>
<field name="max_tokens">1800</field>
<field name="response_format">json</field>
<field name="graph_type">simple</field>
<field name="max_revisions">0</field>
<field name="quality_checks"></field>
<field name="sequence">70</field>
<field name="system_prompt">You are a calibrated IELTS Speaking examiner judging a transcript.
Rules:
- Only score what the transcript supports; pronunciation judgements must be flagged as indirect.
- Quote a line of the transcript before each criterion judgement.
- Suggestions prescribe one concrete drill (e.g. "practice minimal pairs /iː/ vs /ɪ/ for 2 weeks").
- Output JSON: {"scores": {"criterion_code": number}, "overall_band": number, "feedback": string, "suggestions": [string]}.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_rubric_fetch'),
ref('ai_tool_scoring_speaking'),
])]"/>
</record>
<!-- Feature flag: pipelines consult this before routing through AgentRuntime.
Default "True" so the defaults-ship-working contract holds. -->
<record id="ai_default_use_langgraph" model="ir.config_parameter">
<field name="key">encoach_ai.use_langgraph_runtime</field>
<field name="value">True</field>
</record>
</odoo>

View File

@@ -1,3 +1,6 @@
from . import ai_settings
from . import ai_log
from . import ai_prompt
from . import ai_feedback
from . import ai_agent
from . import constants

View File

@@ -0,0 +1,357 @@
"""LangGraph-backed AI agents and the tools they can invoke.
Architecture
------------
The platform already has:
* ``encoach.ai.prompt`` — versioned prompt *templates* with rendering.
* ``encoach.ai.log`` — per-call telemetry.
* ``OpenAIService`` — thin wrapper around the OpenAI Python SDK.
This module adds the **agent layer** that sits on top of those primitives:
* :py:class:`EncoachAIAgent` — a named, configurable agent (e.g.
``course_planner``, ``exam_generator``). Each agent has a system prompt,
model choice, temperature budget, a graph topology (``simple``,
``plan_review_revise`` or ``rag``) and an M2M list of tools it is
allowed to call.
* :py:class:`EncoachAITool` — a catalogue row describing one callable
capability. The *implementation* lives in
``encoach_ai.services.agent_tools`` keyed by :py:attr:`key`; this model
only stores the metadata (JSON Schema, human description, whether it
mutates data, which audiences may use it). Admins toggle tools on or
off per agent through the UI without touching Python code.
The runtime itself (LangGraph state machine, tool-routing loop, log
emission) is in :py:mod:`encoach_ai.services.agent_runtime`. Keeping the
models here and the execution engine there makes the runtime easy to
swap / upgrade without touching the DB schema.
"""
from __future__ import annotations
import json
import logging
import re
from odoo import api, fields, models
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
# Keys follow the same shape as prompt keys: ``lowercase.dotted.identifiers``.
_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)*$")
GRAPH_TYPES = [
# Single LLM call, no tools. Lowest latency, used for deterministic tasks.
("simple", "Simple (single LLM call)"),
# Plan → self-critique → optionally revise once. Used for long-form
# generation (course plans, exam papers) where we want quality checks.
("plan_review_revise", "Plan → Review → Revise"),
# Retrieval-augmented: runs the ``search_resources`` tool first, injects
# the hits as context, then calls the LLM. Used for curriculum-aware
# generation that must cite existing materials.
("rag", "Retrieval-augmented (RAG)"),
# LLM decides which tools to call via OpenAI function-calling, in a
# loop. Used for LMS tutor / study assistant / grading workflows that
# may need to fetch rubrics, student profiles, etc.
("react", "ReAct (tool-calling loop)"),
]
TOOL_CATEGORIES = [
("retrieval", "Retrieval"),
("persistence", "Persistence"),
("quality", "Quality & gating"),
("scoring", "Scoring & grading"),
("reference", "Reference lookup"),
("other", "Other"),
]
# Models we're OK advertising in the admin UI. Keep small — the whole point
# of the agent layer is to encapsulate model choice.
MODEL_CHOICES = [
("gpt-4o", "GPT-4o (quality)"),
("gpt-4o-mini", "GPT-4o mini (cheap / fast)"),
("gpt-4.1", "GPT-4.1"),
("gpt-4.1-mini", "GPT-4.1 mini"),
("gpt-3.5-turbo", "GPT-3.5 turbo (legacy)"),
]
# =============================================================================
# Tool catalogue
# =============================================================================
class EncoachAITool(models.Model):
_name = "encoach.ai.tool"
_description = "AI Agent Tool"
_order = "category, sequence, name"
key = fields.Char(
required=True,
index=True,
help="Stable dotted identifier (e.g. 'resources.search'). The Python "
"handler is resolved from this key via the agent_tools registry.",
)
name = fields.Char(required=True, translate=True)
description = fields.Text(
required=True, translate=True,
help="Shown to the LLM when the tool is exposed as a callable. "
"Keep it concrete: explain *when* to use the tool, not *how* it works.",
)
category = fields.Selection(
TOOL_CATEGORIES, required=True, default="other", index=True,
)
schema_json = fields.Text(
required=True,
default="{}",
help="JSON Schema (Draft-07 subset) describing the tool's parameters. "
"Passed verbatim to OpenAI function-calling.",
)
mutates = fields.Boolean(
default=False,
help="If True, the tool writes to the database. Used by the runtime "
"to wrap calls in a savepoint so a failed LLM step can't leave half-"
"created records behind.",
)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True, index=True)
_sql_constraints = [
("tool_key_uniq", "unique(key)", "Tool key must be unique."),
]
# ------------------------------------------------------------------
@api.constrains("key")
def _check_key(self):
for rec in self:
if not _KEY_RE.match(rec.key or ""):
raise ValidationError(
f"Invalid tool key {rec.key!r}. Use lowercase dotted identifiers."
)
@api.constrains("schema_json")
def _check_schema(self):
for rec in self:
try:
parsed = json.loads(rec.schema_json or "{}")
except Exception as exc:
raise ValidationError(f"schema_json must be valid JSON: {exc}")
if not isinstance(parsed, dict):
raise ValidationError("schema_json must be a JSON object.")
# ------------------------------------------------------------------
def to_api_dict(self):
self.ensure_one()
try:
schema = json.loads(self.schema_json or "{}")
except Exception:
schema = {}
return {
"id": self.id,
"key": self.key,
"name": self.name,
"description": self.description,
"category": self.category,
"schema": schema,
"mutates": bool(self.mutates),
"active": bool(self.active),
}
def to_openai_tool(self):
"""Render this row in the shape OpenAI function-calling expects."""
self.ensure_one()
try:
params = json.loads(self.schema_json or "{}")
except Exception:
params = {}
# Ensure the JSON Schema is OpenAI-compatible (an object with
# ``properties``). Fall back to an empty object if the author
# forgot to wrap parameters.
if "type" not in params:
params = {"type": "object", "properties": params.get("properties", {})}
return {
"type": "function",
"function": {
"name": self.key.replace(".", "__"),
"description": self.description or self.name,
"parameters": params,
},
}
# =============================================================================
# Agent
# =============================================================================
class EncoachAIAgent(models.Model):
_name = "encoach.ai.agent"
_description = "AI Agent"
_order = "sequence, name"
key = fields.Char(
required=True,
index=True,
help="Stable dotted identifier the platform uses to resolve this "
"agent (e.g. 'course_planner'). Pipelines look the agent up by key "
"via AgentRuntime.from_key().",
)
name = fields.Char(required=True, translate=True)
description = fields.Text(translate=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True, index=True)
# ------------------------------------------------------------------
# Prompt & model wiring
# ------------------------------------------------------------------
system_prompt = fields.Text(
required=True,
translate=False,
help="System message sent to the LLM. Referenced variables can be "
"filled in by the caller via AgentRuntime.invoke(variables=...).",
)
prompt_key = fields.Char(
help="Optional: key of an encoach.ai.prompt record. When set, the "
"active version of that prompt *overrides* system_prompt at runtime. "
"Use this when you want prompt authors to iterate without touching "
"the agent config.",
)
model = fields.Selection(
MODEL_CHOICES, required=True, default="gpt-4o",
help="OpenAI chat model used for this agent's LLM calls.",
)
fallback_model = fields.Selection(
MODEL_CHOICES, default="gpt-4o-mini",
help="Model tried automatically if the primary model fails with a "
"5xx / rate-limit error. Leave blank to disable the fallback.",
)
temperature = fields.Float(
default=0.4,
help="0.0 = deterministic, 1.0 = very creative. 0.3-0.5 is a sane "
"default for structured-JSON generation.",
)
max_tokens = fields.Integer(default=4096)
response_format = fields.Selection(
[("text", "Text"), ("json", "JSON object")],
default="json",
help="`json` enables OpenAI's JSON mode and asks the LLM to return "
"parseable JSON. Use `text` for free-form output (tutor chat, "
"coach feedback).",
)
# ------------------------------------------------------------------
# Graph & tools
# ------------------------------------------------------------------
graph_type = fields.Selection(
GRAPH_TYPES, required=True, default="simple", index=True,
help="Which LangGraph topology to use when invoking this agent.",
)
max_revisions = fields.Integer(
default=1,
help="For `plan_review_revise`: cap on how many times the agent may "
"revise its own draft before emitting the final answer.",
)
quality_checks = fields.Char(
default="",
help="Comma-separated list of quality-gate tool keys to run after "
"generation (e.g. 'quality.cefr_check,quality.ai_detect'). Used by "
"the `plan_review_revise` topology.",
)
tool_ids = fields.Many2many(
"encoach.ai.tool",
"encoach_ai_agent_tool_rel",
"agent_id", "tool_id",
string="Tools",
help="Tools this agent is allowed to call. For `react` graphs, "
"these are exposed to the LLM as OpenAI function-calling tools; "
"for `rag` / `plan_review_revise`, only tools whose key matches a "
"pre-defined hook (e.g. `resources.search` for RAG, "
"`quality.*` for review) are executed.",
)
tool_count = fields.Integer(compute="_compute_tool_count", store=False)
_sql_constraints = [
("agent_key_uniq", "unique(key)", "Agent key must be unique."),
]
# ------------------------------------------------------------------
@api.depends("tool_ids")
def _compute_tool_count(self):
for rec in self:
rec.tool_count = len(rec.tool_ids)
@api.constrains("key")
def _check_key(self):
for rec in self:
if not _KEY_RE.match(rec.key or ""):
raise ValidationError(
f"Invalid agent key {rec.key!r}. "
"Use lowercase dotted identifiers (e.g. 'course_planner')."
)
@api.constrains("temperature")
def _check_temperature(self):
for rec in self:
if rec.temperature < 0.0 or rec.temperature > 2.0:
raise ValidationError("Temperature must be between 0.0 and 2.0")
# ------------------------------------------------------------------
# Lookup helpers
# ------------------------------------------------------------------
@api.model
def get_by_key(self, key):
"""Return the active agent for ``key`` or an empty recordset."""
return self.sudo().search(
[("key", "=", key), ("active", "=", True)], limit=1,
)
def resolved_system_prompt(self, variables=None):
"""Resolve the prompt to send: versioned prompt (if set) or inline.
``variables`` are passed to ``encoach.ai.prompt.render`` when the
agent is bound to a prompt key.
"""
self.ensure_one()
variables = variables or {}
if self.prompt_key:
prompt = self.env["encoach.ai.prompt"].sudo().get_active(self.prompt_key)
if prompt:
try:
return prompt.render(variables)
except UserError:
# Missing variables — fall back to the inline prompt so
# the caller still gets *something* and logs tell us
# exactly which variable was missing.
_logger.warning(
"Prompt %s v%s has unfilled variables; falling back "
"to inline system_prompt for agent %s",
prompt.key, prompt.version, self.key,
)
return self.system_prompt or ""
def to_api_dict(self, *, include_prompt=True):
self.ensure_one()
data = {
"id": self.id,
"key": self.key,
"name": self.name,
"description": self.description or "",
"model": self.model,
"fallback_model": self.fallback_model or "",
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"response_format": self.response_format,
"graph_type": self.graph_type,
"max_revisions": self.max_revisions,
"quality_checks": [
k.strip() for k in (self.quality_checks or "").split(",") if k.strip()
],
"prompt_key": self.prompt_key or "",
"tool_count": len(self.tool_ids),
"tool_keys": self.tool_ids.mapped("key"),
"active": bool(self.active),
}
if include_prompt:
data["system_prompt"] = self.system_prompt or ""
return data

View File

@@ -0,0 +1,134 @@
"""Student feedback on AI-generated content (thumbs up/down).
This closes the AI quality loop started by the human-review workflow (P3.3)
and the versioned prompts (P3.4):
* Every AI-generated artefact (a question, a coach reply, an explanation, a
translation, ...) can have any number of feedback rows attached to it.
* Feedback is always coupled to a **subject type + subject id** so it can
point at anything (``encoach.question``, ``encoach.ai.log``, ...) without
forcing a hard FK per model.
* When the student flags feedback as negative, we also store their free-text
note so product can triage.
Downstream consumers (prompt library, RAG indexers, report dashboards) can
read ``encoach.ai.feedback`` to compute win rates per prompt key, per model,
per entity, etc.
"""
from __future__ import annotations
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class EncoachAIFeedback(models.Model):
_name = "encoach.ai.feedback"
_description = "Student feedback on AI-generated content"
_order = "create_date desc"
_rec_name = "subject_key"
# ------------------------------------------------------------------
# What was rated
# ------------------------------------------------------------------
subject_type = fields.Selection(
[
("question", "Exam question"),
("coach", "AI Coach reply"),
("explanation", "Content explanation"),
("translation", "Translation"),
("narrative", "Report narrative"),
("other", "Other AI output"),
],
required=True, index=True,
)
subject_id = fields.Integer(
required=True, index=True,
help="Id of the rated artefact (semantics depend on subject_type).",
)
subject_key = fields.Char(
compute="_compute_subject_key", store=True, index=True,
help="Denormalised '<type>:<id>' key for quick lookups/aggregations.",
)
# Optional link to the AI call that produced this artefact. When set, it
# lets us correlate feedback with the underlying model/prompt used.
ai_log_id = fields.Many2one(
"encoach.ai.log", ondelete="set null", index=True,
)
prompt_key = fields.Char(
index=True,
help="If the artefact was produced via encoach.ai.prompt, record the "
"key here so we can compute win-rates per prompt.",
)
prompt_version = fields.Integer()
# ------------------------------------------------------------------
# Rating
# ------------------------------------------------------------------
rating = fields.Selection(
[("up", "Thumbs up"), ("down", "Thumbs down")],
required=True, index=True,
)
comment = fields.Text(
help="Free-text note. Required when rating is 'down' and the UI "
"prompts the student for a reason.",
)
tags = fields.Char(
help="Comma-separated tags (e.g. 'wrong-answer,confusing-stem').",
)
# ------------------------------------------------------------------
# Who / context
# ------------------------------------------------------------------
user_id = fields.Many2one(
"res.users", required=True, index=True,
default=lambda self: self.env.user,
)
entity_id = fields.Many2one(
"encoach.entity", ondelete="set null", index=True,
)
course_id = fields.Many2one(
"op.course", ondelete="set null", index=True,
)
# ------------------------------------------------------------------
# Triage status (filled in by admins/coaches reviewing feedback).
# ------------------------------------------------------------------
status = fields.Selection(
[
("open", "Open"),
("acknowledged", "Acknowledged"),
("fixed", "Fixed"),
("dismissed", "Dismissed"),
],
default="open", index=True,
)
resolved_by_id = fields.Many2one(
"res.users", ondelete="set null",
)
resolved_at = fields.Datetime()
resolution_notes = fields.Text()
# ------------------------------------------------------------------
# Constraints
# ------------------------------------------------------------------
_sql_constraints = [
(
"uniq_user_subject",
"unique(user_id, subject_type, subject_id)",
"A user can only leave one feedback row per AI artefact. "
"Subsequent submissions should update the existing row.",
),
]
@api.depends("subject_type", "subject_id")
def _compute_subject_key(self):
for rec in self:
rec.subject_key = f"{rec.subject_type or ''}:{rec.subject_id or 0}"
@api.constrains("subject_type", "subject_id")
def _check_subject(self):
for rec in self:
if not rec.subject_type or not rec.subject_id:
raise ValidationError("subject_type and subject_id are required.")

View File

@@ -0,0 +1,213 @@
"""Versioned AI prompt templates.
Context (ADR-0004, P3.4 hardening release): we want non-engineers to iterate
on LLM prompts without shipping code. Every time an author edits a prompt we
keep the previous revision around so we can A/B test, audit, and roll back.
Design:
* A **prompt** is identified by its ``key`` (e.g. ``exam.mcq.generate``) — a
stable, dotted string that callers reference from Python.
* Each save produces a new **version** row (bumped ``version`` int). The old
rows stay with ``is_active = False`` so history is preserved; only one row
per ``key`` can be active at a time (enforced in ``write``/``create``).
* Callers look the prompt up with ``get_active(key)`` and render it with
``render(variables)``. Rendering uses :py:meth:`str.format_map` so templates
are just ``{variable}`` placeholders — no new DSL to learn.
This module intentionally does **not** call the LLM itself. It is a
content-management layer that the existing ``encoach_ai.services.*`` pipelines
can adopt at their own pace (falling back to their hard-coded strings until a
prompt with the matching key is created in the UI).
"""
from __future__ import annotations
import logging
import re
from typing import Iterable
from odoo import api, fields, models
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
# Keys look like ``namespace.sub_namespace.name`` — lowercase, dotted.
_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
# Variables pulled from ``{foo}`` / ``{foo.bar}`` placeholders.
_VAR_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_.]*)\}")
class EncoachAIPrompt(models.Model):
_name = "encoach.ai.prompt"
_description = "Versioned AI prompt template"
_order = "key, version desc"
_rec_name = "display_name"
key = fields.Char(
required=True,
index=True,
help="Stable dotted identifier (e.g. 'exam.mcq.generate'). "
"Callers reference this from Python.",
)
version = fields.Integer(required=True, default=1, index=True)
title = fields.Char(
required=True,
help="Short human label shown in the admin editor.",
)
description = fields.Text(
help="Author intent, expected variables, known limitations.",
)
content = fields.Text(
required=True,
help="Template body. Use {variable} placeholders; "
"render() fills them via str.format_map.",
)
is_active = fields.Boolean(
default=False, index=True,
help="Exactly one active row per key. Creating/activating a new row "
"automatically deactivates the previous active version.",
)
author_id = fields.Many2one("res.users", ondelete="set null", readonly=True)
activated_at = fields.Datetime(readonly=True)
# Denormalised for quick display/search. Recomputed on save.
variables = fields.Char(
compute="_compute_variables", store=True,
help="Comma-separated list of {variable} placeholders found in content.",
)
display_name = fields.Char(
compute="_compute_display_name", store=True,
)
_sql_constraints = [
(
"key_version_uniq",
"unique(key, version)",
"A prompt version must be unique for its key.",
),
]
# ------------------------------------------------------------------
# Computed fields
# ------------------------------------------------------------------
@api.depends("content")
def _compute_variables(self):
for rec in self:
if not rec.content:
rec.variables = ""
continue
found = sorted(set(_VAR_RE.findall(rec.content)))
rec.variables = ",".join(found)
@api.depends("key", "version", "title")
def _compute_display_name(self):
for rec in self:
rec.display_name = f"{rec.key} v{rec.version}{rec.title or ''}".rstrip("")
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
@api.constrains("key")
def _check_key_shape(self):
for rec in self:
if not rec.key or not _KEY_RE.match(rec.key):
raise ValidationError(
f"Invalid prompt key {rec.key!r}. "
"Use lowercase dotted identifiers like 'exam.mcq.generate'."
)
@api.constrains("content")
def _check_content_non_empty(self):
for rec in self:
if not (rec.content or "").strip():
raise ValidationError("Prompt content cannot be empty.")
# ------------------------------------------------------------------
# Create / write — enforce "one active per key"
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
# Default version = max(existing) + 1 when caller omits it.
for vals in vals_list:
if "version" not in vals or not vals.get("version"):
key = vals.get("key")
existing = self.search(
[("key", "=", key)], order="version desc", limit=1,
)
vals["version"] = (existing.version + 1) if existing else 1
vals.setdefault("author_id", self.env.user.id)
if vals.get("is_active"):
vals.setdefault("activated_at", fields.Datetime.now())
records = super().create(vals_list)
# Post-create: enforce exclusivity for any rows created as active.
for rec in records:
if rec.is_active:
rec._deactivate_siblings()
return records
def write(self, vals):
res = super().write(vals)
if vals.get("is_active"):
now = fields.Datetime.now()
for rec in self:
if rec.is_active and not rec.activated_at:
super(EncoachAIPrompt, rec).write({"activated_at": now})
rec._deactivate_siblings()
return res
def _deactivate_siblings(self):
for rec in self:
if not rec.is_active:
continue
siblings = self.search([
("key", "=", rec.key),
("id", "!=", rec.id),
("is_active", "=", True),
])
if siblings:
siblings.write({"is_active": False})
# ------------------------------------------------------------------
# Public API (called by pipelines / controllers)
# ------------------------------------------------------------------
@api.model
def get_active(self, key: str):
"""Return the active prompt record for ``key`` (or an empty set)."""
return self.sudo().search(
[("key", "=", key), ("is_active", "=", True)], limit=1,
)
def render(self, variables: dict | None = None) -> str:
"""Fill ``{placeholders}`` using ``variables`` (dict)."""
self.ensure_one()
variables = variables or {}
missing = self._missing_variables(variables.keys())
if missing:
raise UserError(
f"Missing prompt variables for {self.key} v{self.version}: "
f"{', '.join(sorted(missing))}"
)
try:
return self.content.format_map(_SafeFormatDict(variables))
except Exception as exc:
_logger.exception("prompt render failed for %s v%s", self.key, self.version)
raise UserError(f"Prompt render failed: {exc}") from exc
def _missing_variables(self, provided: Iterable[str]) -> set[str]:
self.ensure_one()
required = set(_VAR_RE.findall(self.content or ""))
provided_root = {p.split(".")[0] for p in provided}
return {v for v in required if v.split(".")[0] not in provided_root}
class _SafeFormatDict(dict):
"""Dict that returns ``''`` for dotted/nested lookups str.format can't resolve.
We don't advertise ``{foo.bar}`` as supported (the editor shows top-level
variables only), but we also don't want rendering to explode if a caller
passes an object with attributes.
"""
def __missing__(self, key):
return "{" + key + "}"

View File

@@ -1,3 +1,11 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_ai_log_admin,encoach.ai.log admin,model_encoach_ai_log,base.group_system,1,1,1,1
access_ai_log_user,encoach.ai.log user,model_encoach_ai_log,base.group_user,1,0,1,0
access_ai_prompt_admin,encoach.ai.prompt admin,model_encoach_ai_prompt,base.group_system,1,1,1,1
access_ai_prompt_user,encoach.ai.prompt user,model_encoach_ai_prompt,base.group_user,1,0,0,0
access_ai_feedback_admin,encoach.ai.feedback admin,model_encoach_ai_feedback,base.group_system,1,1,1,1
access_ai_feedback_user,encoach.ai.feedback user,model_encoach_ai_feedback,base.group_user,1,1,1,0
access_ai_agent_admin,encoach.ai.agent admin,model_encoach_ai_agent,base.group_system,1,1,1,1
access_ai_agent_user,encoach.ai.agent user,model_encoach_ai_agent,base.group_user,1,0,0,0
access_ai_tool_admin,encoach.ai.tool admin,model_encoach_ai_tool,base.group_system,1,1,1,1
access_ai_tool_user,encoach.ai.tool user,model_encoach_ai_tool,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ai_log_admin encoach.ai.log admin model_encoach_ai_log base.group_system 1 1 1 1
3 access_ai_log_user encoach.ai.log user model_encoach_ai_log base.group_user 1 0 1 0
4 access_ai_prompt_admin encoach.ai.prompt admin model_encoach_ai_prompt base.group_system 1 1 1 1
5 access_ai_prompt_user encoach.ai.prompt user model_encoach_ai_prompt base.group_user 1 0 0 0
6 access_ai_feedback_admin encoach.ai.feedback admin model_encoach_ai_feedback base.group_system 1 1 1 1
7 access_ai_feedback_user encoach.ai.feedback user model_encoach_ai_feedback base.group_user 1 1 1 0
8 access_ai_agent_admin encoach.ai.agent admin model_encoach_ai_agent base.group_system 1 1 1 1
9 access_ai_agent_user encoach.ai.agent user model_encoach_ai_agent base.group_user 1 0 0 0
10 access_ai_tool_admin encoach.ai.tool admin model_encoach_ai_tool base.group_system 1 1 1 1
11 access_ai_tool_user encoach.ai.tool user model_encoach_ai_tool base.group_user 1 0 0 0

View File

@@ -5,3 +5,7 @@ from .elevenlabs_service import ElevenLabsService
from .gptzero_service import GPTZeroService
from .elai_service import ElaiService
from .coach_service import CoachService
from . import cefr_mapper # canonical CEFR / band / theta mapper (P0.9)
from . import question_validator # schema + quality gate for AI-generated questions (P1.6/P1.1)
from . import agent_tools # registry of tool handlers used by AgentRuntime
from .agent_runtime import AgentRuntime # LangGraph-backed core agent runtime

View File

@@ -0,0 +1,500 @@
"""LangGraph-based agent runtime.
This is the core AI engine for EnCoach — every pipeline that used to call
``OpenAIService`` directly can instead go through an
:py:class:`AgentRuntime` loaded from a named :py:class:`encoach.ai.agent`
row. That buys us:
* A single place to reason about retries, logging, tool execution and
self-review, instead of the same boilerplate inside every pipeline.
* Admins editing prompts, model choice, temperature or enabled tools in
the UI without redeploys.
* A consistent shape (``invoke(variables, payload)``) across course
planning, exam generation, LMS tutor, grading, etc.
Graph topologies
----------------
Each agent picks one of four graphs. They're all built on the same
:py:class:`AgentState` TypedDict so upgrading an agent from one topology
to another only means flipping a selection field.
``simple``
``START → llm → END``. The workhorse — used for deterministic
JSON generation (course plan header, exam question batches).
``plan_review_revise``
``START → llm → review → [revise → llm]? → END``. ``review`` runs
every configured quality tool (``quality.cefr_check`` etc.); if any
returns ``ok=False`` we ask the LLM to revise once, capped by
``max_revisions`` on the agent. Keeps structured outputs (reading
passages, listening scripts) inside their CEFR band.
``rag``
``START → retrieve → llm → END``. Runs ``resources.search`` before
the LLM and injects the hits as extra system context. Used for
curriculum-aware generation that must cite real library material.
``react``
Classic tool-calling loop. The LLM is given the OpenAI-format tool
list and can call tools in a loop until it emits a final answer.
Used for the LMS tutor / study assistant.
We depend on ``langgraph`` for the orchestration (state machine,
conditional edges) but still call OpenAI through the existing
:py:class:`OpenAIService` so the API key wiring, retry behaviour and
``encoach.ai.log`` rows keep working unchanged.
"""
from __future__ import annotations
import json
import logging
import time
from typing import Any, TypedDict
from odoo.tools import config as odoo_config # noqa: F401 (future use)
from . import agent_tools
from .openai_service import OpenAIService
_logger = logging.getLogger(__name__)
# =============================================================================
# State
# =============================================================================
class AgentState(TypedDict, total=False):
"""Shared state passed between every node of the graph."""
messages: list[dict] # chat history sent to the LLM
output: Any # parsed final answer (dict or string)
output_raw: str # the raw LLM text (for logging)
tool_calls: list[dict] # pending tool_calls emitted by the LLM
tool_results: list[dict] # results of executed tools, appended
quality_issues: list[str] # issues collected by the review node
revisions_used: int # revision counter (capped by max_revisions)
variables: dict # caller-supplied variables (for prompt rendering)
retrieval: list[dict] # hits from the retrieval node (RAG)
iterations: int # guard against runaway ReAct loops
error: str # populated on fatal failure
# =============================================================================
# AgentRuntime
# =============================================================================
class AgentRuntime:
"""Wraps an :py:class:`encoach.ai.agent` row with a compiled LangGraph."""
MAX_REACT_ITERATIONS = 6 # hard cap on tool-calling loops
# ------------------------------------------------------------------
# Factories
# ------------------------------------------------------------------
def __init__(self, env, agent, *, language: str | None = None):
self.env = env
self.agent = agent
self.language = language
self.ai = OpenAIService(env, language=language)
self._graph = None # lazily compiled
@classmethod
def from_key(cls, env, key: str, *, language: str | None = None):
"""Factory: load the active agent with ``key`` and build its runtime.
Returns ``None`` if no agent is configured for that key — callers
can decide whether to fall back to their legacy direct-SDK path.
"""
agent = env["encoach.ai.agent"].sudo().get_by_key(key)
if not agent:
return None
return cls(env, agent, language=language)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def invoke(self, variables: dict | None = None, payload: Any = None,
*, extra_system: str = "") -> AgentState:
"""Run the agent's graph end-to-end and return the terminal state.
``variables`` are substituted into the system prompt (if the agent
is bound to a prompt_key). ``payload`` becomes the first user
message; pass a dict to have it rendered as JSON, or a string to
pass it through verbatim. ``extra_system`` lets callers tack on
a context block without touching the agent's stored prompt.
"""
t0 = time.time()
graph = self._compile()
initial = self._initial_state(variables or {}, payload, extra_system)
try:
# LangGraph is sync here — we're already inside an Odoo
# request worker so sticking with sync keeps the control
# flow simple.
final: AgentState = graph.invoke(initial)
except Exception as exc:
_logger.exception("agent %s crashed", self.agent.key)
final = {**initial, "error": str(exc)}
latency_ms = int((time.time() - t0) * 1000)
self._log(final, latency_ms)
return final
# ------------------------------------------------------------------
# Graph construction
# ------------------------------------------------------------------
def _compile(self):
if self._graph is not None:
return self._graph
try:
from langgraph.graph import StateGraph, START, END
except ImportError as exc:
raise RuntimeError(
"LangGraph is not installed. "
"Add `langgraph>=0.2.0` to backend/requirements.txt and pip install."
) from exc
g = StateGraph(AgentState)
if self.agent.graph_type == "simple":
g.add_node("llm", self._node_llm)
g.add_edge(START, "llm")
g.add_edge("llm", END)
elif self.agent.graph_type == "rag":
g.add_node("retrieve", self._node_retrieve)
g.add_node("llm", self._node_llm)
g.add_edge(START, "retrieve")
g.add_edge("retrieve", "llm")
g.add_edge("llm", END)
elif self.agent.graph_type == "plan_review_revise":
g.add_node("llm", self._node_llm)
g.add_node("review", self._node_review)
g.add_edge(START, "llm")
g.add_edge("llm", "review")
g.add_conditional_edges(
"review",
self._route_after_review,
{"revise": "llm", "done": END},
)
elif self.agent.graph_type == "react":
g.add_node("llm", self._node_llm_tools)
g.add_node("tools", self._node_tools)
g.add_edge(START, "llm")
g.add_conditional_edges(
"llm",
self._route_after_llm_tools,
{"tools": "tools", "done": END},
)
g.add_edge("tools", "llm")
else:
raise ValueError(f"Unknown graph_type: {self.agent.graph_type}")
self._graph = g.compile()
return self._graph
# ------------------------------------------------------------------
# Initial state
# ------------------------------------------------------------------
def _initial_state(self, variables: dict, payload: Any, extra_system: str) -> AgentState:
system_prompt = self.agent.resolved_system_prompt(variables)
messages: list[dict] = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
if extra_system:
messages.append({"role": "system", "content": extra_system})
if payload is not None:
user_content = (
payload if isinstance(payload, str)
else json.dumps(payload, ensure_ascii=False)
)
messages.append({"role": "user", "content": user_content})
return {
"messages": messages,
"output": None,
"output_raw": "",
"tool_calls": [],
"tool_results": [],
"quality_issues": [],
"revisions_used": 0,
"variables": variables,
"retrieval": [],
"iterations": 0,
"error": "",
}
# ------------------------------------------------------------------
# Nodes
# ------------------------------------------------------------------
def _node_llm(self, state: AgentState) -> AgentState:
"""Plain LLM call respecting the agent's model + response_format."""
messages = list(state.get("messages") or [])
model = self.agent.model
action = f"agent.{self.agent.key}"
try:
if self.agent.response_format == "json":
content = self.ai.chat_json(
messages,
model=model,
temperature=self.agent.temperature,
max_tokens=self.agent.max_tokens,
action=action,
)
raw = json.dumps(content, ensure_ascii=False)
else:
raw = self.ai.chat(
messages,
model=model,
temperature=self.agent.temperature,
max_tokens=self.agent.max_tokens,
action=action,
)
content = raw
except Exception as exc:
# Try the fallback model exactly once before giving up.
if self.agent.fallback_model and model != self.agent.fallback_model:
_logger.warning(
"agent %s primary model %s failed (%s); retrying with %s",
self.agent.key, model, exc, self.agent.fallback_model,
)
try:
if self.agent.response_format == "json":
content = self.ai.chat_json(
messages, model=self.agent.fallback_model,
temperature=self.agent.temperature,
max_tokens=self.agent.max_tokens,
action=f"{action}.fallback",
)
raw = json.dumps(content, ensure_ascii=False)
else:
raw = self.ai.chat(
messages, model=self.agent.fallback_model,
temperature=self.agent.temperature,
max_tokens=self.agent.max_tokens,
action=f"{action}.fallback",
)
content = raw
except Exception as exc2:
return {**state, "error": str(exc2)}
else:
return {**state, "error": str(exc)}
new_messages = messages + [{"role": "assistant", "content": raw}]
return {
**state,
"messages": new_messages,
"output": content,
"output_raw": raw,
}
def _node_retrieve(self, state: AgentState) -> AgentState:
"""RAG node: call resources.search with the user's payload as query."""
variables = state.get("variables") or {}
query = ""
# The last user message is our best default query.
for m in reversed(state.get("messages") or []):
if m.get("role") == "user":
query = m.get("content") or ""
break
query = variables.get("query") or query
hits = agent_tools.invoke(self.env, "resources.search", {
"query": query[:1000],
"limit": 5,
})
items = hits.get("items") or []
context = self._format_retrieval(items)
messages = list(state.get("messages") or [])
if context:
# Insert the context block *after* the system prompt(s).
last_sys = -1
for i, m in enumerate(messages):
if m.get("role") == "system":
last_sys = i
insert_at = last_sys + 1 if last_sys >= 0 else 0
messages.insert(insert_at, {
"role": "system",
"content": (
"Relevant content from the library (use it when accurate, "
"cite ids; do not fabricate):\n\n" + context
),
})
return {**state, "messages": messages, "retrieval": items}
def _node_review(self, state: AgentState) -> AgentState:
"""Run every configured quality tool against the LLM's output."""
text = state.get("output_raw") or ""
if isinstance(state.get("output"), dict):
# Flatten the dict to text so the quality tools see something
# meaningful (most just want prose).
text = json.dumps(state["output"], ensure_ascii=False)
issues: list[str] = []
checks = [
k.strip() for k in (self.agent.quality_checks or "").split(",")
if k.strip()
]
variables = state.get("variables") or {}
target_cefr = (
variables.get("cefr_level")
or variables.get("target_cefr")
or "b1"
)
for key in checks:
res = agent_tools.invoke(self.env, key, {
"text": text,
"target_cefr": target_cefr,
"cefr_level": target_cefr,
})
if res.get("ok") is False:
issues.extend(res.get("issues") or [res.get("error") or key])
return {**state, "quality_issues": issues}
def _route_after_review(self, state: AgentState) -> str:
issues = state.get("quality_issues") or []
if not issues:
return "done"
if (state.get("revisions_used") or 0) >= max(0, self.agent.max_revisions):
return "done"
# Queue up a revision: add a system message with the critique and
# bump the counter. We return via "revise" which loops back to
# the LLM node.
critique = (
"Your previous draft was rejected for the following reasons:\n- "
+ "\n- ".join(issues)
+ "\n\nProduce an improved version that addresses every issue. "
"Keep the same JSON schema if one was requested."
)
messages = list(state.get("messages") or []) + [
{"role": "system", "content": critique}
]
state["messages"] = messages
state["revisions_used"] = (state.get("revisions_used") or 0) + 1
return "revise"
# ReAct / tool-calling -------------------------------------------------
def _node_llm_tools(self, state: AgentState) -> AgentState:
"""ReAct step: ask the LLM, exposing all enabled tools."""
if state.get("iterations", 0) >= self.MAX_REACT_ITERATIONS:
return {**state, "error": "react_iteration_limit_exceeded"}
client = self.ai.client
if client is None:
return {**state, "error": "openai_not_configured"}
tools = [t.to_openai_tool() for t in self.agent.tool_ids]
try:
resp = client.chat.completions.create(
model=self.agent.model,
messages=state.get("messages") or [],
temperature=self.agent.temperature,
max_tokens=self.agent.max_tokens,
tools=tools or None,
tool_choice="auto" if tools else None,
timeout=self.ai.request_timeout,
)
except Exception as exc:
return {**state, "error": str(exc)}
choice = resp.choices[0].message
assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": choice.content or "",
}
tool_calls = []
if getattr(choice, "tool_calls", None):
# Preserve the OpenAI-shaped tool_calls list on the message so
# the next round references them by id.
assistant_msg["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in choice.tool_calls
]
for tc in choice.tool_calls:
try:
args = json.loads(tc.function.arguments or "{}")
except Exception:
args = {}
tool_calls.append({
"id": tc.id,
"name": tc.function.name,
"args": args,
})
new_messages = list(state.get("messages") or []) + [assistant_msg]
return {
**state,
"messages": new_messages,
"tool_calls": tool_calls,
"output": choice.content or state.get("output"),
"output_raw": choice.content or state.get("output_raw") or "",
"iterations": state.get("iterations", 0) + 1,
}
def _route_after_llm_tools(self, state: AgentState) -> str:
if state.get("error"):
return "done"
if state.get("tool_calls"):
return "tools"
return "done"
def _node_tools(self, state: AgentState) -> AgentState:
"""Execute every queued tool_call and append results to the chat."""
allowed = {t.key: t for t in self.agent.tool_ids}
messages = list(state.get("messages") or [])
results: list[dict] = list(state.get("tool_results") or [])
for call in state.get("tool_calls") or []:
# Tools are stored with dotted keys but OpenAI flattens dots to
# double-underscores (because function names must match [A-Za-z0-9_]).
key = (call.get("name") or "").replace("__", ".")
if key not in allowed:
result = {"error": f"tool_not_allowed:{key}"}
else:
result = agent_tools.invoke(self.env, key, call.get("args") or {})
results.append({"tool": key, "args": call.get("args"), "result": result})
messages.append({
"role": "tool",
"tool_call_id": call.get("id"),
"name": call.get("name") or key,
"content": json.dumps(result, ensure_ascii=False, default=str)[:6000],
})
return {
**state,
"messages": messages,
"tool_calls": [],
"tool_results": results,
}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _format_retrieval(items: list[dict]) -> str:
parts = []
for r in items or []:
label = f"[{r.get('type','?')}#{r.get('id','?')}]"
title = r.get("title") or ""
snippet = (r.get("snippet") or "")[:400]
parts.append(f"{label} {title}\n{snippet}")
return "\n---\n".join(parts)
def _log(self, final: AgentState, latency_ms: int):
try:
self.env["encoach.ai.log"].sudo().create({
"service": "openai",
"action": f"agent.{self.agent.key}",
"model_used": self.agent.model,
"latency_ms": latency_ms,
"status": "error" if final.get("error") else "success",
"error_message": final.get("error") or "",
"input_preview": json.dumps(final.get("variables") or {})[:500],
"output_preview": (final.get("output_raw") or "")[:500],
})
except Exception:
_logger.warning("agent %s log write failed", self.agent.key, exc_info=True)

View File

@@ -0,0 +1,326 @@
"""Python implementations of the tools the agent runtime can invoke.
Every :py:class:`encoach.ai.tool` row in the DB points to one of the
handler functions registered here via :py:func:`register`. The DB row
holds the metadata (description, JSON Schema, admin toggle); the real
logic is Python so it can import Odoo models, run transactions and
reuse existing services (vector search, quality gates, etc.).
Tools follow a strict contract:
* Signature: ``handler(env, **params) -> dict``.
* The returned dict must be JSON-serialisable. It becomes the tool
message the LLM sees next, so keys should be descriptive.
* Tools that write to the DB must set ``mutates=True`` on their catalogue
row so the runtime wraps the call in a savepoint.
* Tools never raise: catch exceptions and return ``{"error": str(exc)}``
so the agent can reason about failures and the top-level call keeps
going instead of aborting the whole graph.
Adding a new tool
-----------------
1. Write a handler below, decorated with ``@register("namespace.name")``.
2. Add a seed row in ``data/agents_defaults.xml`` with the same key.
3. Bind it to one or more agents in the same seed file.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Callable
_logger = logging.getLogger(__name__)
# Registry: tool key → handler callable.
_REGISTRY: dict[str, Callable[..., dict]] = {}
def register(key: str):
"""Decorator to register a tool handler under ``key``."""
def decorator(func: Callable[..., dict]) -> Callable[..., dict]:
if key in _REGISTRY:
_logger.warning("Agent tool %r is being re-registered", key)
_REGISTRY[key] = func
return func
return decorator
def get_handler(key: str) -> Callable[..., dict] | None:
return _REGISTRY.get(key)
def list_keys() -> list[str]:
return sorted(_REGISTRY.keys())
def invoke(env, key: str, params: dict | None = None) -> dict:
"""Resolve and invoke the handler for ``key`` with ``params``.
All tool failures are normalised to ``{"error": "..."}`` so callers
(LangGraph nodes) don't need to care about exceptions. A missing
handler returns ``{"error": "unknown_tool"}``.
"""
handler = _REGISTRY.get(key)
if not handler:
return {"error": f"unknown_tool:{key}"}
try:
return handler(env, **(params or {}))
except TypeError as exc:
# Bad arguments from the LLM — make the complaint readable.
return {"error": f"bad_arguments: {exc}"}
except Exception as exc:
_logger.exception("agent tool %s failed", key)
return {"error": str(exc)}
# =============================================================================
# Built-in tools
# =============================================================================
#
# Each handler is deliberately small: they're thin adapters over services
# we already have. The LLM gets a compact JSON payload to reason over.
# --- Retrieval ----------------------------------------------------------------
@register("resources.search")
def _search_resources(env, query: str = "", skill: str = "", cefr_level: str = "",
limit: int = 5, **_: Any) -> dict:
"""Semantic search over the LMS resource library.
Returns titles + short snippets so the agent can cite existing
materials instead of inventing new ones every run.
"""
from odoo.addons.encoach_vector.services.embedding_service import (
EmbeddingService, # noqa: F401
)
try:
svc = EmbeddingService(env)
# EmbeddingService.search is expected to filter by content_type;
# we accept a skill filter from the agent but don't require it.
results = svc.search(query or "", limit=int(limit or 5))
except Exception as exc:
_logger.debug("resource vector search unavailable: %s", exc)
results = []
out = []
for r in results or []:
out.append({
"id": r.get("content_id") or r.get("id"),
"type": r.get("content_type") or "",
"title": (r.get("metadata") or {}).get("title", ""),
"snippet": (r.get("text") or "")[:400],
"similarity": r.get("similarity"),
})
return {"query": query, "count": len(out), "items": out}
@register("rubric.fetch")
def _fetch_rubric(env, rubric_id: int | None = None, skill: str = "", **_: Any) -> dict:
"""Return rubric criteria for a given id, or the newest rubric for a skill."""
Rubric = env["encoach.rubric"].sudo() if "encoach.rubric" in env else None
if Rubric is None:
return {"error": "rubric_model_missing"}
rec = None
if rubric_id:
rec = Rubric.browse(int(rubric_id))
if not rec.exists():
rec = None
if rec is None and skill:
rec = Rubric.search(
[("skill", "=", skill)], order="create_date desc", limit=1,
)
if not rec:
return {"error": "rubric_not_found"}
criteria = []
for crit in getattr(rec, "criterion_ids", rec.browse([])):
criteria.append({
"code": getattr(crit, "code", "") or "",
"name": crit.name or "",
"weight": getattr(crit, "weight", 0) or 0,
"descriptors": getattr(crit, "descriptors", "") or "",
})
return {
"id": rec.id,
"name": rec.name,
"skill": getattr(rec, "skill", "") or "",
"criteria": criteria,
}
@register("outcomes.fetch")
def _fetch_course_outcomes(env, course_id: int | None = None,
cefr_level: str = "", **_: Any) -> dict:
"""Return learning outcomes for a course (or CEFR level)."""
LO = env["encoach.learning.objective"].sudo() \
if "encoach.learning.objective" in env else None
if LO is None:
return {"error": "learning_objective_model_missing"}
domain = []
if course_id:
domain.append(("course_id", "=", int(course_id)))
if cefr_level:
domain.append(("cefr_level", "=", cefr_level))
records = LO.search(domain, limit=200)
return {
"count": len(records),
"items": [{
"id": r.id,
"code": getattr(r, "code", "") or "",
"skill": getattr(r, "skill", "") or "",
"cefr_level": getattr(r, "cefr_level", "") or "",
"description": r.name or getattr(r, "description", "") or "",
} for r in records],
}
@register("student.profile")
def _fetch_student_profile(env, student_id: int, **_: Any) -> dict:
"""Return a compact gap-profile the agent can use to personalise content."""
SP = env["encoach.student.profile"].sudo() \
if "encoach.student.profile" in env else None
if SP is None:
return {"error": "student_profile_model_missing"}
rec = SP.search([("student_id", "=", int(student_id))], limit=1)
if not rec:
return {"error": "profile_not_found"}
return {
"student_id": int(student_id),
"cefr_level": getattr(rec, "cefr_level", "") or "",
"strengths_json": getattr(rec, "strengths_json", "") or "",
"gaps_json": getattr(rec, "gaps_json", "") or "",
}
# --- Quality gates ------------------------------------------------------------
@register("quality.cefr_check")
def _cefr_check(env, text: str = "", target_cefr: str = "b1", **_: Any) -> dict:
"""Grade the readability of ``text`` against a target CEFR band."""
try:
import textstat # noqa: F401
fk = textstat.flesch_kincaid_grade(text or "")
fre = textstat.flesch_reading_ease(text or "")
except Exception:
fk, fre = None, None
# Rough mapping — deliberately conservative; the LLM uses it as a hint.
band_map = {"a1": (1, 3), "a2": (3, 5), "b1": (5, 7),
"b2": (7, 9), "c1": (9, 12), "c2": (12, 20)}
ok = True
issues = []
if fk is not None:
lo, hi = band_map.get((target_cefr or "b1").lower(), (5, 7))
if fk < lo - 0.5:
ok = False
issues.append(f"Text reads below {target_cefr.upper()} (FK={fk:.1f})")
elif fk > hi + 0.5:
ok = False
issues.append(f"Text reads above {target_cefr.upper()} (FK={fk:.1f})")
return {
"ok": ok,
"target_cefr": target_cefr,
"flesch_kincaid": fk,
"flesch_reading_ease": fre,
"issues": issues,
}
@register("quality.ai_detect")
def _ai_detect(env, text: str = "", **_: Any) -> dict:
"""Return AI-detection probability if GPTZero is configured, else neutral."""
try:
# Try to reuse whatever GPTZero wrapper the platform already has.
from odoo.addons.encoach_ai.services.gptzero_service import (
GPTZeroService, # type: ignore
)
svc = GPTZeroService(env)
return svc.score(text)
except Exception as exc:
_logger.debug("gptzero unavailable: %s", exc)
return {"ok": True, "ai_probability": None, "note": "detector_unavailable"}
@register("quality.content_gate")
def _content_gate(env, text: str = "", cefr_level: str = "b1", **_: Any) -> dict:
"""Run the project's unified content-source gate (if installed)."""
try:
from odoo.addons.encoach_quality_gate.services.content_source_gate import (
ContentSourceGate, # type: ignore
)
gate = ContentSourceGate(env)
return gate.check(text, cefr_level=cefr_level)
except Exception as exc:
_logger.debug("content_gate unavailable: %s", exc)
return {"ok": True, "note": "gate_unavailable"}
# --- Persistence --------------------------------------------------------------
@register("course_plan.save")
def _save_course_plan(env, plan_vals: dict, weeks: list | None = None, **_: Any) -> dict:
"""Persist an AI-generated course plan. Used by the course_planner agent.
``plan_vals`` is the dict the LLM produced (after the runtime's JSON
normalisation). ``weeks`` is the list of per-week rows. The handler is
idempotent-friendly: it always creates new rows (agents are expected
to decide whether to reuse an existing plan before calling this).
"""
Plan = env["encoach.course.plan"].sudo() if "encoach.course.plan" in env else None
Week = env["encoach.course.plan.week"].sudo() \
if "encoach.course.plan.week" in env else None
if Plan is None or Week is None:
return {"error": "course_plan_models_missing"}
plan = Plan.create(plan_vals or {})
created_weeks = 0
for w in weeks or []:
try:
Week.create({**w, "plan_id": plan.id})
created_weeks += 1
except Exception as exc:
_logger.warning("agent tool course_plan.save: bad week row %r: %s", w, exc)
return {"plan_id": plan.id, "weeks_created": created_weeks}
@register("course_plan.save_materials")
def _save_week_materials(env, plan_id: int, week_id: int,
materials: list | None = None, **_: Any) -> dict:
Material = env["encoach.course.plan.material"].sudo() \
if "encoach.course.plan.material" in env else None
if Material is None:
return {"error": "material_model_missing"}
created = 0
for m in materials or []:
try:
Material.create({
**m,
"plan_id": int(plan_id),
"week_id": int(week_id),
"body_json": json.dumps(m.get("body") or {}, ensure_ascii=False)
if "body" in m else m.get("body_json", ""),
})
created += 1
except Exception as exc:
_logger.warning("agent tool save_materials: bad row %r: %s", m, exc)
return {"plan_id": plan_id, "week_id": week_id, "materials_created": created}
# --- Scoring (best-effort wrappers over existing services) --------------------
@register("scoring.grade_writing")
def _grade_writing(env, rubric: str = "", task: str = "", response: str = "",
**_: Any) -> dict:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
svc = OpenAIService(env)
try:
return svc.grade_writing(rubric, task, response)
except Exception as exc:
return {"error": str(exc)}
@register("scoring.grade_speaking")
def _grade_speaking(env, rubric: str = "", transcript: str = "", **_: Any) -> dict:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
svc = OpenAIService(env)
try:
return svc.grade_speaking(rubric, transcript)
except Exception as exc:
return {"error": str(exc)}

View File

@@ -0,0 +1,149 @@
"""Canonical CEFR / IELTS band / IRT theta mapper.
This is the SINGLE SOURCE OF TRUTH for band ↔ CEFR ↔ theta conversions across
the platform. All modules MUST import from here rather than hand-rolling their
own mapping tables. See §21 of docs/PROJECT_SUMMARY.md for the rationale.
"""
# IRT theta band thresholds (ability parameter → CEFR level)
THETA_TO_CEFR = [
(-4.0, -2.5, 'pre_a1'),
(-2.5, -1.5, 'a1'),
(-1.5, -0.5, 'a2'),
(-0.5, 0.5, 'b1'),
(0.5, 1.5, 'b2'),
(1.5, 2.5, 'c1'),
(2.5, 4.0, 'c2'),
]
# CEFR code → canonical IELTS band centre value
CEFR_TO_BAND = {
'pre_a1': 2.0,
'a1': 3.0,
'a2': 4.0,
'b1': 5.0,
'b2': 6.5,
'c1': 7.5,
'c2': 9.0,
}
# Lowercase CEFR code → human-readable label
CEFR_LABELS = {
'pre_a1': 'Pre-A1 (Beginner)',
'a1': 'A1 (Elementary)',
'a2': 'A2 (Pre-Intermediate)',
'b1': 'B1 (Intermediate)',
'b2': 'B2 (Upper-Intermediate)',
'c1': 'C1 (Advanced)',
'c2': 'C2 (Proficient)',
}
# Monotonic (highest band → lowest) thresholds used by band_to_cefr.
# Picked to align CEFR_TO_BAND with widely-published IELTS/CEFR crosswalks
# (Cambridge, Council of Europe): 8.0+ → C2, 7.0-7.9 → C1, 5.5-6.9 → B2, etc.
_BAND_THRESHOLDS = [
(8.0, 'c2'),
(7.0, 'c1'),
(5.5, 'b2'),
(4.5, 'b1'),
(3.5, 'a2'),
(2.5, 'a1'),
(0.0, 'pre_a1'),
]
def band_to_cefr(band):
"""Map an IELTS band (0-9, may be float) to a canonical CEFR code."""
if band is None:
return None
try:
b = float(band)
except (TypeError, ValueError):
return None
for threshold, level in _BAND_THRESHOLDS:
if b >= threshold:
return level
return 'pre_a1'
def theta_to_cefr(theta):
"""Map an IRT theta ability parameter (typically -4..+4) to a CEFR code."""
try:
t = float(theta)
except (TypeError, ValueError):
return 'pre_a1'
for low, high, level in THETA_TO_CEFR:
if low <= t < high:
return level
return 'c2' if t >= 2.5 else 'pre_a1'
def theta_to_band(theta):
"""Interpolate an IRT theta to an IELTS band, rounded to nearest 0.5."""
try:
t = float(theta)
except (TypeError, ValueError):
return 0.0
cefr = theta_to_cefr(t)
base_band = CEFR_TO_BAND.get(cefr, 5.0)
for low, high, level in THETA_TO_CEFR:
if level == cefr:
width = high - low
position = (t - low) / width if width > 0 else 0.5
cefr_list = list(CEFR_TO_BAND.keys())
idx = cefr_list.index(cefr)
next_band = CEFR_TO_BAND.get(
cefr_list[min(idx + 1, len(cefr_list) - 1)], base_band + 1.0
)
band = base_band + position * (next_band - base_band)
return round(band * 2) / 2
return base_band
def cefr_to_band(cefr):
"""Return the canonical centre-of-range band for a CEFR code."""
if not cefr:
return None
return CEFR_TO_BAND.get(str(cefr).strip().lower().replace('-', '_'))
def normalize_cefr(label):
"""Canonicalise a CEFR label to the uppercase display form (e.g. 'A2', 'Pre-A1').
Accepts 'a2', 'A2', 'pre_a1', 'Pre-A1', 'pre a1' etc. Returns ``None``
if the input is falsy; returns the input unchanged if unrecognised.
"""
if not label:
return None
s = str(label).strip().lower().replace('-', '_').replace(' ', '_')
return {
'pre_a1': 'Pre-A1',
'a1': 'A1',
'a2': 'A2',
'b1': 'B1',
'b2': 'B2',
'c1': 'C1',
'c2': 'C2',
}.get(s, label)
def cefr_label(cefr_code):
"""Return the human-readable label (e.g. 'B2 (Upper-Intermediate)') for a code."""
return CEFR_LABELS.get(cefr_code, cefr_code)
class CefrMapper:
"""Class-style facade kept for backward compatibility with ``encoach_placement``."""
THETA_TO_CEFR = THETA_TO_CEFR
CEFR_TO_BAND = CEFR_TO_BAND
CEFR_LABELS = CEFR_LABELS
theta_to_cefr = staticmethod(theta_to_cefr)
theta_to_band = staticmethod(theta_to_band)
band_to_cefr = staticmethod(band_to_cefr)
@staticmethod
def get_cefr_label(cefr_code):
return cefr_label(cefr_code)

View File

@@ -9,10 +9,10 @@ _logger = logging.getLogger(__name__)
class CoachService:
"""High-level AI coaching: chat, tips, explanations, writing help, study plans."""
def __init__(self, env):
def __init__(self, env, *, language=None):
from .openai_service import OpenAIService
self.env = env
self.ai = OpenAIService(env)
self.ai = OpenAIService(env, language=language)
def _log(self, action, latency_ms=0, status="success", error=None, inp=None, out=None):
try:

View File

@@ -12,24 +12,106 @@ except ImportError:
_openai_mod = None
# Human-readable names for the UI languages we support. Kept in sync with the
# frontend i18n language set. When a user has the UI in Arabic (`ar`), we want
# the LLM to reply in Arabic too — otherwise the user sees Arabic chrome with
# English AI content, which is what they reported as "not translated correct".
_LANGUAGE_NAMES = {
"en": "English",
"ar": "Arabic",
"fr": "French",
"es": "Spanish",
"de": "German",
"ru": "Russian",
"tr": "Turkish",
"fa": "Persian",
"ur": "Urdu",
"hi": "Hindi",
"zh": "Chinese",
"ja": "Japanese",
"ko": "Korean",
}
def _normalize_language(code):
"""Pull a short ISO-639-1 code out of a raw Accept-Language-style string.
Handles ``ar``, ``ar-EG``, ``ar-EG,en;q=0.9`` and friends. Falls back to
``en`` for anything we don't recognise so the AI always has a concrete
target language and never reverts to an empty prompt.
"""
if not code:
return "en"
token = str(code).strip().split(",")[0].split(";")[0].strip().lower()
short = token.split("-")[0]
return short if short in _LANGUAGE_NAMES else "en"
class OpenAIService:
"""Wraps the OpenAI Python SDK with Odoo settings and logging."""
def __init__(self, env):
def __init__(self, env, *, language=None):
self.env = env
self._get_param = env["ir.config_parameter"].sudo().get_param
self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes")
self.max_retries = int(self._get_param("encoach_ai.max_retries", "3"))
# Hard wall-clock timeout (seconds) enforced by the OpenAI SDK on each
# HTTP call. Prevents a single slow LLM request from tying up an Odoo
# worker for minutes. See P0.5 in docs/PROJECT_SUMMARY.md §21.
self.request_timeout = float(self._get_param("encoach_ai.request_timeout", "30"))
api_key = self._get_param("encoach_ai.openai_api_key", "")
if not api_key:
import os
api_key = os.environ.get("OPENAI_API_KEY", "")
if _openai_mod and api_key:
self.client = _openai_mod.OpenAI(api_key=api_key)
self.client = _openai_mod.OpenAI(api_key=api_key, timeout=self.request_timeout)
else:
self.client = None
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o")
self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-3.5-turbo")
self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-4o-mini")
self.language = _normalize_language(language)
def _language_system_message(self):
"""Return a system message that forces the LLM to answer in the user's
UI language, or ``None`` for English (the model's default).
We keep the original English prompts (which are tuned for JSON
structure) and simply tack a language instruction on the end. This
preserves behaviour for ``en`` users while giving Arabic users Arabic
output without having to translate every prompt in the codebase.
"""
if not self.language or self.language == "en":
return None
lang_name = _LANGUAGE_NAMES.get(self.language, "English")
return {
"role": "system",
"content": (
f"LOCALIZATION: Write every user-facing natural-language string "
f"(titles, descriptions, explanations, feedback, recommendations, "
f"suggestions, motivation, narrative) in {lang_name}. "
"Keep JSON keys, enum values (e.g. 'info', 'warning', 'critical', "
"'TRUE', 'FALSE', 'NOT GIVEN'), CEFR band codes (A1-C2), band numbers, "
"and identifiers in their original form. Do not translate rubric "
"category names that appear as JSON keys."
),
}
def _inject_language(self, messages):
"""Prepend the localization instruction after the existing system
prompt(s) so it doesn't displace the structural prompt but is still
heeded by the model."""
lang_msg = self._language_system_message()
if not lang_msg:
return messages
messages = list(messages)
# Find the index of the last system message so we append after it.
last_system = -1
for i, m in enumerate(messages):
if isinstance(m, dict) and m.get("role") == "system":
last_system = i
insert_at = last_system + 1 if last_system >= 0 else 0
messages.insert(insert_at, lang_msg)
return messages
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None):
try:
@@ -78,6 +160,7 @@ class OpenAIService:
if not self.client:
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
model = model or self.model
messages = self._inject_language(messages)
t0 = time.time()
try:
def _call():
@@ -86,6 +169,7 @@ class OpenAIService:
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
timeout=self.request_timeout,
)
resp = self._retry_with_backoff(_call, action, model)
content = resp.choices[0].message.content
@@ -103,6 +187,7 @@ class OpenAIService:
if not self.client:
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
model = model or self.model
messages = self._inject_language(messages)
t0 = time.time()
try:
def _call():
@@ -112,6 +197,7 @@ class OpenAIService:
temperature=temperature,
max_tokens=max_tokens,
response_format={"type": "json_object"},
timeout=self.request_timeout,
)
resp = self._retry_with_backoff(_call, action, model)
raw = resp.choices[0].message.content

View File

@@ -0,0 +1,318 @@
"""Validation + quality gate for AI-generated exam content.
This module is the single entry point used by exam-generation controllers to:
1. **Schema-validate** raw LLM output before any DB insert (``validate_payload``),
catching malformed AI responses (missing prompt, empty options for MCQ,
wrong correct_answer format, etc.) before they corrupt the question pool.
2. **Score content quality** of persisted questions (``score_question``), wrapping
:class:`encoach_quality_gate.services.quality_checker.QualityChecker` and
:class:`encoach_ielts_validation.services.validator.IeltsValidator` with
graceful degradation when those addons are not installed or their optional
Python deps (``textstat``, ``language_tool_python``) are missing.
Design choices:
- Zero hard dependency on ``jsonschema`` or ``pydantic`` — the schema is a
lightweight, introspectable dict used by :func:`_validate_dict`. Good enough
for the shapes we actually emit; keeps our external-deps footprint small.
- Quality scoring is best-effort and **never** raises to callers — failures
degrade to a ``warn`` verdict so the HTTP request still completes.
"""
from __future__ import annotations
import hashlib
import json
import logging
from typing import Any
_logger = logging.getLogger(__name__)
VERDICT_OK = "ok"
VERDICT_WARN = "warn"
VERDICT_FAIL = "fail"
MIN_STEM_LEN = 8
MIN_QUALITY_SCORE = 0.55
_VALID_QUESTION_TYPES = {
"mcq", "mcq_multi", "tfng", "ynng", "gap_fill", "short_answer",
"form_completion", "note_completion", "map_labelling", "matching",
"summary_completion", "heading_matching", "matching_features",
"numerical", "expression", "code_completion", "code_output",
"sql_query", "matrix", "graph",
}
_SKILL_VALUES = {
"listening", "reading", "writing", "speaking", "grammar",
"vocabulary", "math", "it",
}
EXERCISE_SCHEMA: dict[str, dict[str, Any]] = {
"prompt": {"type": str, "required": True, "min_len": MIN_STEM_LEN},
"type": {"type": str, "required": False, "choices": _VALID_QUESTION_TYPES | {
"multiple_choice", "true_false", "fill_blanks", "matching_headings",
"paragraph_match", "sentence_completion", "matching_information",
}},
"options": {"type": list, "required": False},
"correct_answer": {"type": (str, list, int, float), "required": False},
"marks": {"type": (int, float), "required": False, "min": 0, "max": 20},
"cefr_level": {"type": str, "required": False},
}
MODULE_SCHEMA: dict[str, dict[str, Any]] = {
"difficulty": {"type": (list, str), "required": False},
"timer": {"type": (int, float), "required": False, "min": 0, "max": 600},
"totalMarks": {"type": (int, float), "required": False, "min": 0, "max": 500},
"passages": {"type": list, "required": False},
"sections": {"type": list, "required": False},
"tasks": {"type": list, "required": False},
"parts": {"type": list, "required": False},
}
def _validate_dict(obj: Any, schema: dict, path: str = "") -> list[str]:
"""Return list of human-readable validation errors for ``obj`` against ``schema``."""
errors: list[str] = []
if not isinstance(obj, dict):
errors.append(f"{path or 'root'}: expected object, got {type(obj).__name__}")
return errors
for key, rule in schema.items():
here = f"{path}.{key}" if path else key
if key not in obj or obj[key] in (None, ""):
if rule.get("required"):
errors.append(f"{here}: missing required field")
continue
val = obj[key]
expected_type = rule.get("type")
if expected_type and not isinstance(val, expected_type):
got = type(val).__name__
want = (
expected_type.__name__ if isinstance(expected_type, type)
else "/".join(t.__name__ for t in expected_type)
)
errors.append(f"{here}: expected {want}, got {got}")
continue
if isinstance(val, str):
min_len = rule.get("min_len")
if min_len and len(val.strip()) < min_len:
errors.append(f"{here}: too short (< {min_len} chars)")
choices = rule.get("choices")
if choices and val not in choices:
errors.append(f"{here}: '{val}' not in allowed values")
if isinstance(val, (int, float)):
if "min" in rule and val < rule["min"]:
errors.append(f"{here}: {val} < min {rule['min']}")
if "max" in rule and val > rule["max"]:
errors.append(f"{here}: {val} > max {rule['max']}")
return errors
def _validate_exercise(ex: Any, path: str = "exercise") -> list[str]:
"""Validate a single AI-generated exercise dict."""
errors = _validate_dict(ex, EXERCISE_SCHEMA, path)
if errors:
return errors
q_type = (ex.get("type") or "mcq").lower()
options = ex.get("options") or []
correct = ex.get("correct_answer")
if q_type in ("mcq", "multiple_choice"):
if not options or len(options) < 2:
errors.append(f"{path}.options: MCQ requires >= 2 options")
if correct in (None, ""):
errors.append(f"{path}.correct_answer: MCQ requires a correct_answer")
if q_type in ("mcq_multi",):
if not isinstance(correct, list) or len(correct) < 1:
errors.append(f"{path}.correct_answer: mcq_multi requires a list of answers")
if q_type in ("true_false", "tfng", "ynng"):
if isinstance(correct, str) and correct.lower() not in {
"true", "false", "not_given", "yes", "no", "t", "f"
}:
errors.append(f"{path}.correct_answer: '{correct}' not a valid T/F/NG answer")
return errors
def validate_payload(payload: Any) -> dict[str, Any]:
"""Validate an entire exam-generation submit payload.
Expected shape (best-effort, since the frontend already normalizes it):
.. code-block:: text
{
"title": str (required),
"modules": {
"<skill>": {
"passages": [{"text": str, "exercises": [ExerciseSchema, ...]}],
"sections": [...], "tasks": [...], "parts": [...],
...
},
...
}
}
Returns a dict with::
{"verdict": "ok"|"warn"|"fail", "errors": [...], "warnings": [...]}
``verdict == "fail"`` means the caller MUST NOT persist this payload as-is.
"""
errors: list[str] = []
warnings: list[str] = []
if not isinstance(payload, dict):
return {"verdict": VERDICT_FAIL,
"errors": [f"payload: expected object, got {type(payload).__name__}"],
"warnings": []}
title = (payload.get("title") or "").strip()
if not title:
errors.append("title: missing required field")
modules = payload.get("modules") or {}
if not isinstance(modules, dict) or not modules:
errors.append("modules: missing or empty — nothing to persist")
return {"verdict": VERDICT_FAIL, "errors": errors, "warnings": warnings}
total_exercises = 0
for mod_key, mod in modules.items():
mpath = f"modules.{mod_key}"
if not isinstance(mod, dict):
errors.append(f"{mpath}: expected object")
continue
errors.extend(_validate_dict(mod, MODULE_SCHEMA, mpath))
for p_idx, passage in enumerate(mod.get("passages") or []):
if not isinstance(passage, dict):
errors.append(f"{mpath}.passages[{p_idx}]: expected object")
continue
for e_idx, ex in enumerate(passage.get("exercises") or []):
total_exercises += 1
errors.extend(_validate_exercise(
ex, f"{mpath}.passages[{p_idx}].exercises[{e_idx}]"))
for s_idx, sec in enumerate(mod.get("sections") or []):
if not isinstance(sec, dict):
errors.append(f"{mpath}.sections[{s_idx}]: expected object")
continue
for e_idx, ex in enumerate(sec.get("exercises") or []):
total_exercises += 1
errors.extend(_validate_exercise(
ex, f"{mpath}.sections[{s_idx}].exercises[{e_idx}]"))
if total_exercises == 0 and not any(
(m.get("tasks") or m.get("parts")) for m in modules.values() if isinstance(m, dict)
):
warnings.append("no exercises/tasks/parts found — exam will have no questions")
if errors:
verdict = VERDICT_FAIL
elif warnings:
verdict = VERDICT_WARN
else:
verdict = VERDICT_OK
return {"verdict": verdict, "errors": errors, "warnings": warnings,
"total_exercises": total_exercises}
def prompt_hash(prompt: str) -> str:
"""SHA-256 hex digest of a rendered prompt, for provenance tracking."""
return hashlib.sha256((prompt or "").encode("utf-8")).hexdigest()
def score_question(stem: str, *, skill: str | None = None,
cefr_level: str | None = None) -> dict[str, Any]:
"""Best-effort quality score for a persisted question stem.
Wraps QualityChecker + IeltsValidator defensively — any import/runtime
failure downgrades the verdict to ``warn`` without raising. Returns::
{
"verdict": "ok"|"warn"|"fail",
"score": float in [0, 1],
"readability": {...} | None,
"grammar_issues": int,
"checks_ran": ["readability", "grammar", ...],
"errors": [str, ...],
}
"""
report: dict[str, Any] = {
"verdict": VERDICT_OK,
"score": 1.0,
"readability": None,
"grammar_issues": 0,
"checks_ran": [],
"errors": [],
}
if not stem or len(stem.strip()) < MIN_STEM_LEN:
report["verdict"] = VERDICT_FAIL
report["score"] = 0.0
report["errors"].append(f"stem shorter than {MIN_STEM_LEN} chars")
return report
try:
from odoo.addons.encoach_quality_gate.services.quality_checker import QualityChecker
except Exception as exc:
report["verdict"] = VERDICT_WARN
report["errors"].append(f"quality_gate unavailable: {exc}")
return report
try:
readability = QualityChecker.check_readability(stem)
report["readability"] = readability
report["checks_ran"].append("readability")
if cefr_level:
cefr_match = QualityChecker.check_cefr_alignment(stem, cefr_level.lower())
report["cefr_alignment"] = cefr_match
report["checks_ran"].append("cefr_alignment")
if not cefr_match.get("aligned", True):
report["score"] -= 0.2
grammar = QualityChecker.check_grammar(stem)
report["grammar_issues"] = len(grammar.get("issues", [])) if isinstance(grammar, dict) else 0
report["checks_ran"].append("grammar")
if report["grammar_issues"] > 3:
report["score"] -= 0.2
except Exception as exc:
_logger.warning("QualityChecker failed for stem (skill=%s): %s", skill, exc)
report["verdict"] = VERDICT_WARN
report["errors"].append(f"quality_checker error: {exc}")
report["score"] = max(0.0, min(1.0, report["score"]))
if report["verdict"] == VERDICT_OK and report["score"] < MIN_QUALITY_SCORE:
report["verdict"] = VERDICT_WARN
return report
def summarize_question_reports(reports: list[dict]) -> dict[str, Any]:
"""Reduce a list of per-question reports to an aggregate verdict."""
if not reports:
return {"verdict": VERDICT_OK, "total": 0, "failed": 0,
"warned": 0, "avg_score": 1.0}
failed = sum(1 for r in reports if r.get("verdict") == VERDICT_FAIL)
warned = sum(1 for r in reports if r.get("verdict") == VERDICT_WARN)
avg = sum(float(r.get("score") or 0) for r in reports) / max(1, len(reports))
if failed:
verdict = VERDICT_FAIL
elif warned:
verdict = VERDICT_WARN
else:
verdict = VERDICT_OK
return {
"verdict": verdict,
"total": len(reports),
"failed": failed,
"warned": warned,
"avg_score": round(avg, 3),
}
def dumps_report(report: dict) -> str:
"""Compact JSON dump safe for storing in a Text column."""
try:
return json.dumps(report, ensure_ascii=False, default=str)
except Exception:
return "{}"

View File

@@ -1 +1,2 @@
from . import ai_course
from . import course_plan

View File

@@ -0,0 +1,171 @@
"""REST endpoints for AI course-plan generation and browsing.
All endpoints sit under ``/api/ai/course-plan`` so they don't collide
with the existing ``/api/ai-course/...`` English / IELTS generation
endpoints. Every route is JWT-guarded via the shared ``@jwt_required``
decorator and returns JSON.
"""
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required,
_json_response,
_error_response,
_get_json_body,
_paginate,
)
from odoo.addons.encoach_ai_course.services.course_plan_pipeline import (
CoursePlanPipeline,
)
_logger = logging.getLogger(__name__)
def _request_language():
"""Return the UI language sent by the frontend as a short ISO code."""
try:
raw = (
request.httprequest.headers.get('X-UI-Language')
or request.httprequest.headers.get('Accept-Language')
or 'en'
)
except Exception:
raw = 'en'
return str(raw).split(',')[0].split(';')[0].split('-')[0].strip().lower() or 'en'
class CoursePlanController(http.Controller):
# ------------------------------------------------------------------
# POST /api/ai/course-plan
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def generate_plan(self, **kw):
try:
body = _get_json_body()
if not (body.get('title') or '').strip():
return _error_response('title is required', 400)
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
plan = pipeline.generate_plan(body)
return _json_response({'data': plan.to_api_dict(include_weeks=True)})
except Exception as exc:
_logger.exception('course-plan.generate failed')
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_plans(self, **kw):
try:
params = request.httprequest.args
domain = []
search = (params.get('search') or '').strip()
if search:
domain.append(('name', 'ilike', search))
Plan = request.env['encoach.course.plan'].sudo()
offset, limit, page = _paginate({
'page': params.get('page', 0),
'size': params.get('size', 20),
})
total = Plan.search_count(domain)
records = Plan.search(
domain, offset=offset, limit=limit,
order='create_date desc, id desc',
)
return _json_response({
'items': [r.to_api_dict(include_weeks=False) for r in records],
'page': {'page': page, 'size': limit, 'total': total},
})
except Exception as exc:
_logger.exception('course-plan.list failed')
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/<id>
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_plan(self, plan_id, **kw):
try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
return _json_response({
'data': plan.to_api_dict(include_weeks=True, include_materials=True),
})
except Exception as exc:
_logger.exception('course-plan.get failed')
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# DELETE /api/ai/course-plan/<id>
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def delete_plan(self, plan_id, **kw):
try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
plan.unlink()
return _json_response({'success': True})
except Exception as exc:
_logger.exception('course-plan.delete failed')
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# POST /api/ai/course-plan/<id>/weeks/<n>/materials
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def generate_week_materials(self, plan_id, week_number, **kw):
try:
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
materials = pipeline.generate_week_materials(plan_id, week_number)
return _json_response({
'items': [m.to_api_dict() for m in materials],
'count': len(materials),
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.generate_week_materials failed')
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/<id>/weeks/<n>/materials
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_week_materials(self, plan_id, week_number, **kw):
try:
week = request.env['encoach.course.plan.week'].sudo().search([
('plan_id', '=', int(plan_id)),
('week_number', '=', int(week_number)),
], limit=1)
if not week:
return _error_response('Week not found', 404)
return _json_response({
'items': [m.to_api_dict() for m in week.material_ids],
'count': len(week.material_ids),
})
except Exception as exc:
_logger.exception('course-plan.list_week_materials failed')
return _error_response(str(exc), 500)

View File

@@ -1,2 +1,3 @@
from . import ai_generation_log
from . import ai_ielts_generation_log
from . import course_plan

View File

@@ -0,0 +1,276 @@
"""Course Plan models.
A *course plan* is the AI-generated, structured outline of a full course
(similar to the UTAS GE1 outline: objectives, per-skill learning outcomes,
grammar scope, assessment split, and a week-by-week delivery plan).
Distinct from the existing exam / exercise generation pipeline:
* ``encoach.ai.generation.log`` generates **exam questions**.
* ``encoach.course.plan`` generates **teaching content** — weeks,
reading texts, listening scripts, speaking prompts, grammar lessons, etc.
Large, loosely-structured JSON (objectives, learning outcomes grouped by
skill, grammar topics, assessment breakdown, learning resources) lives on
the header as ``Text`` columns to keep the schema boring. Per-week rows
and per-week materials each get their own table because they are
generated incrementally and users want to drill into them.
"""
import json
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
SKILL_SELECTION = [
('reading', 'Reading'),
('writing', 'Writing'),
('listening', 'Listening'),
('speaking', 'Speaking'),
('grammar', 'Grammar'),
('vocabulary', 'Vocabulary'),
('integrated', 'Integrated'),
]
MATERIAL_TYPE_SELECTION = [
('reading_text', 'Reading Text'),
('listening_script', 'Listening Script'),
('speaking_prompt', 'Speaking Prompt'),
('writing_prompt', 'Writing Prompt'),
('grammar_lesson', 'Grammar Lesson'),
('vocabulary_list', 'Vocabulary List'),
('practice', 'Practice Exercises'),
('other', 'Other'),
]
class CoursePlan(models.Model):
_name = 'encoach.course.plan'
_description = 'AI-generated Course Plan'
_order = 'create_date desc, id desc'
name = fields.Char(required=True)
course_id = fields.Many2one('op.course', ondelete='set null', string='Linked course')
cefr_level = fields.Selection([
('pre_a1', 'Pre-A1'),
('a1', 'A1'),
('a2', 'A2'),
('b1', 'B1'),
('b2', 'B2'),
('c1', 'C1'),
('c2', 'C2'),
], default='a2')
total_weeks = fields.Integer(default=12, string='Total weeks')
contact_hours_per_week = fields.Integer(default=18, string='Contact hours / week')
# The "Reading & Writing = 10 hrs/wk, Listening & Speaking = 8 hrs/wk"
# breakdown is a free-form label so AI can propose any split.
skills_division = fields.Char(
string='Skills division',
help='Free-form label describing how hours are split across skill '
'tracks, e.g. "10 hrs/wk Reading & Writing + 8 hrs/wk '
'Listening & Speaking".',
)
description = fields.Text()
objectives_json = fields.Text(
help='JSON array of high-level course objectives.',
)
outcomes_json = fields.Text(
help='JSON object keyed by skill (reading/writing/listening/speaking/'
'vocabulary/grammar). Each value is an ordered list of '
'{code, description} learning outcome rows — code is e.g. '
'"RLO1", "WLO3", "GLO2a".',
)
grammar_json = fields.Text(
help='JSON array of grammar topics in the order they should be '
'taught. Each item is {code, label, sub_items: []}.',
)
assessment_json = fields.Text(
help='JSON object describing the CA/FE split and component weights.',
)
resources_json = fields.Text(
help='JSON array of textbooks / URLs / materials referenced by the '
'AI when planning content.',
)
status = fields.Selection([
('draft', 'Draft'),
('generated', 'Generated'),
('approved', 'Approved'),
('archived', 'Archived'),
], default='draft')
brief_json = fields.Text(
help='Original brief that was sent to the AI — kept for audit and '
'so the user can re-generate if the first pass disappoints.',
)
week_ids = fields.One2many(
'encoach.course.plan.week', 'plan_id', string='Weeks',
)
material_ids = fields.One2many(
'encoach.course.plan.material', 'plan_id', string='Materials',
)
week_count = fields.Integer(compute='_compute_counts', store=False)
material_count = fields.Integer(compute='_compute_counts', store=False)
@api.depends('week_ids', 'material_ids')
def _compute_counts(self):
for rec in self:
rec.week_count = len(rec.week_ids)
rec.material_count = len(rec.material_ids)
# ------------------------------------------------------------------
# Serialisation helpers — used by the REST controller so payload
# shape stays in a single, obvious place.
# ------------------------------------------------------------------
def _loads(self, raw, default):
if not raw:
return default
try:
return json.loads(raw)
except (TypeError, ValueError):
return default
def to_api_dict(self, *, include_weeks=True, include_materials=False):
self.ensure_one()
data = {
'id': self.id,
'name': self.name,
'course_id': self.course_id.id if self.course_id else None,
'course_name': self.course_id.name if self.course_id else '',
'cefr_level': self.cefr_level or '',
'total_weeks': self.total_weeks or 0,
'contact_hours_per_week': self.contact_hours_per_week or 0,
'skills_division': self.skills_division or '',
'description': self.description or '',
'status': self.status or 'draft',
'objectives': self._loads(self.objectives_json, []),
'outcomes': self._loads(self.outcomes_json, {}),
'grammar': self._loads(self.grammar_json, []),
'assessment': self._loads(self.assessment_json, {}),
'resources': self._loads(self.resources_json, []),
'week_count': len(self.week_ids),
'material_count': len(self.material_ids),
'created_at': self.create_date.isoformat() if self.create_date else None,
}
if include_weeks:
data['weeks'] = [w.to_api_dict() for w in self.week_ids.sorted('week_number')]
if include_materials:
data['materials'] = [m.to_api_dict() for m in self.material_ids]
return data
class CoursePlanWeek(models.Model):
_name = 'encoach.course.plan.week'
_description = 'Course Plan Week'
_order = 'week_number asc, id asc'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
week_number = fields.Integer(required=True)
date_label = fields.Char(
help='Human-readable date range, e.g. "7-11 Sep. 2025".',
)
unit = fields.Char(help='Textbook unit / theme for the week.')
focus = fields.Char(help='Short focus headline for the week.')
items_json = fields.Text(
help='JSON array of per-skill rows for this week: '
'[{skill, outcome_codes: [...], remarks}]. Mirrors the '
'GE1 delivery plan table.',
)
material_ids = fields.One2many(
'encoach.course.plan.material', 'week_id', string='Materials',
)
material_count = fields.Integer(compute='_compute_material_count', store=False)
@api.depends('material_ids')
def _compute_material_count(self):
for rec in self:
rec.material_count = len(rec.material_ids)
def _loads(self, raw, default):
if not raw:
return default
try:
return json.loads(raw)
except (TypeError, ValueError):
return default
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'week_number': self.week_number or 0,
'date_label': self.date_label or '',
'unit': self.unit or '',
'focus': self.focus or '',
'items': self._loads(self.items_json, []),
'material_count': len(self.material_ids),
}
class CoursePlanMaterial(models.Model):
_name = 'encoach.course.plan.material'
_description = 'Course Plan Teaching Material'
_order = 'week_id, skill, id'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
week_id = fields.Many2one(
'encoach.course.plan.week', ondelete='cascade', index=True,
)
week_number = fields.Integer(
related='week_id.week_number', store=True, string='Week #',
)
skill = fields.Selection(SKILL_SELECTION, required=True)
material_type = fields.Selection(
MATERIAL_TYPE_SELECTION, required=True, default='other',
)
title = fields.Char(required=True)
summary = fields.Text(
help='Short blurb — purpose / learning outcomes targeted / how to use.',
)
body_json = fields.Text(
help='Structured payload. Shape depends on material_type: '
'reading_text → {text, questions[]}, '
'listening_script → {script, comprehension_questions[]}, '
'grammar_lesson → {explanation, examples[], practice[]}, etc.',
)
body_text = fields.Text(
help='Plain-text rendering for easy preview / copy-paste when the '
'structured body is not needed.',
)
def _loads(self, raw, default):
if not raw:
return default
try:
return json.loads(raw)
except (TypeError, ValueError):
return default
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'plan_id': self.plan_id.id,
'week_id': self.week_id.id if self.week_id else None,
'week_number': self.week_number or 0,
'skill': self.skill or '',
'material_type': self.material_type or 'other',
'title': self.title or '',
'summary': self.summary or '',
'body': self._loads(self.body_json, {}),
'body_text': self.body_text or '',
}

View File

@@ -1,3 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_ai_generation_log_user,encoach.ai.generation.log.user,model_encoach_ai_generation_log,base.group_user,1,1,1,1
access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user,model_encoach_ai_ielts_generation_log,base.group_user,1,1,1,1
access_encoach_course_plan_user,encoach.course.plan.user,model_encoach_course_plan,base.group_user,1,1,1,1
access_encoach_course_plan_week_user,encoach.course.plan.week.user,model_encoach_course_plan_week,base.group_user,1,1,1,1
access_encoach_course_plan_material_user,encoach.course.plan.material.user,model_encoach_course_plan_material,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_ai_generation_log_user encoach.ai.generation.log.user model_encoach_ai_generation_log base.group_user 1 1 1 1
3 access_encoach_ai_ielts_generation_log_user encoach.ai.ielts.generation.log.user model_encoach_ai_ielts_generation_log base.group_user 1 1 1 1
4 access_encoach_course_plan_user encoach.course.plan.user model_encoach_course_plan base.group_user 1 1 1 1
5 access_encoach_course_plan_week_user encoach.course.plan.week.user model_encoach_course_plan_week base.group_user 1 1 1 1
6 access_encoach_course_plan_material_user encoach.course.plan.material.user model_encoach_course_plan_material base.group_user 1 1 1 1

View File

@@ -1,2 +1,3 @@
from .english_pipeline import EnglishPipeline
from .ielts_pipeline import IeltsPipeline
from .course_plan_pipeline import CoursePlanPipeline

View File

@@ -0,0 +1,496 @@
"""Course plan generation pipeline.
Two public entry points:
* :py:meth:`generate_plan` — given a short brief (course title, CEFR level,
duration, skill coverage, grammar focus, resources), produce a full
curriculum outline and persist it as an
:py:class:`encoach.course.plan` record, with one
:py:class:`encoach.course.plan.week` row per planned week.
* :py:meth:`generate_week_materials` — given an existing plan and a
week number, produce the actual teaching content for that week
(reading text, listening script, speaking prompts, grammar mini-lesson
+ practice, writing prompt, vocabulary list) and persist each as an
:py:class:`encoach.course.plan.material` row.
We deliberately ask the LLM to return strict JSON and then normalise it
server-side — the frontend gets a stable shape no matter how loose the
model's output is. Any parse failure is swallowed and reported back
through the standard error channel so the caller can retry without the
server crashing.
"""
import json
import logging
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
except ImportError:
OpenAIService = None
# AgentRuntime is the LangGraph-backed engine. When the feature flag
# ``encoach_ai.use_langgraph_runtime`` is true (default) and an agent with
# the matching key is configured, the pipeline routes through the agent
# instead of calling OpenAIService directly. This keeps the existing
# fall-back path so the pipeline still works if the agent layer is broken
# or being upgraded.
try:
from odoo.addons.encoach_ai.services.agent_runtime import AgentRuntime
except ImportError: # pragma: no cover - optional dep
AgentRuntime = None
_logger = logging.getLogger(__name__)
# JSON schema we coax the LLM into following. Keeping this as a prompt
# string (rather than an OpenAI function call) makes it portable if the
# underlying `chat_json` implementation ever changes providers.
_PLAN_JSON_HINT = """
Return JSON with exactly this shape:
{
"description": "<2-4 sentence course description incl. CEFR>",
"objectives": ["<overall course objective>", ...],
"outcomes": {
"reading": [{"code": "RLO1", "description": "..."}, ...],
"writing": [{"code": "WLO1", "description": "..."}, ...],
"listening": [{"code": "LLO1", "description": "..."}, ...],
"speaking": [{"code": "SLO1", "description": "..."}, ...],
"vocabulary": [{"code": "VLO1", "description": "..."}, ...],
"grammar": [{"code": "GLO1", "description": "..."}, ...]
},
"grammar": [
{"code": "GT1", "label": "Present tense",
"sub_items": ["present simple", "present continuous"]},
...
],
"assessment": {
"continuous_assessment": {"total_weight": 50, "components":
[{"name":"MTE","weight":30}, {"name":"Oral Presentation","weight":10}, ...]},
"final_exam": {"total_weight": 50}
},
"resources": [
{"type": "textbook", "citation": "..."},
{"type": "stm", "citation": "..."}
],
"weeks": [
{
"week_number": 1,
"date_label": "7-11 Sep. 2025",
"unit": "One",
"focus": "Personal introductions, simple present",
"items": [
{"skill": "reading", "outcome_codes": ["RLO1","RLO2"], "remarks": "..."},
{"skill": "writing", "outcome_codes": ["WLO1","WLO2"], "remarks": "..."},
{"skill": "listening", "outcome_codes": ["LLO1"], "remarks": ""},
{"skill": "speaking", "outcome_codes": ["SLO1","SLO2"], "remarks": ""},
{"skill": "grammar", "outcome_codes": ["GLO1"], "remarks": ""}
]
},
...
]
}
Use the exact outcome codes across `outcomes` and `weeks[*].items[*].outcome_codes`.
"""
_WEEK_JSON_HINT = """
Return JSON with exactly this shape:
{
"materials": [
{
"skill": "reading",
"material_type": "reading_text",
"title": "...",
"summary": "1-2 sentence teacher note",
"body": {
"text": "<reading passage ~350-450 words>",
"questions": [
{"q": "...", "type": "multiple_choice",
"options": ["A","B","C","D"], "answer": "A"}
]
}
},
{
"skill": "listening",
"material_type": "listening_script",
"title": "...",
"summary": "...",
"body": {
"script": "<3-4 minute dialogue or monologue>",
"comprehension_questions": [
{"q": "...", "answer": "..."}
]
}
},
{
"skill": "speaking",
"material_type": "speaking_prompt",
"title": "...",
"summary": "...",
"body": {
"prompts": ["...", "..."],
"useful_language": ["..."]
}
},
{
"skill": "writing",
"material_type": "writing_prompt",
"title": "...",
"summary": "...",
"body": {
"prompt": "...",
"word_count": 150,
"model_paragraph": "..."
}
},
{
"skill": "grammar",
"material_type": "grammar_lesson",
"title": "...",
"summary": "...",
"body": {
"explanation": "...",
"examples": ["...","..."],
"practice": [
{"q":"...", "answer":"..."}
]
}
},
{
"skill": "vocabulary",
"material_type": "vocabulary_list",
"title": "...",
"summary": "...",
"body": {
"words": [
{"term":"...", "pos":"n.", "definition":"...", "example":"..."}
]
}
}
]
}
Only include skills present in the week's items list.
"""
class CoursePlanPipeline:
"""Wrap the LLM call, normalise the JSON, persist the result."""
def __init__(self, env, *, language="en"):
self.env = env
self.language = language
if OpenAIService is None:
raise RuntimeError(
"OpenAIService is not available — encoach_ai is not installed."
)
self.ai = OpenAIService(env, language=language)
# Decide once per instance whether to route through the LangGraph
# AgentRuntime or fall back to the direct chat_json path.
self._use_agent = self._resolve_agent_flag(env)
@staticmethod
def _resolve_agent_flag(env):
if AgentRuntime is None:
return False
try:
raw = env["ir.config_parameter"].sudo().get_param(
"encoach_ai.use_langgraph_runtime", "True",
)
except Exception:
return False
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def _agent(self, key):
"""Lazily build an AgentRuntime for ``key`` if the flag allows it."""
if not self._use_agent or AgentRuntime is None:
return None
try:
return AgentRuntime.from_key(self.env, key, language=self.language)
except Exception:
_logger.exception("AgentRuntime.from_key(%s) failed", key)
return None
# ------------------------------------------------------------------
# Plan-level generation
# ------------------------------------------------------------------
def generate_plan(self, brief):
"""Generate the full course plan header + week rows from a brief.
:param brief: ``dict`` with optional keys:
title, cefr_level, total_weeks, contact_hours_per_week,
skills_division, grammar_focus (list), resources (list),
learner_profile (string), notes (string), course_id (int),
language (string ISO-639-1).
:returns: ``encoach.course.plan`` record.
"""
title = (brief.get('title') or '').strip() or 'Untitled course'
cefr = (brief.get('cefr_level') or 'a2').lower()
total_weeks = int(brief.get('total_weeks') or 12)
contact_hours = int(brief.get('contact_hours_per_week') or 18)
skills_division = (brief.get('skills_division') or '').strip()
grammar_focus = brief.get('grammar_focus') or []
resources = brief.get('resources') or []
learner_profile = (brief.get('learner_profile') or '').strip()
notes = (brief.get('notes') or '').strip()
system_msg = (
"You are an expert English language curriculum designer. "
"You produce structured course outlines suitable for a "
"general foundation programme. You MUST return valid JSON "
"that matches the schema in the user prompt exactly. Never "
"wrap the JSON in prose."
)
user_msg = (
f"Design a {total_weeks}-week course titled \"{title}\" at "
f"CEFR {cefr.upper()} with approximately {contact_hours} "
f"contact hours per week.\n"
f"Skills division: {skills_division or 'auto'}.\n"
f"Grammar focus: {', '.join(grammar_focus) or 'auto'}.\n"
f"Resources to reference: "
f"{'; '.join(resources) if resources else 'none'}.\n"
f"Learner profile: {learner_profile or 'mixed L1 adult learners'}.\n"
f"Additional notes: {notes or 'none'}.\n\n"
+ _PLAN_JSON_HINT
)
# Prefer the LangGraph agent if one is configured; fall back to the
# direct OpenAI call so the feature still works if the agent table
# is empty or the runtime fails to compile.
content = self._invoke_agent_or_chat(
agent_key="course_planner",
system_msg=system_msg,
user_msg=user_msg,
variables={
"title": title,
"cefr_level": cefr,
"total_weeks": total_weeks,
},
temperature=0.4,
max_tokens=4096,
action="course_plan.generate",
)
if content is None or 'error' in content:
raise RuntimeError(
(content or {}).get('error', 'AI generation failed.')
)
plan_vals = {
'name': title,
'cefr_level': cefr if cefr in {
'pre_a1', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2'
} else 'a2',
'total_weeks': total_weeks,
'contact_hours_per_week': contact_hours,
'skills_division': skills_division,
'description': (content.get('description') or '').strip(),
'objectives_json': json.dumps(content.get('objectives') or [], ensure_ascii=False),
'outcomes_json': json.dumps(content.get('outcomes') or {}, ensure_ascii=False),
'grammar_json': json.dumps(content.get('grammar') or [], ensure_ascii=False),
'assessment_json': json.dumps(content.get('assessment') or {}, ensure_ascii=False),
'resources_json': json.dumps(content.get('resources') or [], ensure_ascii=False),
'brief_json': json.dumps(brief, ensure_ascii=False),
'status': 'generated',
}
if brief.get('course_id'):
try:
plan_vals['course_id'] = int(brief['course_id'])
except (TypeError, ValueError):
pass
plan = self.env['encoach.course.plan'].sudo().create(plan_vals)
# Create week rows.
Week = self.env['encoach.course.plan.week'].sudo()
for w in content.get('weeks') or []:
try:
Week.create({
'plan_id': plan.id,
'week_number': int(w.get('week_number') or 0),
'date_label': (w.get('date_label') or '').strip(),
'unit': (w.get('unit') or '').strip(),
'focus': (w.get('focus') or '').strip(),
'items_json': json.dumps(w.get('items') or [], ensure_ascii=False),
})
except Exception as exc: # pragma: no cover - defensive
_logger.warning("Skipping bad week row: %s", exc)
return plan
# ------------------------------------------------------------------
# Week-level material generation
# ------------------------------------------------------------------
def generate_week_materials(self, plan_id, week_number):
"""Generate teaching materials for one week and persist them.
Any existing materials for the same plan_id + week_number are
replaced — callers that want to keep old versions should copy
them before re-running.
"""
plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
raise ValueError('Plan not found')
week = plan.week_ids.filtered(lambda w: w.week_number == int(week_number))
if not week:
raise ValueError(f'Week {week_number} not found on plan {plan_id}')
week = week[0]
outcomes = plan._loads(plan.outcomes_json, {})
items = week._loads(week.items_json, [])
system_msg = (
"You are an expert English language teacher creating ready-"
"to-use classroom materials. Your output MUST be valid JSON "
"matching the schema in the user prompt. Keep reading texts "
"close to the target word count for the CEFR level. Keep "
"listening scripts natural and conversational. All tasks "
"must target the outcome codes supplied."
)
user_msg = (
f"Course: {plan.name}\n"
f"CEFR: {(plan.cefr_level or '').upper()}\n"
f"Week {week.week_number}{week.date_label or ''}\n"
f"Unit: {week.unit or ''}\n"
f"Focus: {week.focus or ''}\n\n"
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
f"Full outcome catalogue (for looking up codes):\n"
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
+ _WEEK_JSON_HINT
)
content = self._invoke_agent_or_chat(
agent_key="course_week_materials",
system_msg=system_msg,
user_msg=user_msg,
variables={
"course": plan.name,
"cefr_level": (plan.cefr_level or "").lower(),
"week_number": week.week_number,
},
temperature=0.6,
max_tokens=6000,
action="course_plan.generate_week",
)
if content is None or 'error' in content:
raise RuntimeError(
(content or {}).get('error', 'AI generation failed.')
)
# Wipe any previous materials for this week so re-generating is
# idempotent and we never accumulate duplicates.
existing = self.env['encoach.course.plan.material'].sudo().search([
('plan_id', '=', plan.id), ('week_id', '=', week.id),
])
if existing:
existing.unlink()
Material = self.env['encoach.course.plan.material'].sudo()
created = []
for m in content.get('materials') or []:
try:
rec = Material.create({
'plan_id': plan.id,
'week_id': week.id,
'skill': (m.get('skill') or 'integrated').strip().lower(),
'material_type': (m.get('material_type') or 'other').strip(),
'title': (m.get('title') or '').strip() or 'Untitled',
'summary': (m.get('summary') or '').strip(),
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
'body_text': self._flatten_body(m.get('body') or {}),
})
created.append(rec)
except Exception as exc: # pragma: no cover - defensive
_logger.warning("Skipping bad material row: %s", exc)
return created
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _chat_json(self, messages, **kwargs):
"""Best-effort wrapper around ``ai.chat_json``.
The underlying service may raise (network, invalid key, etc.),
or return a dict with an ``error`` field when content moderation
rejects the request. We normalise both to a dict so callers can
just check ``'error' in result``.
"""
try:
return self.ai.chat_json(messages, **kwargs)
except Exception as exc:
_logger.exception("Course plan AI call failed")
return {'error': str(exc)}
def _invoke_agent_or_chat(self, *, agent_key, system_msg, user_msg,
variables, temperature, max_tokens, action):
"""Route through AgentRuntime when available; fall back to chat_json.
Both branches return the same shape — a dict the caller can
``json.loads``-style consume — so the rest of the pipeline doesn't
change. We pass ``user_msg`` as the payload because the agent's own
system prompt is normally the one used; only when the agent is
missing do we pass the inline ``system_msg``.
"""
runtime = self._agent(agent_key)
if runtime is not None:
# The pipeline owns the JSON schema for backward-compat, so we
# forward the schema-bearing user message into the agent. The
# agent's stored system prompt covers the role/rules; we add
# the schema as ``extra_system`` so it's heeded but auditable.
final = runtime.invoke(
variables=variables,
payload=user_msg,
extra_system=system_msg,
)
if final.get("error"):
_logger.warning(
"agent %s failed (%s); falling back to direct chat_json",
agent_key, final.get("error"),
)
else:
output = final.get("output")
if isinstance(output, dict):
return output
# Text output — try parsing once, otherwise fall back.
try:
return json.loads(final.get("output_raw") or "{}")
except Exception:
pass
# Fallback path: plain OpenAI call (legacy).
return self._chat_json(
[
{"role": "system", "content": system_msg},
{"role": "user", "content": user_msg},
],
temperature=temperature,
max_tokens=max_tokens,
action=action,
)
@staticmethod
def _flatten_body(body):
"""Produce a plain-text dump of a material body for quick preview.
Not every shape is predictable (the model sometimes inserts
unusual keys), so we do a shallow walk and join string values
with newlines.
"""
if not isinstance(body, dict):
return ''
lines = []
for key, value in body.items():
if isinstance(value, str):
lines.append(f"{key}: {value}")
elif isinstance(value, list):
lines.append(f"{key}:")
for item in value:
if isinstance(item, str):
lines.append(f" - {item}")
elif isinstance(item, dict):
parts = []
for k, v in item.items():
if isinstance(v, (str, int, float)):
parts.append(f"{k}={v}")
if parts:
lines.append(" - " + ", ".join(parts))
elif isinstance(value, dict):
lines.append(f"{key}: " + json.dumps(value, ensure_ascii=False))
return "\n".join(lines)

View File

@@ -1 +1,3 @@
from . import models
from . import controllers
from . import utils # exposes odoo.addons.encoach_api.utils.cache

View File

@@ -8,7 +8,10 @@
'external_dependencies': {
'python': ['PyJWT'],
},
'data': [],
'data': [
'security/ir.model.access.csv',
'data/cron.xml',
],
'installable': True,
'license': 'LGPL-3',
}

View File

@@ -1,2 +1,5 @@
from . import base
from . import auth
from . import health
from . import openapi
from . import gdpr

View File

@@ -1,5 +1,25 @@
"""Authentication endpoints.
Split-token design:
* ``access_token`` — short-lived (1h), stateless, verified via signature only.
Sent on every request as ``Authorization: Bearer <access_token>``.
* ``refresh_token`` — long-lived (7d), stateful, backed by
``encoach.jwt.token``. Never sent on regular API calls; only to
``/api/auth/refresh``. Rotated on every refresh so a leaked token is
usable at most once.
The frontend should store ``refresh_token`` in a secure, non-extractable
location (ideally ``httpOnly`` cookie if the deployment proxy allows it,
otherwise ``localStorage`` behind an explicit trust boundary) and call
``/api/auth/refresh`` in the background whenever the access token is within
2 minutes of expiry.
"""
import logging
import time
import uuid
from datetime import datetime, timedelta
import jwt as pyjwt
@@ -13,6 +33,40 @@ from .base import (
_logger = logging.getLogger(__name__)
ACCESS_TTL_SECONDS = 3600 # 1 hour
REFRESH_TTL_SECONDS = 7 * 86400 # 7 days
def _issue_tokens(user, *, user_agent=None, remote_ip=None):
"""Mint a fresh ``(access_token, refresh_token, refresh_jti)`` triple.
Persisting the refresh token is the caller's job — they get the ``jti``
back so they can insert exactly one row.
"""
secret = _get_jwt_secret()
now = int(time.time())
access_token = pyjwt.encode(
{
"user_id": user.id,
"type": "access",
"iat": now,
"exp": now + ACCESS_TTL_SECONDS,
},
secret, algorithm="HS256",
)
refresh_jti = uuid.uuid4().hex
refresh_token = pyjwt.encode(
{
"user_id": user.id,
"type": "refresh",
"jti": refresh_jti,
"iat": now,
"exp": now + REFRESH_TTL_SECONDS,
},
secret, algorithm="HS256",
)
return access_token, refresh_token, refresh_jti
class EncoachAuthController(http.Controller):
@@ -30,7 +84,6 @@ class EncoachAuthController(http.Controller):
if not login or not password:
return _error_response('login and password are required', 400)
# Odoo 19: session.authenticate(env, credential_dict)
credential = {
'type': 'password',
'login': login,
@@ -49,26 +102,41 @@ class EncoachAuthController(http.Controller):
user = request.env['res.users'].sudo().browse(uid)
# Generate JWT token
secret = _get_jwt_secret()
if not secret:
return _error_response('JWT not configured on server', 500)
token = pyjwt.encode(
{'user_id': user.id, 'exp': int(time.time()) + 86400},
secret, algorithm='HS256',
)
# User-Agent / IP are best-effort — some proxies strip them; we
# record whatever we get but never refuse login because they're
# missing.
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
ip = request.httprequest.remote_addr if request.httprequest else ''
access_token, refresh_token, refresh_jti = _issue_tokens(
user, user_agent=ua, remote_ip=ip,
)
request.env['encoach.jwt.token'].sudo().create({
'jti': refresh_jti,
'user_id': user.id,
'issued_at': fields.Datetime.now(),
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
'user_agent': (ua or '')[:255],
'remote_ip': (ip or '')[:64],
})
# Get permissions
permissions = []
if hasattr(user, 'get_all_permissions'):
permissions = user.get_all_permissions().mapped('code')
# Update last login
user.write({'last_login': fields.Datetime.now()})
return _json_response({
'token': token,
'token': access_token, # legacy name (frontend fallback)
'access_token': access_token,
'refresh_token': refresh_token,
'expires_in': ACCESS_TTL_SECONDS,
'refresh_expires_in': REFRESH_TTL_SECONDS,
'token_type': 'Bearer',
'user': self._user_to_dict(user),
'permissions': permissions,
})
@@ -77,6 +145,90 @@ class EncoachAuthController(http.Controller):
_logger.exception('login failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/auth/refresh
# ------------------------------------------------------------------
@http.route('/api/auth/refresh', type='http', auth='public',
methods=['POST'], csrf=False)
def refresh(self, **kw):
"""Rotate a refresh token into a fresh access+refresh pair.
Consumes (revokes) the presented refresh token and issues new ones.
This is the *only* place a refresh token is ever accepted, so replay
attempts against other endpoints fail at the access-token layer.
"""
try:
body = _get_json_body()
token = body.get('refresh_token') or ''
if not token:
return _error_response('refresh_token is required', 400)
secret = _get_jwt_secret()
if not secret:
return _error_response('JWT not configured on server', 500)
try:
claims = pyjwt.decode(token, secret, algorithms=['HS256'])
except pyjwt.ExpiredSignatureError:
return _error_response('refresh_token expired', 401)
except pyjwt.InvalidTokenError:
return _error_response('refresh_token invalid', 401)
if claims.get('type') != 'refresh':
return _error_response('wrong token type', 401)
jti = claims.get('jti')
user_id = claims.get('user_id')
if not jti or not user_id:
return _error_response('refresh_token malformed', 401)
Token = request.env['encoach.jwt.token'].sudo()
row = Token.search([
('jti', '=', jti),
('user_id', '=', user_id),
('revoked', '=', False),
], limit=1)
if not row:
# Either already rotated (possible replay) or never existed.
# Revoke every active token for that user as a defensive
# measure — stolen refresh tokens shouldn't buy multiple
# rotations.
Token.search([
('user_id', '=', user_id),
('revoked', '=', False),
]).write({'revoked': True})
return _error_response('refresh_token revoked', 401)
user = request.env['res.users'].sudo().browse(user_id)
if not user.exists() or not user.active:
return _error_response('user disabled', 401)
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
ip = request.httprequest.remote_addr if request.httprequest else ''
access_token, refresh_token, refresh_jti = _issue_tokens(
user, user_agent=ua, remote_ip=ip,
)
row.write({'revoked': True, 'last_used_at': fields.Datetime.now()})
Token.create({
'jti': refresh_jti,
'user_id': user.id,
'issued_at': fields.Datetime.now(),
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
'user_agent': (ua or '')[:255],
'remote_ip': (ip or '')[:64],
})
return _json_response({
'access_token': access_token,
'token': access_token,
'refresh_token': refresh_token,
'expires_in': ACCESS_TTL_SECONDS,
'refresh_expires_in': REFRESH_TTL_SECONDS,
'token_type': 'Bearer',
})
except Exception as e:
_logger.exception('refresh failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/user (returns current authenticated user)
# ------------------------------------------------------------------
@@ -107,7 +259,32 @@ class EncoachAuthController(http.Controller):
@http.route('/api/logout', type='http', auth='public',
methods=['POST'], csrf=False)
def logout(self, **kw):
# JWT is stateless — client clears token. Server just returns OK.
"""Revoke the presented refresh token (if any) and return OK.
Access tokens remain stateless; they expire naturally within an hour.
Clients that care about immediate lockout should also delete the
access token from local storage when they call this endpoint.
"""
try:
body = _get_json_body() or {}
token = body.get('refresh_token') or ''
if token:
secret = _get_jwt_secret()
if secret:
try:
claims = pyjwt.decode(
token, secret, algorithms=['HS256'],
options={'verify_exp': False},
)
jti = claims.get('jti')
if jti:
request.env['encoach.jwt.token'].sudo().revoke_by_jti(jti)
except pyjwt.InvalidTokenError:
# Invalid tokens can't be revoked but also can't be
# reused, so this is effectively a no-op.
pass
except Exception:
_logger.exception('logout cleanup failed')
return _json_response({'ok': True})
# ------------------------------------------------------------------

View File

@@ -2,6 +2,7 @@ import json
import functools
import logging
import time
import uuid
import jwt as pyjwt
@@ -9,14 +10,75 @@ from odoo.http import request
_logger = logging.getLogger(__name__)
REQUEST_ID_HEADER = "X-Request-Id"
def _get_or_create_request_id():
"""Return the X-Request-Id for the current request, minting one if absent.
The value is cached on ``request`` so the same id is reused for every log
line emitted during the request lifecycle (dispatcher, controller,
post-dispatch). Call sites should prefer :func:`current_request_id`.
"""
existing = getattr(request, "_encoach_request_id", None)
if existing:
return existing
header_val = request.httprequest.headers.get(REQUEST_ID_HEADER)
req_id = (header_val or uuid.uuid4().hex)[:64]
try:
request._encoach_request_id = req_id
except Exception:
pass
return req_id
def current_request_id():
"""Best-effort accessor for the active request id (None outside a request)."""
try:
return getattr(request, "_encoach_request_id", None)
except Exception:
return None
def _log_json(level, event, **fields):
"""Emit a single structured JSON log line, enriched with the request id.
Keeping all non-interactive logs JSON-formatted lets ops scrape them with
Loki / OpenSearch without brittle regexes. Falls back silently if the
logging backend rejects the payload.
"""
try:
payload = {
"ts": round(time.time(), 3),
"event": event,
"rid": current_request_id(),
}
payload.update(fields)
_logger.log(level, json.dumps(payload, default=str, ensure_ascii=False))
except Exception:
_logger.log(level, "%s %s", event, fields)
_jwt_secret_cache = {"secret": None, "ts": 0}
_JWT_SECRET_TTL = 300
# Per-worker JWT secret cache TTL (seconds). Kept short so secret rotation
# via ir.config_parameter propagates quickly across all workers without
# requiring a process restart. See P0.10 in docs/PROJECT_SUMMARY.md §21.
_JWT_SECRET_TTL = 30
_user_exists_cache = {}
_USER_CACHE_TTL = 60
_USER_CACHE_MAX = 200
def invalidate_jwt_secret_cache():
"""Invalidate the per-worker JWT secret cache immediately.
Called by ``encoach.auth.settings`` whenever the admin rotates the secret
so that subsequent requests pick up the new value on the next read.
"""
_jwt_secret_cache["secret"] = None
_jwt_secret_cache["ts"] = 0
def _get_jwt_secret():
now = time.time()
if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL:
@@ -48,6 +110,12 @@ def validate_token():
return None
except pyjwt.InvalidTokenError:
return None
# Refresh tokens are only accepted by /api/auth/refresh. Presenting one as
# a Bearer on any other endpoint is treated as unauthenticated so a
# leaked refresh token never buys API access on its own.
token_type = payload.get("type")
if token_type and token_type != "access":
return None
user_id = payload.get("user_id")
if not user_id:
return None
@@ -70,26 +138,95 @@ def validate_token():
def jwt_required(func):
"""Decorator that validates the JWT token and sets request.env user context."""
"""Decorator that validates the JWT token and sets request.env user context.
Also handles per-request observability plumbing:
- Assigns (or honours) an ``X-Request-Id`` so every log line, downstream
service call, and response can be correlated back to the same request.
- Emits a structured ``api.request.start`` / ``api.request.end`` log pair
with duration in ms, status code, user id, route, and method — powering
the P2.3 metrics pipeline once it lands without any extra wiring.
- Echoes the request id back to the caller via the ``X-Request-Id``
response header.
"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
req_id = _get_or_create_request_id()
route = request.httprequest.path
method = request.httprequest.method
t0 = time.time()
user = validate_token()
if not user:
return _error_response("Authentication required", status=401)
_log_json(logging.INFO, "api.request.unauthorized",
route=route, method=method, status=401)
resp = _error_response("Authentication required", status=401)
try:
resp.headers[REQUEST_ID_HEADER] = req_id
except Exception:
pass
return resp
request.update_env(user=user.id)
return func(*args, **kwargs)
_log_json(logging.INFO, "api.request.start",
route=route, method=method, user_id=user.id)
try:
resp = func(*args, **kwargs)
status = getattr(resp, "status_code", 200)
duration_ms = int((time.time() - t0) * 1000)
_log_json(logging.INFO, "api.request.end",
route=route, method=method, user_id=user.id,
status=status, duration_ms=duration_ms)
try:
resp.headers[REQUEST_ID_HEADER] = req_id
except Exception:
pass
_record_metric(route, status, duration_ms)
return resp
except Exception as exc:
duration_ms = int((time.time() - t0) * 1000)
_log_json(logging.ERROR, "api.request.error",
route=route, method=method, user_id=user.id,
duration_ms=duration_ms,
error=type(exc).__name__, message=str(exc)[:500])
_record_metric(route, 500, duration_ms)
raise
return wrapper
def _record_metric(route, status, duration_ms):
"""Soft import of the metrics recorder so this module has no hard cycle."""
try:
from odoo.addons.encoach_api.controllers.openapi import record_request
record_request(route, status, duration_ms)
except Exception:
pass
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
resp = request.make_json_response(data, status=status)
try:
rid = current_request_id()
if rid:
resp.headers[REQUEST_ID_HEADER] = rid
except Exception:
pass
return resp
def _error_response(message, status=400, code=None):
body = {"error": message}
if code:
body["code"] = code
return request.make_json_response(body, status=status)
resp = request.make_json_response(body, status=status)
try:
rid = current_request_id()
if rid:
resp.headers[REQUEST_ID_HEADER] = rid
except Exception:
pass
return resp
def _get_json_body():
@@ -99,6 +236,31 @@ def _get_json_body():
return {}
def paginated_envelope(items, *, total=None, page=None, size=None, extra=None):
"""Canonical ``{items, total, page, size}`` envelope.
This is the platform-wide pagination shape — prefer returning the output of
this helper from any new list endpoint. Legacy keys (``data``, ``results``,
and custom aliases such as ``students`` / ``subjects``) may be added via
the ``extra`` dict for transitional backward compatibility with the
frontend services that still read those keys; new code must read ``items``.
"""
items = list(items or [])
envelope = {
"items": items,
"total": int(total if total is not None else len(items)),
}
if page is not None:
envelope["page"] = int(page)
if size is not None:
envelope["size"] = int(size)
if extra:
for k, v in extra.items():
if k not in envelope:
envelope[k] = v
return envelope
def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'):
"""Paginate an Odoo model search or extract params from a kwargs dict.

View File

@@ -0,0 +1,316 @@
"""GDPR data-subject rights endpoints.
Exposes two routes:
* ``GET /api/gdpr/export``
Returns a JSON snapshot of the calling user's personal data — profile,
entity membership, exam attempts, answers, AI interactions, feedback,
tickets, and login history.
* ``POST /api/gdpr/delete``
Initiates the "right to erasure" flow. Because Odoo enforces FK
constraints on linked business records (exam attempts, tickets, audit
trail), we **anonymise** instead of hard-deleting: the ``res.users``
row is deactivated, ``res.partner`` PII fields are wiped, and a
tombstone row is written to ``encoach.gdpr.erasure.request`` for
audit. Hard delete of linked business rows (attempts, answers,
feedback) is performed for records where it is legally required and
where no other user depends on them.
The flow is intentionally verbose so operators can see what happened. A
production deployment may want to queue the erasure to a background job
and require a cooling-off period before executing; we expose a
``confirm`` flag for that.
"""
from __future__ import annotations
import json
import logging
from odoo import fields, http
from odoo.http import request
from .base import (
_error_response,
_get_json_body,
_json_response,
jwt_required,
)
_logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
def _safe_records(env_name: str, domain, fields_to_read):
"""Read records if the model exists, else return []."""
env = request.env
if env_name not in env:
return []
try:
return env[env_name].sudo().search(domain).read(fields_to_read)
except Exception as exc:
_logger.warning("gdpr: could not read %s: %s", env_name, exc)
return []
def _sanitize(values):
"""Convert non-JSON-serialisable values to strings/nulls."""
out = {}
for key, value in values.items():
if isinstance(value, (list, tuple)) and len(value) == 2 and isinstance(value[0], int):
# Many2one read tuples — keep both id and display name.
out[key] = {"id": value[0], "display_name": value[1]}
elif isinstance(value, bytes):
out[key] = f"<{len(value)} bytes omitted>"
elif hasattr(value, "isoformat"):
out[key] = value.isoformat()
else:
out[key] = value
return out
# ----------------------------------------------------------------------
# Controller
# ----------------------------------------------------------------------
class EncoachGdprController(http.Controller):
"""Implements export + right-to-erasure endpoints."""
# ------------------------------------------------------------------
# GET /api/gdpr/export
# ------------------------------------------------------------------
@http.route("/api/gdpr/export", type="http", auth="none", methods=["GET"], csrf=False)
@jwt_required
def export(self, **kw):
try:
user = request.env.user
partner = user.partner_id
profile = {
"id": user.id,
"login": user.login,
"name": user.name,
"email": user.email or "",
"lang": user.lang or "",
"tz": user.tz or "",
"create_date": user.create_date.isoformat() if user.create_date else None,
"login_date": user.login_date.isoformat() if user.login_date else None,
}
partner_data = {
"id": partner.id if partner else None,
"name": partner.name if partner else None,
"email": partner.email if partner else None,
"phone": partner.phone if partner else None,
"mobile": partner.mobile if partner else None,
"street": partner.street if partner else None,
"city": partner.city if partner else None,
"country": partner.country_id.name if partner and partner.country_id else None,
}
# Entity / role memberships
entity_rel = _safe_records(
"encoach.user.entity.rel",
[("user_id", "=", user.id)],
["id", "entity_id", "role_id", "is_active"],
)
# Exam attempts & answers
attempts = _safe_records(
"encoach.student.attempt",
[("user_id", "=", user.id)],
["id", "exam_id", "status", "score", "started_at", "submitted_at"],
)
attempt_ids = [a["id"] for a in attempts]
answers = _safe_records(
"encoach.student.answer",
[("attempt_id", "in", attempt_ids)],
["id", "attempt_id", "question_id", "student_answer", "is_correct", "score"],
) if attempt_ids else []
# AI feedback they left
feedback = _safe_records(
"encoach.ai.feedback",
[("user_id", "=", user.id)],
["id", "subject_type", "subject_id", "rating", "comment", "status", "create_date"],
)
# AI calls they triggered (logs)
ai_logs = _safe_records(
"encoach.ai.log",
[("user_id", "=", user.id)] if "encoach.ai.log" in request.env else [],
["id", "service", "action", "model_used", "total_tokens", "status", "create_date"],
)
# Tickets they filed
tickets = _safe_records(
"encoach.ticket",
[("created_by_id", "=", user.id)],
["id", "name", "status", "priority", "assigned_to_id", "create_date"],
)
# Feedback on their coaching, if any
coaching_sessions = _safe_records(
"encoach.coaching.session",
[("user_id", "=", user.id)],
["id", "create_date", "summary"],
)
payload = {
"profile": profile,
"partner": partner_data,
"entity_memberships": [_sanitize(r) for r in entity_rel],
"exam_attempts": [_sanitize(r) for r in attempts],
"exam_answers": [_sanitize(r) for r in answers],
"ai_feedback": [_sanitize(r) for r in feedback],
"ai_calls": [_sanitize(r) for r in ai_logs],
"tickets": [_sanitize(r) for r in tickets],
"coaching_sessions": [_sanitize(r) for r in coaching_sessions],
"exported_at": fields.Datetime.to_string(fields.Datetime.now()),
"export_format_version": "1.0",
}
return _json_response(payload)
except Exception as e:
_logger.exception("gdpr export failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/gdpr/delete
# ------------------------------------------------------------------
@http.route("/api/gdpr/delete", type="http", auth="none", methods=["POST"], csrf=False)
@jwt_required
def delete(self, **kw):
try:
body = _get_json_body() or {}
if not body.get("confirm"):
return _error_response(
"Set {\"confirm\": true} to initiate erasure. "
"This action cannot be undone.",
400,
)
user = request.env.user
if user.id == 1 or user.has_group("base.group_system"):
return _error_response(
"Admin accounts cannot self-erase via this endpoint. "
"Contact the data protection officer.",
403,
)
login = user.login
email = user.email or ""
archived_id = user.id
summary = {
"anonymised_partner_fields": [],
"deactivated_user": False,
"deleted_feedback_count": 0,
"deleted_coaching_count": 0,
"retained_attempts_count": 0,
}
with request.env.cr.savepoint():
# 1. Anonymise the partner PII.
partner = user.partner_id
if partner:
partner.sudo().write({
"name": f"deleted-user-{archived_id}",
"email": False,
"phone": False,
"mobile": False,
"street": False,
"street2": False,
"city": False,
"zip": False,
"image_1920": False,
"comment": "Erased per GDPR request.",
})
summary["anonymised_partner_fields"] = [
"name", "email", "phone", "mobile",
"street", "street2", "city", "zip",
"image_1920", "comment",
]
# 2. Hard-delete their feedback (personal reviews/comments).
if "encoach.ai.feedback" in request.env:
feedback = request.env["encoach.ai.feedback"].sudo().search([
("user_id", "=", user.id),
])
summary["deleted_feedback_count"] = len(feedback)
feedback.unlink()
# 3. Hard-delete coaching session transcripts (personal).
if "encoach.coaching.session" in request.env:
sessions = request.env["encoach.coaching.session"].sudo().search([
("user_id", "=", user.id),
])
summary["deleted_coaching_count"] = len(sessions)
sessions.unlink()
# 4. Retain exam attempts (aggregate analytics rely on them)
# but strip personal free-text fields where they exist.
if "encoach.student.attempt" in request.env:
retained = request.env["encoach.student.attempt"].sudo().search([
("user_id", "=", user.id),
])
summary["retained_attempts_count"] = len(retained)
# Clear any written responses that contain user PII — keep
# the scores/timestamps for analytics integrity.
if "encoach.student.answer" in request.env:
answers = request.env["encoach.student.answer"].sudo().search([
("attempt_id", "in", retained.ids),
])
# student_answer can contain free-text PII.
answers.write({"student_answer": False})
# 5. Rewrite AI log user_id → None so we can no longer tie
# those rows to the person.
if "encoach.ai.log" in request.env:
ai_logs = request.env["encoach.ai.log"].sudo().search([
("user_id", "=", user.id),
])
if ai_logs:
try:
ai_logs.write({"user_id": False})
except Exception:
# user_id may be required; fall back to deletion.
ai_logs.unlink()
# 6. Deactivate the user (we can't unlink — FKs).
user.sudo().write({
"active": False,
"login": f"erased-{archived_id}@example.invalid",
})
summary["deactivated_user"] = True
# 7. Write tombstone audit row.
request.env["encoach.gdpr.erasure.request"].sudo().create({
"user_login": login,
"user_email": email,
"user_id_archived": archived_id,
"status": "completed",
"summary": json.dumps(summary),
"completed_at": fields.Datetime.now(),
})
# Blow away any cached JWT sessions for this user.
try:
from odoo.addons.encoach_api.utils.jwt_cache import (
invalidate_user as _invalidate_user,
)
_invalidate_user(archived_id)
except Exception as exc:
_logger.debug("jwt cache invalidation skipped: %s", exc)
return _json_response({
"ok": True,
"summary": summary,
"message": (
"Your account has been anonymised and deactivated. "
"Some records (e.g. exam attempts) are retained "
"without personal identifiers for statistical purposes."
),
})
except Exception as e:
_logger.exception("gdpr delete failed")
return _error_response(str(e), 500)

View File

@@ -0,0 +1,65 @@
"""Platform health probes (P0.4).
Exposes unauthenticated ``/api/health`` (quick liveness) and ``/api/health/ready``
(deep readiness: DB + JWT secret + OpenAI key). Designed to be consumed by
uptime monitors, load-balancer health checks and container orchestrators.
"""
import logging
import time
from odoo import http
from odoo.http import request
from odoo.release import version as odoo_version
_logger = logging.getLogger(__name__)
_STARTED_AT = time.time()
class HealthController(http.Controller):
"""Platform health probes — safe to expose publicly."""
@http.route("/api/health", type="http", auth="none", methods=["GET"], csrf=False)
def health(self, **_kw):
"""Liveness probe: the process is up and able to handle HTTP."""
return request.make_json_response({
"status": "ok",
"service": "encoach-backend",
"odoo_version": odoo_version,
"uptime_seconds": int(time.time() - _STARTED_AT),
})
@http.route("/api/health/ready", type="http", auth="none", methods=["GET"], csrf=False)
def ready(self, **_kw):
"""Readiness probe: DB reachable, JWT secret configured, AI key known."""
checks = {}
ok = True
try:
request.env.cr.execute("SELECT 1")
checks["database"] = "ok"
except Exception as exc:
ok = False
checks["database"] = f"error: {exc}"
try:
IrParam = request.env["ir.config_parameter"].sudo()
checks["jwt_secret"] = "ok" if IrParam.get_param("encoach.jwt_secret") else "missing"
if checks["jwt_secret"] == "missing":
ok = False
ai_key = IrParam.get_param("encoach_ai.openai_api_key")
import os
if not ai_key:
ai_key = os.environ.get("OPENAI_API_KEY", "")
checks["openai_key"] = "ok" if ai_key else "missing"
except Exception as exc:
ok = False
checks["config"] = f"error: {exc}"
status = 200 if ok else 503
return request.make_json_response({
"status": "ok" if ok else "degraded",
"checks": checks,
"uptime_seconds": int(time.time() - _STARTED_AT),
}, status=status)

View File

@@ -0,0 +1,265 @@
"""OpenAPI 3.0 + Prometheus-ish metrics exporter.
Both endpoints are **unauthenticated** by design so they can be scraped by
third-party tooling (Swagger UI, Prometheus, uptime monitors) without needing
to solve a JWT exchange first. The OpenAPI spec is generated by introspecting
every class decorated with :func:`odoo.http.route` and therefore stays in sync
with the running server automatically.
Scope: the generator emits a minimal but valid OpenAPI document — routes,
HTTP methods, path params, tag derived from the route prefix, and a shared
``BearerAuth`` security scheme. Request / response schemas are left open
(``{"type": "object"}``) since we don't have pydantic models yet; a future
iteration can enrich individual routes via ``route_decorated_method.openapi``
annotations.
"""
from __future__ import annotations
import logging
import re
import time
from collections import Counter, defaultdict
from odoo import http
from odoo.http import Controller, Response, request
_logger = logging.getLogger(__name__)
_metrics_lock = None
_metrics: dict = {
"started_at": time.time(),
"routes": Counter(), # key -> hit count
"statuses": Counter(), # status bucket -> hit count
"latencies_ms": defaultdict(list), # route -> [recent ms samples] (cap 128)
}
_LATENCY_SAMPLE_CAP = 128
_PATH_PARAM_RE = re.compile(r"<(?:(?P<conv>\w+):)?(?P<name>\w+)>")
def record_request(route: str, status: int, duration_ms: int) -> None:
"""Lightweight in-process request counter used by the metrics endpoint.
Intentionally thread-unsafe for minimum overhead; worst case is a slightly
off counter on concurrent writes which is fine for ops dashboards. Switch
to a proper Prometheus client (``prometheus_client``) in P2.3 when needed.
"""
try:
_metrics["routes"][route] += 1
bucket = f"{status // 100}xx" if isinstance(status, int) else "xxx"
_metrics["statuses"][bucket] += 1
samples = _metrics["latencies_ms"][route]
samples.append(int(duration_ms))
if len(samples) > _LATENCY_SAMPLE_CAP:
del samples[0:len(samples) - _LATENCY_SAMPLE_CAP]
except Exception:
pass
def _convert_odoo_path(path: str) -> str:
"""Translate Odoo's ``<int:foo>`` placeholder syntax to OpenAPI ``{foo}``."""
def repl(m: re.Match) -> str:
return "{" + m.group("name") + "}"
return _PATH_PARAM_RE.sub(repl, path)
def _path_parameters(path: str) -> list[dict]:
params = []
for m in _PATH_PARAM_RE.finditer(path):
conv = (m.group("conv") or "string").lower()
if conv in ("int", "integer"):
schema = {"type": "integer"}
elif conv in ("float", "number"):
schema = {"type": "number"}
else:
schema = {"type": "string"}
params.append({
"name": m.group("name"),
"in": "path",
"required": True,
"schema": schema,
})
return params
def _iter_routes():
"""Yield ``(route_str, method_name, tag, class_name, doc)`` for every
``@http.route`` decorated callable currently registered.
Uses Odoo's live ``ir.http.routing_map()`` so we pick up every handler the
dispatcher actually routes to — including extension classes.
"""
seen = set()
try:
routing_map = request.env["ir.http"].routing_map()
except Exception:
_logger.exception("could not fetch routing_map")
return
try:
rules = list(routing_map.iter_rules())
except Exception:
_logger.exception("routing_map has no iter_rules")
return
for rule in rules:
url = rule.rule
if not isinstance(url, str) or not url.startswith("/api/"):
continue
endpoint = rule.endpoint
# ``endpoint`` is a wrapped callable; the real controller method is
# stashed on ``endpoint.func`` by Odoo.
fn = getattr(endpoint, "func", endpoint)
routing = getattr(fn, "routing", {}) or {}
methods = routing.get("methods") or list(rule.methods or {"GET"})
methods = [m for m in methods if m not in {"HEAD", "OPTIONS"}]
if not methods:
methods = ["GET"]
cls_name = ""
try:
qual = getattr(fn, "__qualname__", fn.__name__)
cls_name = qual.split(".")[0] if "." in qual else ""
except Exception:
cls_name = ""
module = getattr(fn, "__module__", "") or ""
tag_base = module.split(".")[-2] if "." in module else module or "api"
doc = ""
try:
doc = (fn.__doc__ or "").strip()
except Exception:
pass
for m in methods:
key = (url, m.upper(), cls_name, fn.__name__)
if key in seen:
continue
seen.add(key)
yield url, m.upper(), tag_base, cls_name, doc
def _build_openapi_spec() -> dict:
base_url = (
request.env["ir.config_parameter"].sudo().get_param("web.base.url")
or "http://localhost:8069"
)
paths: dict[str, dict] = {}
tags: dict[str, str] = {}
for route, method, tag_base, cls_name, doc in _iter_routes():
openapi_path = _convert_odoo_path(route)
path_entry = paths.setdefault(openapi_path, {})
# Derive a friendly tag from the URL prefix (``/api/exam/...`` -> ``exam``).
segs = [s for s in route.split("/") if s and s != "api"]
tag = segs[0] if segs else tag_base
tags.setdefault(tag, f"Endpoints under /api/{tag}")
summary = doc.splitlines()[0][:120] if doc else f"{cls_name}.{route}"
op: dict = {
"summary": summary,
"operationId": f"{cls_name}_{method.lower()}_{route.strip('/').replace('/', '_').replace('<', '_').replace('>', '_').replace(':', '_')}",
"tags": [tag],
"security": [{"BearerAuth": []}],
"responses": {
"200": {"description": "OK",
"content": {"application/json":
{"schema": {"type": "object"}}}},
"401": {"description": "Authentication required"},
"400": {"description": "Bad request"},
"500": {"description": "Server error"},
},
}
params = _path_parameters(route)
if params:
op["parameters"] = params
if method in ("POST", "PUT", "PATCH"):
op["requestBody"] = {
"required": False,
"content": {"application/json":
{"schema": {"type": "object"}}},
}
path_entry[method.lower()] = op
return {
"openapi": "3.0.3",
"info": {
"title": "EnCoach Platform API",
"version": "19.0.1.0",
"description": (
"Auto-generated from ``@http.route`` decorators. "
"Schemas are currently open; see docs/PROJECT_SUMMARY.md §21 for the "
"roadmap to enrich request/response shapes."
),
},
"servers": [{"url": base_url}],
"tags": [{"name": name, "description": desc}
for name, desc in sorted(tags.items())],
"components": {
"securitySchemes": {
"BearerAuth": {
"type": "http", "scheme": "bearer", "bearerFormat": "JWT",
},
},
},
"paths": dict(sorted(paths.items())),
}
class EncoachOpenAPIController(Controller):
@http.route("/api/openapi.json", type="http", auth="none",
methods=["GET"], csrf=False)
def openapi(self, **_kw):
"""Return a freshly generated OpenAPI 3.0 spec for the running server."""
try:
spec = _build_openapi_spec()
return request.make_json_response(spec)
except Exception:
_logger.exception("openapi generation failed")
return request.make_json_response(
{"error": "could not generate openapi spec"}, status=500,
)
@http.route("/api/metrics", type="http", auth="none",
methods=["GET"], csrf=False)
def metrics(self, **_kw):
"""Return in-process request counters in a Prometheus-compatible text format.
This is a minimalist shim, not a full Prometheus client. It exposes:
- ``encoach_requests_total{route, method, status_bucket}`` — request count
- ``encoach_request_latency_ms_p95{route}`` — rolling p95 (sample-window based)
- ``encoach_uptime_seconds`` — process uptime
"""
lines = [
"# HELP encoach_uptime_seconds Seconds since this worker booted",
"# TYPE encoach_uptime_seconds gauge",
f"encoach_uptime_seconds {int(time.time() - _metrics['started_at'])}",
"",
"# HELP encoach_requests_total Total number of /api/* requests handled",
"# TYPE encoach_requests_total counter",
]
for route, count in _metrics["routes"].most_common():
safe = route.replace('"', '\\"')
lines.append(f'encoach_requests_total{{route="{safe}"}} {count}')
lines.append("")
lines.append("# HELP encoach_responses_total Count of responses per status bucket")
lines.append("# TYPE encoach_responses_total counter")
for bucket, count in _metrics["statuses"].items():
lines.append(f'encoach_responses_total{{bucket="{bucket}"}} {count}')
lines.append("")
lines.append("# HELP encoach_request_latency_ms_p95 Rolling p95 latency per route")
lines.append("# TYPE encoach_request_latency_ms_p95 gauge")
for route, samples in _metrics["latencies_ms"].items():
if not samples:
continue
sorted_samples = sorted(samples)
p95 = sorted_samples[min(len(sorted_samples) - 1,
int(0.95 * len(sorted_samples)))]
safe = route.replace('"', '\\"')
lines.append(
f'encoach_request_latency_ms_p95{{route="{safe}"}} {p95}'
)
body = "\n".join(lines) + "\n"
return Response(body, status=200,
content_type="text/plain; version=0.0.4")

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="ir_cron_purge_expired_jwt" model="ir.cron">
<field name="name">EnCoach: purge expired JWT refresh tokens</field>
<field name="model_id" ref="model_encoach_jwt_token"/>
<field name="state">code</field>
<field name="code">model.purge_expired()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import jwt_token
from . import gdpr_erasure

View File

@@ -0,0 +1,35 @@
"""Tombstone record written when a user exercises the right-to-erasure.
We cannot hard-delete the original res.users row (ORM FK constraints on
attempts, tickets, audit trail, etc.), so this table lets us prove the
request happened, what was anonymised/deleted, and when.
"""
from odoo import fields, models
class EncoachGdprErasureRequest(models.Model):
_name = "encoach.gdpr.erasure.request"
_description = "GDPR right-to-erasure audit trail"
_order = "create_date desc"
user_login = fields.Char(required=True, index=True)
user_email = fields.Char(index=True)
user_id_archived = fields.Integer(
index=True,
help="Id of the res.users row at time of erasure (row is now archived).",
)
status = fields.Selection(
[
("requested", "Requested"),
("completed", "Completed"),
("partial", "Partial — manual follow-up required"),
],
default="requested", required=True,
)
summary = fields.Text(
help="JSON summary of what was deleted/anonymised/retained.",
)
requested_at = fields.Datetime(default=fields.Datetime.now)
completed_at = fields.Datetime()
notes = fields.Text()

View File

@@ -0,0 +1,105 @@
"""Refresh-token ledger for the EnCoach API.
Access tokens stay short-lived (1 hour) and stateless — they are verified
with the JWT signature alone. Refresh tokens, on the other hand, must be
revocable so compromised devices can be signed out without rotating the
server secret. Every refresh token corresponds to one row here, keyed by a
UUID (``jti``) that the client echoes back on ``/api/auth/refresh``.
Rotation policy: ``/api/auth/refresh`` consumes the presented row (revokes
it) and issues a brand-new row, so a stolen refresh token only works once.
Replay attempts after rotation will be rejected because the replayed ``jti``
is no longer ``active``.
"""
from __future__ import annotations
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class EncoachJwtToken(models.Model):
_name = "encoach.jwt.token"
_description = "JWT Refresh Token Ledger"
_order = "issued_at desc"
_rec_name = "jti"
jti = fields.Char(
string="Token ID",
required=True,
index=True,
help="UUID used as the JWT's `jti` claim; serves as the primary handle.",
)
user_id = fields.Many2one(
"res.users",
string="User",
required=True,
ondelete="cascade",
index=True,
)
issued_at = fields.Datetime(required=True, default=fields.Datetime.now)
expires_at = fields.Datetime(required=True)
last_used_at = fields.Datetime()
revoked = fields.Boolean(default=False, index=True)
user_agent = fields.Char(help="User-Agent header of the issuing client, for audit.")
remote_ip = fields.Char(help="Remote IP of the issuing client, for audit.")
@api.model
def _auto_init(self):
res = super()._auto_init()
cr = self.env.cr
for name, ddl in (
(
"encoach_jwt_token_jti_unique",
"CREATE UNIQUE INDEX IF NOT EXISTS encoach_jwt_token_jti_unique "
"ON encoach_jwt_token (jti)",
),
(
"encoach_jwt_token_user_revoked_idx",
"CREATE INDEX IF NOT EXISTS encoach_jwt_token_user_revoked_idx "
"ON encoach_jwt_token (user_id, revoked, expires_at)",
),
):
try:
cr.execute(ddl)
except Exception:
_logger.warning("could not create index %s", name, exc_info=True)
return res
@api.model
def revoke_by_jti(self, jti: str) -> bool:
"""Soft-revoke every row matching ``jti``.
Returns ``True`` iff at least one row was flipped. Called from
``/api/auth/refresh`` (to consume the rotated token) and
``/api/logout`` (to end the session).
"""
if not jti:
return False
rows = self.sudo().search([("jti", "=", jti), ("revoked", "=", False)])
if not rows:
return False
rows.write({"revoked": True})
return True
@api.model
def purge_expired(self, batch_size: int = 500) -> int:
"""Remove rows that expired more than a day ago.
Wired to the daily cleanup cron in :file:`data/cron.xml`; manual
invocations (e.g. migrations) should pass a larger ``batch_size``
to avoid multi-pass overhead.
"""
from datetime import datetime, timedelta
cutoff = datetime.utcnow() - timedelta(days=1)
rows = self.sudo().search(
[("expires_at", "<", cutoff)],
limit=batch_size,
)
n = len(rows)
if n:
rows.unlink()
return n

View File

@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_jwt_token_user,encoach.jwt.token user,model_encoach_jwt_token,base.group_user,1,0,0,0
access_encoach_jwt_token_admin,encoach.jwt.token admin,model_encoach_jwt_token,base.group_system,1,1,1,1
access_gdpr_erasure_admin,encoach.gdpr.erasure.request admin,model_encoach_gdpr_erasure_request,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_jwt_token_user encoach.jwt.token user model_encoach_jwt_token base.group_user 1 0 0 0
3 access_encoach_jwt_token_admin encoach.jwt.token admin model_encoach_jwt_token base.group_system 1 1 1 1
4 access_gdpr_erasure_admin encoach.gdpr.erasure.request admin model_encoach_gdpr_erasure_request base.group_system 1 1 1 1

View File

@@ -0,0 +1 @@
from . import test_health

View File

@@ -0,0 +1,27 @@
"""Smoke tests for the health + GDPR endpoints.
Run with:
./odoo-bin -c odoo.conf --test-tags encoach_api --stop-after-init
"""
import json
from odoo.tests import HttpCase, tagged
@tagged("post_install", "-at_install", "encoach_api")
class TestHealthEndpoints(HttpCase):
def test_health_live_returns_200(self):
"""``GET /api/health`` must always return 200 once Odoo has booted."""
response = self.url_open("/api/health", timeout=10)
self.assertEqual(response.status_code, 200)
body = response.json() if hasattr(response, "json") else json.loads(response.text)
self.assertTrue(body.get("ok") is True or body.get("status") in {"ok", "live"})
def test_health_ready_returns_2xx(self):
"""``GET /api/health/ready`` is allowed to flap, but must not 5xx."""
response = self.url_open("/api/health/ready", timeout=10)
self.assertIn(
response.status_code, (200, 503),
f"unexpected status {response.status_code}: {response.text[:200]}",
)

View File

@@ -0,0 +1 @@
from . import cache # noqa: F401

View File

@@ -0,0 +1,106 @@
"""Zero-dependency in-process TTL cache for hot endpoints.
Designed for read-heavy, mostly-idempotent endpoints like the admin report
pages (``/api/reports/stats-corporate``, ``/api/reports/student-performance``)
and the AI narrative generator. A single LRU-ish dict per worker with a hard
time bound is usually enough to absorb the dashboard refresh storm caused by
an admin tabbing between pages.
**Not** a substitute for Redis — cross-worker consistency and bounded memory
are out of scope. If ops ever needs those guarantees, swap the internal dict
for a ``redis.Redis.setex`` call; the public API (``memoize_ttl`` and
``invalidate``) stays identical.
"""
from __future__ import annotations
import hashlib
import json
import logging
import threading
import time
from functools import wraps
from typing import Any, Callable
_logger = logging.getLogger(__name__)
_cache: dict[str, tuple[float, Any]] = {}
_lock = threading.Lock()
_MAX_ENTRIES = 512
def _make_key(namespace: str, args: tuple, kwargs: dict) -> str:
payload = json.dumps([args, kwargs], sort_keys=True, default=str)
digest = hashlib.md5(payload.encode("utf-8")).hexdigest()
return f"{namespace}:{digest}"
def get(key: str):
entry = _cache.get(key)
if not entry:
return None
expiry, value = entry
if expiry < time.time():
with _lock:
_cache.pop(key, None)
return None
return value
def put(key: str, value, ttl_seconds: int) -> None:
with _lock:
if len(_cache) >= _MAX_ENTRIES:
oldest = sorted(_cache.items(), key=lambda kv: kv[1][0])[: _MAX_ENTRIES // 4]
for k, _v in oldest:
_cache.pop(k, None)
_cache[key] = (time.time() + max(1, ttl_seconds), value)
def invalidate(namespace: str | None = None) -> int:
"""Drop every cached entry, or just entries under ``namespace``.
Returns the number of removed entries. Callers should invoke this from
write endpoints that affect the cached read so subsequent reads observe
the new state.
"""
removed = 0
with _lock:
if namespace is None:
removed = len(_cache)
_cache.clear()
return removed
prefix = f"{namespace}:"
keys = [k for k in _cache if k.startswith(prefix)]
for k in keys:
_cache.pop(k, None)
removed = len(keys)
return removed
def memoize_ttl(namespace: str, ttl_seconds: int = 30):
"""Decorator: cache ``func(*args, **kwargs)`` for ``ttl_seconds`` per key.
The key is derived from the namespace + a stable JSON dump of the args,
so callers don't need to worry about mutable keyword order or unhashable
defaults. JWT decorator should run *before* this one so unauthenticated
traffic never hits the cache.
"""
def decorator(func: Callable):
@wraps(func)
def wrapper(*args, **kwargs):
try:
key = _make_key(namespace, args, kwargs)
except Exception:
return func(*args, **kwargs)
cached = get(key)
if cached is not None:
return cached
value = func(*args, **kwargs)
try:
put(key, value, ttl_seconds)
except Exception:
_logger.debug("cache put failed for %s", namespace, exc_info=True)
return value
wrapper._encoach_cache_namespace = namespace
return wrapper
return decorator

View File

@@ -7,4 +7,4 @@ from . import exam_schedules
from . import rubrics
from . import approval_workflows
from . import entities
from . import exam_session
from . import review_workflow

View File

@@ -57,17 +57,33 @@ def _ser_workflow(wf):
def _ser_request(req):
stage = req.current_stage_id
# Resolve target record so the UI can show "what am I approving?"
# rather than an opaque id.
target_name = ''
target_status = ''
if req.res_model and req.res_id:
try:
target = request.env[req.res_model].sudo().browse(req.res_id)
if target.exists():
target_name = getattr(target, 'title', '') or getattr(target, 'name', '') or ''
target_status = getattr(target, 'status', '') or ''
except (KeyError, AttributeError):
pass
return {
'id': req.id,
'workflow_id': req.workflow_id.id or None,
'workflow_name': req.workflow_id.name if req.workflow_id else '',
'res_model': req.res_model or '',
'res_id': req.res_id or 0,
'target_name': target_name,
'target_status': target_status,
'state': req.state or 'draft',
'requester_id': req.requester_id.id or None,
'requester_name': req.requester_id.name if req.requester_id else '',
'current_stage_id': stage.id or None,
'current_stage_sequence': stage.sequence if stage else None,
'current_stage_approver_id': stage.approver_id.id if stage and stage.approver_id else None,
'current_stage_approver_name': stage.approver_id.name if stage and stage.approver_id else '',
'bypass_reason': req.bypass_reason or '',
'created_at': req.created_at.isoformat() if req.created_at else None,
}
@@ -234,6 +250,16 @@ class ApprovalWorkflowController(http.Controller):
methods=['GET'], csrf=False)
@jwt_required
def list_requests(self, **kw):
"""List approval requests.
Query params:
* ``state`` — filter by request state
* ``workflow_id`` — filter by workflow
* ``mine=1`` — only requests currently awaiting the calling user
(current_stage.approver_id == me). Used by the "My approvals"
queue so approvers see exactly what's on their plate.
* ``requester=1`` — only requests the calling user submitted
"""
try:
M = request.env['encoach.approval.request'].sudo()
domain = []
@@ -241,6 +267,14 @@ class ApprovalWorkflowController(http.Controller):
domain.append(('state', '=', kw['state']))
if kw.get('workflow_id'):
domain.append(('workflow_id', '=', int(kw['workflow_id'])))
if kw.get('mine') in ('1', 'true', True):
uid = request.env.user.id
domain += [
('current_stage_id.approver_id', '=', uid),
('state', 'in', ('draft', 'in_progress')),
]
if kw.get('requester') in ('1', 'true', True):
domain.append(('requester_id', '=', request.env.user.id))
recs = M.search(domain, order='created_at desc, id desc')
items = [_ser_request(r) for r in recs]
return _json_response({
@@ -256,36 +290,56 @@ class ApprovalWorkflowController(http.Controller):
auth='none', methods=['POST'], csrf=False)
@jwt_required
def approve_request(self, req_id, **kw):
"""Approve the current stage or finalize the request.
Wrapped in a ``SAVEPOINT`` so stage + request writes commit or roll back
atomically. The savepoint is released only after both writes succeed,
preventing partial states (e.g. stage marked ``approved`` while request
still ``in_progress``) that were possible with the previous
non-transactional implementation.
"""
try:
body = _get_json_body()
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
if not req_rec.exists():
return _error_response('Request not found', 404)
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'approved',
'comment': body.get('comment', ''),
'acted_at': datetime.now(),
})
with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'approved',
'comment': body.get('comment', ''),
'acted_at': datetime.now(),
})
# Move to next stage or finalize.
stages = req_rec.workflow_id.stage_ids.sorted('sequence')
stage_ids = [s.id for s in stages]
try:
idx = stage_ids.index(stage.id) if stage else -1
except ValueError:
idx = -1
stages = req_rec.workflow_id.stage_ids.sorted('sequence')
stage_ids = [s.id for s in stages]
try:
idx = stage_ids.index(stage.id) if stage else -1
except ValueError:
idx = -1
if 0 <= idx and idx + 1 < len(stage_ids):
next_stage = request.env['encoach.approval.stage'].sudo().browse(stage_ids[idx + 1])
req_rec.write({
'current_stage_id': next_stage.id,
'state': 'in_progress',
})
else:
req_rec.write({'state': 'approved'})
if 0 <= idx and idx + 1 < len(stage_ids):
next_stage = request.env['encoach.approval.stage'].sudo().browse(
stage_ids[idx + 1]
)
req_rec.write({
'current_stage_id': next_stage.id,
'state': 'in_progress',
})
else:
req_rec.write({'state': 'approved'})
# Last stage passed — publish the underlying record.
# Today this is always an exam; guard defensively so
# other res_models don't raise.
if req_rec.res_model == 'encoach.exam.custom' and req_rec.res_id:
try:
target = request.env['encoach.exam.custom'].sudo().browse(req_rec.res_id)
if target.exists() and target.status in ('draft', 'pending_review', 'pending_approval'):
target.write({'status': 'published'})
except Exception:
_logger.exception('failed to publish exam %s on final approval', req_rec.res_id)
return _json_response({'success': True, 'id': req_id,
'state': req_rec.state})
@@ -297,19 +351,34 @@ class ApprovalWorkflowController(http.Controller):
auth='none', methods=['POST'], csrf=False)
@jwt_required
def reject_request(self, req_id, **kw):
"""Reject a stage and mark the request rejected atomically.
Uses a ``SAVEPOINT`` so that if the request ``write`` fails after the
stage has been updated, Postgres rolls both writes back and the caller
sees a clean 500 rather than a half-updated workflow.
"""
try:
body = _get_json_body()
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
if not req_rec.exists():
return _error_response('Request not found', 404)
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'rejected',
'comment': body.get('comment', ''),
'acted_at': datetime.now(),
})
req_rec.write({'state': 'rejected'})
with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'rejected',
'comment': body.get('comment', ''),
'acted_at': datetime.now(),
})
req_rec.write({'state': 'rejected'})
# Mark the underlying exam rejected so the author can see it.
if req_rec.res_model == 'encoach.exam.custom' and req_rec.res_id:
try:
target = request.env['encoach.exam.custom'].sudo().browse(req_rec.res_id)
if target.exists():
target.write({'status': 'rejected'})
except Exception:
_logger.exception('failed to mark exam %s rejected', req_rec.res_id)
return _json_response({'success': True, 'id': req_id,
'state': req_rec.state})
except Exception as e:
@@ -322,18 +391,33 @@ class ApprovalWorkflowController(http.Controller):
methods=['GET'], csrf=False)
@jwt_required
def list_users(self, **kw):
"""Return users eligible to act as approvers.
Explicitly excludes:
* inactive users, ``__system__`` (id=1), the portal template user, and
any ``op.student`` mirrored account (``op_student_id`` set)
* users with ``user_type='student'`` — students must never be
approvers; QA flagged student logins appearing in the picker.
"""
try:
Users = request.env['res.users'].sudo()
domain = [('active', '=', True), ('id', '>', 1)]
domain = [
('active', '=', True),
('id', '>', 1),
('share', '=', False),
'|', ('user_type', '=', False), ('user_type', '!=', 'student'),
('op_student_id', '=', False),
]
search = (kw.get('search') or '').strip()
if search:
domain += ['|', ('name', 'ilike', search), ('login', 'ilike', search)]
recs = Users.search(domain, order='name', limit=100)
recs = Users.search(domain, order='name', limit=200)
items = [{
'id': u.id,
'name': u.name or u.login,
'login': u.login or '',
'email': u.email or '',
'user_type': u.user_type or '',
} for u in recs]
return _json_response({'items': items, 'results': items, 'total': len(items)})
except Exception as e:

View File

@@ -142,9 +142,23 @@ class EncoachExamScheduleController(http.Controller):
if batch_ids:
vals['batch_ids'] = [(6, 0, [int(b) for b in batch_ids])]
# IMPORTANT: the frontend's student picker consumes /api/students
# which returns `op.student.id` values. `schedule.student_ids` is a
# Many2many → res.users, so we must resolve op.student → user_id
# before writing. Skipping this step silently linked schedules to
# whatever `res.users` row happened to share the same integer id
# (e.g. OdooBot), producing "0 assignees" and no visible exam for
# the student (bug surfaced during the Apr-2026 QA E2E run).
student_ids = body.get('student_ids', [])
if student_ids:
vals['student_ids'] = [(6, 0, [int(s) for s in student_ids])]
resolved_user_ids = self._resolve_student_user_ids(student_ids)
if resolved_user_ids:
vals['student_ids'] = [(6, 0, resolved_user_ids)]
else:
_logger.warning(
'exam-schedule create: student_ids=%s resolved to 0 users',
student_ids,
)
rec = request.env['encoach.exam.schedule'].sudo().create(vals)
@@ -155,6 +169,46 @@ class EncoachExamScheduleController(http.Controller):
_logger.exception('exam-schedule create failed')
return _json_response({'error': str(e)}, 500)
def _resolve_student_user_ids(self, raw_ids):
"""Accept a list of ``op.student`` ids from the UI and return the
matching ``res.users`` ids — dropping entries that don't link to a
portal user (e.g. a student record created without an Odoo user).
Falls back to treating any id that doesn't match an op.student row as
a raw res.users id, so direct back-office callers posting user ids
keep working.
"""
try:
ints = [int(s) for s in raw_ids if s is not None and str(s).strip() != '']
except (TypeError, ValueError):
_logger.warning('exam-schedule: non-integer student_ids payload %r', raw_ids)
return []
if not ints:
return []
Student = request.env['op.student'].sudo()
Users = request.env['res.users'].sudo()
student_recs = Student.search([('id', 'in', ints)])
resolved = set()
matched_student_ids = set()
for s in student_recs:
matched_student_ids.add(s.id)
user = s.user_id if hasattr(s, 'user_id') else None
if user and user.id:
resolved.add(user.id)
# Any id we couldn't match as an op.student: treat as a direct
# res.users id but verify the user exists and is not a system user.
leftover = [i for i in ints if i not in matched_student_ids]
if leftover:
fallback_users = Users.search([
('id', 'in', leftover),
('share', '=', False),
('active', '=', True),
])
for u in fallback_users:
resolved.add(u.id)
return sorted(resolved)
def _create_individual_assignments(self, schedule):
"""Create individual assignment records for each targeted student."""
Assignment = request.env['encoach.exam.assignment'].sudo()
@@ -233,7 +287,9 @@ class EncoachExamScheduleController(http.Controller):
if 'batch_ids' in body:
vals['batch_ids'] = [(6, 0, [int(b) for b in body['batch_ids']])]
if 'student_ids' in body:
vals['student_ids'] = [(6, 0, [int(s) for s in body['student_ids']])]
# Resolve op.student.id → res.users.id (same reason as the
# create endpoint — see _resolve_student_user_ids docstring).
vals['student_ids'] = [(6, 0, self._resolve_student_user_ids(body['student_ids']))]
if 'state' in body:
vals['state'] = body['state']
if vals:

View File

@@ -1,316 +0,0 @@
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body,
)
_logger = logging.getLogger(__name__)
def _question_to_student_dict(q):
return {
'id': q.id,
'skill': q.skill or '',
'question_type': q.question_type or '',
'stem': q.stem or '',
'options': json.loads(q.options) if q.options else [],
'marks': q.marks,
'difficulty': q.difficulty or '',
'source_type': q.source_type or '',
'source_id': q.source_id or 0,
}
class ExamSessionController(http.Controller):
@http.route('/api/exam/<int:exam_id>/session', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_session(self, exam_id, **kw):
try:
Exam = request.env['encoach.exam.custom'].sudo()
exam = Exam.browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
uid = request.env.user.id
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.search([
('student_id', '=', uid),
('exam_id', '=', exam.id),
('status', 'in', ['in_progress', 'scoring']),
], limit=1, order='id desc')
if not attempt:
attempt = Attempt.create({
'student_id': uid,
'exam_id': exam.id,
'status': 'in_progress',
'entity_id': exam.entity_id.id if exam.entity_id else False,
})
Answer = request.env['encoach.student.answer'].sudo()
saved_answers = {}
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
saved_answers[ans.question_id.id] = ans.answer or ''
sections = []
for sec in exam.section_ids.sorted('sequence'):
questions = []
for q in sec.question_ids:
q_dict = _question_to_student_dict(q)
q_dict['saved_answer'] = saved_answers.get(q.id, '')
questions.append(q_dict)
sec_dict = {
'id': sec.id,
'title': sec.title,
'skill': sec.skill or '',
'difficulty': sec.difficulty or '',
'time_limit_min': sec.time_limit_min or 0,
'total_marks': sec.total_marks or 0,
'scoring_method': sec.scoring_method or 'auto',
'sequence': sec.sequence,
'questions': questions,
}
if sec.passage_text:
sec_dict['passage_text'] = sec.passage_text
if sec.instructions_text:
sec_dict['instructions_text'] = sec.instructions_text
if sec.content_json:
try:
sec_dict['content'] = json.loads(sec.content_json)
except (json.JSONDecodeError, TypeError):
pass
sections.append(sec_dict)
return _json_response({
'attempt_id': attempt.id,
'exam_id': exam.id,
'exam_title': exam.title,
'exam_mode': exam.exam_mode or 'official',
'total_time_min': exam.total_time_min or 0,
'total_marks': exam.total_marks or 0,
'grading_system': exam.grading_system or 'ielts',
'access_type': exam.access_type or 'private',
'randomize_questions': exam.randomize_questions or False,
'status': attempt.status,
'started_at': str(attempt.started_at) if attempt.started_at else None,
'sections': sections,
})
except Exception as e:
_logger.exception('get_session failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/autosave', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def autosave(self, exam_id, **kw):
try:
body = _get_json_body()
attempt_id = body.get('attempt_id')
raw_answers = body.get('answers', [])
if not attempt_id:
return _error_response('attempt_id is required', 400)
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.browse(int(attempt_id))
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
return _error_response('Invalid attempt', 403)
answer_pairs = self._normalize_answers(raw_answers)
Answer = request.env['encoach.student.answer'].sudo()
saved = 0
for q_id, answer_val in answer_pairs:
existing = Answer.search([
('attempt_id', '=', attempt.id),
('question_id', '=', q_id),
], limit=1)
if existing:
existing.write({'answer': str(answer_val)})
else:
Answer.create({
'attempt_id': attempt.id,
'question_id': q_id,
'answer': str(answer_val),
})
saved += 1
return _json_response({'saved': saved})
except Exception as e:
_logger.exception('autosave failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/submit', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def submit_exam(self, exam_id, **kw):
try:
body = _get_json_body()
attempt_id = body.get('attempt_id')
raw_answers = body.get('answers', [])
if not attempt_id:
return _error_response('attempt_id is required', 400)
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.browse(int(attempt_id))
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
return _error_response('Invalid attempt', 403)
answer_pairs = self._normalize_answers(raw_answers)
Answer = request.env['encoach.student.answer'].sudo()
Question = request.env['encoach.question'].sudo()
total_score = 0.0
max_score = 0.0
for q_id, answer_val in answer_pairs:
q = Question.browse(q_id)
if not q.exists():
continue
is_correct = False
score = 0.0
correct = (q.correct_answer or '').strip().lower()
given = str(answer_val).strip().lower()
if correct and given:
is_correct = correct == given
score = q.marks if is_correct else 0.0
existing = Answer.search([
('attempt_id', '=', attempt.id),
('question_id', '=', q_id),
], limit=1)
vals = {
'answer': str(answer_val),
'score': score,
'is_correct': is_correct,
}
if existing:
existing.write(vals)
else:
vals.update({
'attempt_id': attempt.id,
'question_id': q_id,
})
Answer.create(vals)
total_score += score
max_score += q.marks
from odoo.fields import Datetime
attempt.write({
'status': 'completed',
'finished_at': Datetime.now(),
'total_score': total_score,
'max_score': max_score,
})
return _json_response({
'attempt_id': attempt.id,
'status': 'completed',
'total_score': total_score,
'max_score': max_score,
'percentage': round(total_score / max_score * 100, 1) if max_score else 0,
'results_available': True,
})
except Exception as e:
_logger.exception('submit_exam failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/status', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_status(self, exam_id, **kw):
try:
uid = request.env.user.id
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.search([
('student_id', '=', uid),
('exam_id', '=', exam_id),
], limit=1, order='id desc')
if not attempt:
return _error_response('No attempt found', 404)
scores_available = attempt.status == 'completed' and attempt.max_score > 0
return _json_response({
'status': attempt.status,
'scores_available': scores_available,
'total_score': attempt.total_score,
'max_score': attempt.max_score,
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
})
except Exception as e:
_logger.exception('get_status failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/results', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_results(self, exam_id, **kw):
try:
uid = request.env.user.id
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.search([
('student_id', '=', uid),
('exam_id', '=', exam_id),
('status', '=', 'completed'),
], limit=1, order='id desc')
if not attempt:
return _error_response('No completed attempt found', 404)
Answer = request.env['encoach.student.answer'].sudo()
answers = []
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
answers.append({
'question_id': ans.question_id.id,
'answer': ans.answer or '',
'score': ans.score,
'is_correct': ans.is_correct,
'feedback': ans.feedback or '',
})
Exam = request.env['encoach.exam.custom'].sudo()
exam = Exam.browse(exam_id)
return _json_response({
'attempt_id': attempt.id,
'exam_id': exam_id,
'exam_title': exam.title if exam.exists() else '',
'status': attempt.status,
'total_score': attempt.total_score,
'max_score': attempt.max_score,
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
'started_at': str(attempt.started_at) if attempt.started_at else None,
'finished_at': str(attempt.finished_at) if attempt.finished_at else None,
'answers': answers,
})
except Exception as e:
_logger.exception('get_results failed')
return _error_response(str(e), 500)
@staticmethod
def _normalize_answers(raw):
"""Accept answers as list of {question_id, answer} or dict {qid: answer}."""
if isinstance(raw, list):
pairs = []
for item in raw:
if isinstance(item, dict):
qid = item.get('question_id') or item.get('qid')
ans = item.get('answer', '')
if qid:
pairs.append((int(qid), ans))
return pairs
if isinstance(raw, dict):
return [(int(k), v) for k, v in raw.items()]
return []

View File

@@ -0,0 +1,256 @@
"""Human-in-the-loop review workflow for AI-generated exams.
When an exam is created via the AI pipeline and fails the automated quality
gate (QualityChecker + IeltsValidator), it transitions to ``pending_review``
instead of being published directly (see ADR 0004 and P1.1 in the hardening
release). This controller exposes the admin-facing endpoints used by the
review queue UI to inspect, approve, or reject those exams.
Endpoints:
* ``GET /api/exam/review/queue`` — paginated list of pending exams
* ``GET /api/exam/review/<exam_id>`` — detail with per-question quality data
* ``POST /api/exam/review/<exam_id>/approve`` — publish (optional notes)
* ``POST /api/exam/review/<exam_id>/reject`` — back to draft (notes required)
"""
import json
import logging
from odoo import fields, http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
_error_response,
_get_json_body,
_json_response,
_paginate,
jwt_required,
)
_logger = logging.getLogger(__name__)
def _question_to_review_dict(q):
"""Compact projection of a question for the review UI."""
try:
report = json.loads(q.quality_report) if q.quality_report else None
except (TypeError, ValueError):
report = {'raw': q.quality_report}
return {
'id': q.id,
'skill': q.skill,
'question_type': q.question_type,
'difficulty': q.difficulty,
'stem': q.stem or '',
'marks': q.marks or 0.0,
'ai_generated': bool(q.ai_generated),
'ielts_certified': bool(q.ielts_certified),
'format_validated': bool(q.format_validated),
'ai_model_used': q.ai_model_used or '',
'ai_prompt_hash': q.ai_prompt_hash or '',
'quality_score': q.quality_score or 0.0,
'quality_report': report,
}
def _review_summary(exam):
"""Aggregate quality stats across every question in the exam."""
questions = exam.section_ids.mapped('question_ids')
total = len(questions)
if total == 0:
return {
'question_count': 0,
'avg_quality_score': 0.0,
'min_quality_score': 0.0,
'failing_count': 0,
'ai_generated_count': 0,
}
scores = [q.quality_score or 0.0 for q in questions]
failing_threshold = 0.7 # configurable later via ir.config_parameter
return {
'question_count': total,
'avg_quality_score': round(sum(scores) / total, 3),
'min_quality_score': round(min(scores), 3),
'failing_count': sum(1 for s in scores if s < failing_threshold),
'ai_generated_count': sum(1 for q in questions if q.ai_generated),
}
def _exam_to_review_dict(exam, *, include_questions=False):
data = {
'id': exam.id,
'title': exam.title,
'status': exam.status,
'subject_id': exam.subject_id.id if exam.subject_id else None,
'subject_name': exam.subject_id.name if exam.subject_id else '',
'entity_id': exam.entity_id.id if exam.entity_id else None,
'teacher_id': exam.teacher_id.id if exam.teacher_id else None,
'teacher_name': exam.teacher_id.name if exam.teacher_id else '',
'grading_system': exam.grading_system,
'total_time_min': exam.total_time_min or 0,
'total_marks': exam.total_marks or 0.0,
'reviewed_by_id': exam.reviewed_by_id.id if exam.reviewed_by_id else None,
'reviewed_by_name': exam.reviewed_by_id.name if exam.reviewed_by_id else '',
'reviewed_at': (
fields.Datetime.to_string(exam.reviewed_at) if exam.reviewed_at else None
),
'review_notes': exam.review_notes or '',
'summary': _review_summary(exam),
}
if include_questions:
data['sections'] = []
for sec in exam.section_ids:
data['sections'].append({
'id': sec.id,
'title': sec.title,
'skill': sec.skill or '',
'sequence': sec.sequence,
'questions': [
_question_to_review_dict(q) for q in sec.question_ids
],
})
return data
class EncoachExamReviewController(http.Controller):
"""Admin review queue for AI-generated exams gated by the quality checker."""
# ------------------------------------------------------------------
# GET /api/exam/review/queue
# ------------------------------------------------------------------
@http.route('/api/exam/review/queue', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def queue(self, **kw):
try:
Exam = request.env['encoach.exam.custom'].sudo()
# Default to pending_review but allow callers to inspect history too.
status = (kw.get('status') or 'pending_review').strip()
domain = [('status', '=', status)] if status else []
search = (kw.get('search') or '').strip()
if search:
domain.append(('title', 'ilike', search))
subject_id = kw.get('subject_id')
if subject_id:
try:
domain.append(('subject_id', '=', int(subject_id)))
except (TypeError, ValueError):
pass
total = Exam.search_count(domain)
page, per_page, offset = _paginate(kw)
exams = Exam.search(
domain, limit=per_page, offset=offset, order='id desc',
)
items = [_exam_to_review_dict(e) for e in exams]
return _json_response({
'items': items,
'data': items,
'total': total,
'page': page,
'size': per_page,
})
except Exception as e:
_logger.exception('exam review queue failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/review/<exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/review/<int:exam_id>', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def detail(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
return _json_response(
_exam_to_review_dict(exam, include_questions=True)
)
except Exception as e:
_logger.exception('exam review detail failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/review/<exam_id>/approve
# ------------------------------------------------------------------
@http.route('/api/exam/review/<int:exam_id>/approve', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def approve(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'pending_review':
return _error_response(
"Only exams in 'pending_review' can be approved "
f"(current status: {exam.status})",
400,
)
body = _get_json_body() or {}
notes = (body.get('notes') or '').strip() or False
with request.env.cr.savepoint():
exam.write({
'status': 'published',
'reviewed_by_id': request.env.user.id,
'reviewed_at': fields.Datetime.now(),
'review_notes': notes,
})
_logger.info(
'exam %s approved by user %s', exam.id, request.env.user.id,
)
return _json_response(
_exam_to_review_dict(exam, include_questions=False)
)
except Exception as e:
_logger.exception('exam review approve failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/review/<exam_id>/reject
# ------------------------------------------------------------------
@http.route('/api/exam/review/<int:exam_id>/reject', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def reject(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'pending_review':
return _error_response(
"Only exams in 'pending_review' can be rejected "
f"(current status: {exam.status})",
400,
)
body = _get_json_body() or {}
notes = (body.get('notes') or '').strip()
# Require notes on rejection — the author needs actionable feedback.
if not notes:
return _error_response(
'Review notes are required when rejecting an exam.', 400,
)
with request.env.cr.savepoint():
exam.write({
'status': 'draft',
'reviewed_by_id': request.env.user.id,
'reviewed_at': fields.Datetime.now(),
'review_notes': notes,
})
_logger.info(
'exam %s rejected by user %s', exam.id, request.env.user.id,
)
return _json_response(
_exam_to_review_dict(exam, include_questions=False)
)
except Exception as e:
_logger.exception('exam review reject failed')
return _error_response(str(e), 500)

View File

@@ -11,5 +11,4 @@ from . import exam_custom_section
from . import exam_assignment
from . import exam_schedule
from . import exam_structure
from . import student_attempt
from . import approval

View File

@@ -39,7 +39,24 @@ class EncoachExamCustom(models.Model):
randomize_questions = fields.Boolean(default=False)
status = fields.Selection([
('draft', 'Draft'),
('pending_review', 'Pending Review'),
('pending_approval', 'Pending Approval'),
('rejected', 'Rejected'),
('published', 'Published'),
('archived', 'Archived'),
], default='draft', required=True)
section_ids = fields.One2many('encoach.exam.custom.section', 'exam_id')
# Human-in-the-loop review audit trail. Populated by the
# /api/exam/review/<id>/(approve|reject) endpoints so we know who signed
# off on an AI-generated exam and what their reasoning was.
reviewed_by_id = fields.Many2one(
'res.users', ondelete='set null',
string='Reviewed By',
readonly=True,
)
reviewed_at = fields.Datetime(string='Reviewed At', readonly=True)
review_notes = fields.Text(
string='Review Notes',
help='Approver or rejecter comments. Required when rejecting back to draft.',
)

View File

@@ -1,4 +1,8 @@
from odoo import models, fields
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class EncoachQuestion(models.Model):
@@ -67,3 +71,55 @@ class EncoachQuestion(models.Model):
format_validated = fields.Boolean(default=False)
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
topic_id = fields.Many2one('encoach.topic', ondelete='set null')
ai_model_used = fields.Char(
string='AI Model',
help='LLM model that produced this question (e.g. gpt-4o-mini).',
index=True,
)
ai_prompt_hash = fields.Char(
string='Prompt Hash',
help='SHA-256 hex digest of the rendered prompt used to generate this question.',
index=True,
)
ai_log_id = fields.Integer(
string='AI Generation Log ID',
help='Row id in encoach.ai.generation.log (soft ref — no FK to avoid cross-module coupling).',
index=True,
)
ai_generated_at = fields.Datetime(string='AI Generated At')
quality_score = fields.Float(
string='Quality Score',
help='Aggregate quality score from automated checkers (0-1).',
)
quality_report = fields.Text(
string='Quality Report (JSON)',
help='JSON dump of the last QualityChecker + IeltsValidator run.',
)
@api.model
def _auto_init(self):
res = super()._auto_init()
cr = self.env.cr
for name, ddl in (
(
'encoach_question_skill_status_idx',
"CREATE INDEX IF NOT EXISTS encoach_question_skill_status_idx "
"ON encoach_question (skill, status)",
),
(
'encoach_question_subject_difficulty_idx',
"CREATE INDEX IF NOT EXISTS encoach_question_subject_difficulty_idx "
"ON encoach_question (subject_id, difficulty) WHERE subject_id IS NOT NULL",
),
(
'encoach_question_ai_prompt_hash_idx',
"CREATE INDEX IF NOT EXISTS encoach_question_ai_prompt_hash_idx "
"ON encoach_question (ai_prompt_hash) WHERE ai_prompt_hash IS NOT NULL",
),
):
try:
cr.execute(ddl)
except Exception:
_logger.warning("could not create index %s", name, exc_info=True)
return res

View File

@@ -1,52 +0,0 @@
from odoo import models, fields
class EncoachStudentAttempt(models.Model):
_name = 'encoach.student.attempt'
_description = 'Student Exam Attempt'
_order = 'id desc'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade')
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
status = fields.Selection([
('in_progress', 'In Progress'),
('scoring', 'Scoring'),
('completed', 'Completed'),
('abandoned', 'Abandoned'),
], default='in_progress', required=True)
started_at = fields.Datetime(default=fields.Datetime.now)
finished_at = fields.Datetime()
overall_band = fields.Float()
cefr_level = fields.Char(size=10)
listening_band = fields.Float()
reading_band = fields.Float()
writing_band = fields.Float()
speaking_band = fields.Float()
total_score = fields.Float()
max_score = fields.Float()
class EncoachStudentAnswer(models.Model):
_name = 'encoach.student.answer'
_description = 'Student Answer'
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade')
question_id = fields.Many2one('encoach.question', required=True, ondelete='cascade')
answer = fields.Text()
score = fields.Float()
is_correct = fields.Boolean()
feedback = fields.Text()
class EncoachStudentScore(models.Model):
_name = 'encoach.student.score'
_description = 'Student Skill Score'
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade')
skill = fields.Char(size=50, required=True)
band_score = fields.Float()
raw_score = fields.Float()
max_score = fields.Float()
cefr_level = fields.Char(size=10)
entity_id = fields.Many2one('encoach.entity', ondelete='set null')

View File

@@ -12,9 +12,6 @@ access_encoach_exam_custom_section_user,encoach.exam.custom.section.user,model_e
access_encoach_exam_assignment_user,encoach.exam.assignment.user,model_encoach_exam_assignment,base.group_user,1,1,1,1
access_encoach_exam_schedule_user,encoach.exam.schedule.user,model_encoach_exam_schedule,base.group_user,1,1,1,1
access_encoach_exam_structure_user,encoach.exam.structure.user,model_encoach_exam_structure,base.group_user,1,1,1,1
access_encoach_student_attempt_user,encoach.student.attempt.user,model_encoach_student_attempt,base.group_user,1,1,1,1
access_encoach_student_answer_user,encoach.student.answer.user,model_encoach_student_answer,base.group_user,1,1,1,1
access_encoach_student_score_user,encoach.student.score.user,model_encoach_student_score,base.group_user,1,1,1,1
access_encoach_approval_workflow_user,encoach.approval.workflow.user,model_encoach_approval_workflow,base.group_user,1,1,1,1
access_encoach_approval_stage_user,encoach.approval.stage.user,model_encoach_approval_stage,base.group_user,1,1,1,1
access_encoach_approval_request_user,encoach.approval.request.user,model_encoach_approval_request,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
12 access_encoach_exam_assignment_user encoach.exam.assignment.user model_encoach_exam_assignment base.group_user 1 1 1 1
13 access_encoach_exam_schedule_user encoach.exam.schedule.user model_encoach_exam_schedule base.group_user 1 1 1 1
14 access_encoach_exam_structure_user encoach.exam.structure.user model_encoach_exam_structure base.group_user 1 1 1 1
access_encoach_student_attempt_user encoach.student.attempt.user model_encoach_student_attempt base.group_user 1 1 1 1
access_encoach_student_answer_user encoach.student.answer.user model_encoach_student_answer base.group_user 1 1 1 1
access_encoach_student_score_user encoach.student.score.user model_encoach_student_score base.group_user 1 1 1 1
15 access_encoach_approval_workflow_user encoach.approval.workflow.user model_encoach_approval_workflow base.group_user 1 1 1 1
16 access_encoach_approval_stage_user encoach.approval.stage.user model_encoach_approval_stage base.group_user 1 1 1 1
17 access_encoach_approval_request_user encoach.approval.request.user model_encoach_approval_request base.group_user 1 1 1 1

View File

@@ -20,6 +20,7 @@
'openeducat_library',
'encoach_core',
'encoach_api',
'encoach_ai',
'encoach_taxonomy',
'encoach_resources',
],

View File

@@ -22,6 +22,7 @@ from . import faq
from . import stats
from . import tickets
from . import payments
from . import paymob
from . import platform_settings
from . import training
from . import reports

View File

@@ -36,7 +36,7 @@ class AttendanceController(http.Controller):
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})
return _json_response({'items': data, 'data': data, 'total': len(data)})
except Exception as e:
_logger.exception('list_attendance')
return _error_response(str(e), 500)

View File

@@ -35,7 +35,7 @@ class GradesController(http.Controller):
'date': str(r.create_date.date()) if r.create_date else '',
'type': r.state or 'graded',
})
return _json_response({'data': data})
return _json_response({'items': data, 'data': data, 'total': len(data)})
except Exception as e:
_logger.exception('list_grades')
return _error_response(str(e), 500)

View File

@@ -116,10 +116,36 @@ class PaymentRecordsController(http.Controller):
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.',
})
"""List Paymob orders for the calling user.
Admins see all orders; regular users see only their own. Orders are
populated by the paymob.py controller's checkout + webhook flow.
"""
try:
Order = request.env['encoach.paymob.order'].sudo()
domain = []
if not request.env.user.has_group('base.group_system'):
domain.append(('user_id', '=', request.env.user.id))
rows = Order.search(domain, order='create_date desc', limit=200)
items = [{
'id': r.id,
'reference': r.reference,
'amount': float(r.amount_cents or 0) / 100.0,
'currency': r.currency,
'state': r.state,
'paid': r.state == 'paid',
'description': r.description or '',
'product_ref': r.product_ref or '',
'paymob_order_id': r.paymob_order_id or '',
'user_id': r.user_id.id if r.user_id else None,
'user_name': r.user_id.name if r.user_id else '',
'date': str(r.create_date) if r.create_date else '',
'last_event_at': str(r.last_event_at) if r.last_event_at else '',
'type': 'Paymob',
} for r in rows]
return _json_response({
'data': items, 'items': items, 'total': len(items),
})
except Exception as e:
_logger.exception('list_paymob_orders')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,351 @@
"""Paymob checkout initiator + HMAC-verified webhook.
This module talks to the `Paymob Accept <https://accept.paymob.com/>`_ API:
1. ``POST /api/payments/paymob/checkout``
Authenticates with the merchant API key, registers an order, and
generates a payment key. Returns the ``payment_key`` + the hosted iframe
URL the frontend should redirect the user to.
2. ``POST /api/payments/paymob/webhook``
Verifies Paymob's HMAC-SHA512 signature against a concatenated subset
of the transaction fields (per Paymob docs), marks the matching
``encoach.paymob.order`` as ``paid`` or ``failed``, and idempotently
no-ops on duplicate deliveries.
Configuration (via ``ir.config_parameter``):
* ``encoach.paymob.api_key`` — merchant API key
* ``encoach.paymob.hmac_secret`` — HMAC secret (from the Paymob dashboard)
* ``encoach.paymob.integration_id`` — default integration id (card)
* ``encoach.paymob.iframe_id`` — hosted iframe id for redirect URL
All secrets are read at request time so operators can rotate them without
restarting Odoo.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
from typing import Any
import requests
from odoo import fields, http
from odoo.http import Response, request
from odoo.addons.encoach_api.controllers.base import (
_error_response,
_get_json_body,
_json_response,
jwt_required,
)
_logger = logging.getLogger(__name__)
PAYMOB_BASE = "https://accept.paymob.com/api"
# Fields Paymob concatenates (in this exact order) to compute the HMAC we
# receive on webhook callbacks. Source: Paymob Accept docs, Transaction
# Processed Callback → "HMAC Calculation" section.
HMAC_FIELDS = [
"amount_cents",
"created_at",
"currency",
"error_occured",
"has_parent_transaction",
"id",
"integration_id",
"is_3d_secure",
"is_auth",
"is_capture",
"is_refunded",
"is_standalone_payment",
"is_voided",
"order.id",
"owner",
"pending",
"source_data.pan",
"source_data.sub_type",
"source_data.type",
"success",
]
def _config(key: str, default: str = "") -> str:
return (
request.env["ir.config_parameter"].sudo().get_param(f"encoach.paymob.{key}", default)
or ""
)
def _paymob_post(path: str, body: dict, timeout: int = 15) -> dict:
url = f"{PAYMOB_BASE}{path}"
resp = requests.post(url, json=body, timeout=timeout)
if resp.status_code >= 400:
raise RuntimeError(f"Paymob {path}{resp.status_code}: {resp.text[:300]}")
try:
return resp.json()
except ValueError as exc:
raise RuntimeError(f"Paymob {path} returned non-JSON: {resp.text[:300]}") from exc
def _get_nested(obj: dict, dotted: str) -> Any:
"""Walk 'a.b.c' through nested dicts, returning '' on any miss."""
cur: Any = obj
for part in dotted.split("."):
if not isinstance(cur, dict):
return ""
cur = cur.get(part, "")
return cur if cur is not None else ""
def _compute_hmac(obj: dict, secret: str) -> str:
"""Reproduce Paymob's HMAC-SHA512 over the canonical field sequence."""
parts = [str(_get_nested(obj, f)).lower() if isinstance(_get_nested(obj, f), bool)
else str(_get_nested(obj, f))
for f in HMAC_FIELDS]
message = "".join(parts).encode("utf-8")
return hmac.new(secret.encode("utf-8"), message, hashlib.sha512).hexdigest()
class EncoachPaymobController(http.Controller):
"""Checkout + webhook endpoints."""
# ------------------------------------------------------------------
# POST /api/payments/paymob/checkout
#
# Body: { amount_cents, currency, product_ref?, description?, integration_id? }
# ------------------------------------------------------------------
@http.route(
"/api/payments/paymob/checkout",
type="http", auth="none", methods=["POST"], csrf=False,
)
@jwt_required
def checkout(self, **kw):
try:
api_key = _config("api_key")
iframe_id = _config("iframe_id")
default_integration = _config("integration_id")
if not api_key or not iframe_id or not default_integration:
return _error_response(
"Paymob is not configured. "
"Set encoach.paymob.api_key / hmac_secret / integration_id / iframe_id "
"in System Parameters.",
503,
)
body = _get_json_body() or {}
try:
amount_cents = int(body.get("amount_cents") or 0)
except (TypeError, ValueError):
return _error_response("amount_cents must be an integer", 400)
if amount_cents <= 0:
return _error_response("amount_cents must be positive", 400)
currency = (body.get("currency") or "EGP").upper()
description = body.get("description") or ""
product_ref = body.get("product_ref") or ""
integration_id = str(body.get("integration_id") or default_integration)
user = request.env.user
partner = user.partner_id
# Create our own tracking row *before* calling Paymob so we never
# lose orders to partial failures.
order = request.env["encoach.paymob.order"].sudo().create({
"reference": f"enc-{user.id}-{int(fields.Datetime.now().timestamp())}",
"user_id": user.id,
"partner_id": partner.id if partner else False,
"amount_cents": amount_cents,
"currency": currency,
"description": description,
"product_ref": product_ref,
"integration_id": integration_id,
"state": "draft",
})
# Step 1 — authenticate with the merchant API key.
auth = _paymob_post("/auth/tokens", {"api_key": api_key})
auth_token = auth.get("token")
if not auth_token:
raise RuntimeError("Paymob auth returned no token")
# Step 2 — register the order with Paymob.
order_payload = {
"auth_token": auth_token,
"delivery_needed": False,
"amount_cents": amount_cents,
"currency": currency,
"items": [],
"merchant_order_id": order.reference,
}
paymob_order = _paymob_post("/ecommerce/orders", order_payload)
paymob_order_id = str(paymob_order.get("id") or "")
if not paymob_order_id:
raise RuntimeError("Paymob order registration returned no id")
# Step 3 — generate a payment key.
billing = {
"first_name": (user.name or "Guest").split(" ")[0][:50] or "Guest",
"last_name": (
" ".join((user.name or "User").split(" ")[1:])[:50] or "User"
),
"email": user.email or "no-reply@encoach.invalid",
"phone_number": partner.phone or partner.mobile or "+201000000000",
"apartment": "NA", "floor": "NA", "street": "NA",
"building": "NA", "shipping_method": "NA", "postal_code": "NA",
"city": "NA", "country": "EG", "state": "NA",
}
pk_payload = {
"auth_token": auth_token,
"amount_cents": amount_cents,
"currency": currency,
"order_id": paymob_order_id,
"billing_data": billing,
"integration_id": int(integration_id),
"lock_order_when_paid": True,
}
pk = _paymob_post("/acceptance/payment_keys", pk_payload)
payment_key = pk.get("token")
if not payment_key:
raise RuntimeError("Paymob payment_keys returned no token")
iframe_url = (
f"https://accept.paymob.com/api/acceptance/iframes/"
f"{iframe_id}?payment_token={payment_key}"
)
order.write({
"paymob_order_id": paymob_order_id,
"payment_key": payment_key,
"state": "initiated",
})
return _json_response({
"order_id": order.id,
"reference": order.reference,
"paymob_order_id": paymob_order_id,
"payment_key": payment_key,
"iframe_url": iframe_url,
})
except Exception as e:
_logger.exception("paymob checkout failed")
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/payments/paymob/webhook
#
# Paymob delivers the full transaction JSON with an ``hmac`` query-string
# parameter. We verify the HMAC over a canonical subset of fields and
# then update the matching order.
# ------------------------------------------------------------------
@http.route(
"/api/payments/paymob/webhook",
type="http", auth="public", methods=["POST"], csrf=False,
)
def webhook(self, **kw):
try:
secret = _config("hmac_secret")
if not secret:
_logger.error("paymob webhook: hmac secret not configured")
return Response(status=503)
received_hmac = (kw.get("hmac") or "").strip()
if not received_hmac:
return Response(status=400)
raw = (request.httprequest.data or b"")
try:
payload = json.loads(raw.decode("utf-8") or "{}")
except Exception:
return Response(status=400)
# Paymob's payload envelope is:
# { "type": "TRANSACTION", "obj": { ...fields... } }
inner = payload.get("obj") or payload
expected_hmac = _compute_hmac(inner, secret)
if not hmac.compare_digest(expected_hmac, received_hmac):
_logger.warning(
"paymob webhook: HMAC mismatch (expected=%s… got=%s…)",
expected_hmac[:16], received_hmac[:16],
)
return Response(status=401)
merchant_order_ref = (
_get_nested(inner, "order.merchant_order_id")
or inner.get("merchant_order_id")
or ""
)
paymob_order_id = str(_get_nested(inner, "order.id") or "")
success = bool(inner.get("success"))
Order = request.env["encoach.paymob.order"].sudo()
order = False
if merchant_order_ref:
order = Order.search([("reference", "=", merchant_order_ref)], limit=1)
if not order and paymob_order_id:
order = Order.search([("paymob_order_id", "=", paymob_order_id)], limit=1)
if not order:
_logger.warning(
"paymob webhook: no matching order for ref=%s paymob=%s",
merchant_order_ref, paymob_order_id,
)
# Return 200 so Paymob doesn't retry indefinitely — the event
# will still be visible in their dashboard.
return Response(status=200)
if order.state == "paid":
# Idempotent: duplicate delivery.
return Response(status=200)
payload_json = json.dumps(inner)
if success:
order.mark_paid(payload_json, received_hmac)
else:
order.mark_failed(payload_json, received_hmac)
return Response(status=200)
except Exception:
_logger.exception("paymob webhook handler crashed")
return Response(status=500)
# ------------------------------------------------------------------
# GET /api/payments/paymob/orders — list my orders
# ------------------------------------------------------------------
@http.route(
"/api/payments/paymob/orders",
type="http", auth="none", methods=["GET"], csrf=False,
)
@jwt_required
def my_orders(self, **kw):
try:
Order = request.env["encoach.paymob.order"].sudo()
domain = [("user_id", "=", request.env.user.id)]
rows = Order.search(domain, order="create_date desc", limit=200)
items = [{
"id": r.id,
"reference": r.reference,
"paymob_order_id": r.paymob_order_id or "",
"amount_cents": r.amount_cents,
"currency": r.currency,
"description": r.description or "",
"product_ref": r.product_ref or "",
"state": r.state,
"create_date": fields.Datetime.to_string(r.create_date) if r.create_date else None,
"last_event_at": (
fields.Datetime.to_string(r.last_event_at) if r.last_event_at else None
),
} for r in rows]
return _json_response({
"items": items,
"data": items,
"total": len(items),
})
except Exception as e:
_logger.exception("paymob my_orders failed")
return _error_response(str(e), 500)

View File

@@ -23,35 +23,24 @@ from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _paginate,
)
from odoo.addons.encoach_api.utils.cache import get as _cache_get, put as _cache_put
_logger = logging.getLogger(__name__)
REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released')
# CEFR ordering + band ranges used when an attempt didn't store cefr_level.
_CEFR_BY_BAND = [
(9.0, 'C2'), (7.0, 'C1'), (5.5, 'B2'),
(4.0, 'B1'), (3.0, 'A2'), (2.0, 'A1'), (0.0, 'Pre-A1'),
]
from odoo.addons.encoach_ai.services.cefr_mapper import (
band_to_cefr as _cefr_code_for_band,
normalize_cefr as _normalize_cefr,
)
def _cefr_for_band(band):
if band is None:
"""Return the display-form CEFR label (e.g. 'B2') for an IELTS band."""
code = _cefr_code_for_band(band)
if code is None:
return None
for threshold, label in _CEFR_BY_BAND:
if band >= threshold:
return label
return 'Pre-A1'
def _normalize_cefr(label):
if not label:
return None
s = str(label).strip().lower().replace('-', '_')
return {
'pre_a1': 'Pre-A1', 'a1': 'A1', 'a2': 'A2',
'b1': 'B1', 'b2': 'B2', 'c1': 'C1', 'c2': 'C2',
}.get(s, label)
return _normalize_cefr(code) or code
def _duration_minutes(att):
@@ -232,6 +221,23 @@ class ReportsController(http.Controller):
include), ``months`` (trend horizon; default 6).
"""
try:
# 30s TTL cache: the stats dashboard is pinged from multiple widgets
# on every page view. Invalidated implicitly once the TTL expires;
# write-heavy endpoints (exam release, attempt score commit) can
# call ``cache.invalidate("reports.stats_corporate")`` if we ever
# need stronger consistency.
cache_key = (
"reports.stats_corporate:"
f"entity={kw.get('entity_id') or ''};"
f"user={kw.get('user_id') or kw.get('student_id') or ''};"
f"thr={kw.get('threshold') or ''};"
f"months={kw.get('months') or ''};"
f"period={kw.get('period') or ''}"
)
cached = _cache_get(cache_key)
if cached is not None:
return _json_response(cached)
Att = request.env['encoach.student.attempt'].sudo()
domain = _build_attempt_domain(kw)
@@ -242,55 +248,88 @@ class ReportsController(http.Controller):
if threshold > 0:
domain.append(('overall_band', '>=', threshold / 10.0))
attempts = Att.search(domain)
skill_totals = {k: [0.0, 0] for k in
('reading', 'listening', 'writing', 'speaking')}
for att in attempts:
for skill, field in (('reading', att.reading_band),
('listening', att.listening_band),
('writing', att.writing_band),
('speaking', att.speaking_band)):
if field is not None and field > 0:
skill_totals[skill][0] += field
skill_totals[skill][1] += 1
# --- Per-skill average via SQL aggregation ------------------
# Previously each of the four skill averages required pulling the
# full attempt rowset into Python and summing per record. This now
# runs as a single ``read_group`` per skill (avg in SQL), dropping
# the hot path from O(N) Python iterations to a constant number of
# round-trips regardless of attempt volume.
by_module = []
for skill in ('Reading', 'Listening', 'Writing', 'Speaking'):
total, n = skill_totals[skill.lower()]
avg = round((total / n) * 10, 1) if n else 0
by_module.append({'module': skill, 'score': avg, 'n': n})
skill_attempts_considered = 0
for display, fname in (
('Reading', 'reading_band'),
('Listening', 'listening_band'),
('Writing', 'writing_band'),
('Speaking', 'speaking_band'),
):
agg_domain = domain + [(fname, '>', 0)]
rows = Att.read_group(
agg_domain,
fields=[f'{fname}:avg'],
groupby=[],
)
if rows:
row = rows[0]
avg = row.get(fname) or 0
n = row.get('__count') or Att.search_count(agg_domain)
else:
avg, n = 0, 0
by_module.append({
'module': display,
'score': round(avg * 10, 1) if n else 0,
'n': n,
})
skill_attempts_considered = max(skill_attempts_considered, n)
# --- Monthly trend via read_group by month ------------------
try:
months = max(1, min(24, int(kw.get('months') or 6)))
except (TypeError, ValueError):
months = 6
month_buckets = defaultdict(lambda: [0.0, 0])
for att in attempts:
ct = _attempt_completed_at(att) or att.started_at
if not ct or att.overall_band is None:
trend_domain = domain + [('overall_band', '!=', False)]
month_rows = Att.read_group(
trend_domain,
fields=['overall_band:avg'],
groupby=['started_at:month'],
orderby='started_at:month asc',
)
month_buckets = {}
for row in month_rows:
label = row.get('started_at:month') or row.get('started_at_month')
if not label:
continue
key = ct.strftime('%Y-%m')
month_buckets[key][0] += att.overall_band
month_buckets[key][1] += 1
try:
ref = datetime.strptime(label, '%B %Y')
except ValueError:
continue
key = ref.strftime('%Y-%m')
month_buckets[key] = (row.get('overall_band') or 0.0,
row.get('__count') or 0)
trend = []
today = datetime.now().replace(day=1)
for i in range(months - 1, -1, -1):
ref = (today - timedelta(days=31 * i)).replace(day=1)
key = ref.strftime('%Y-%m')
total, n = month_buckets.get(key, (0.0, 0))
avg = round((total / n) * 10, 1) if n else 0
trend.append({'month': ref.strftime('%b'), 'avg': avg,
'period': key, 'n': n})
avg_val, n = month_buckets.get(key, (0.0, 0))
trend.append({
'month': ref.strftime('%b'),
'avg': round(avg_val * 10, 1) if n else 0,
'period': key,
'n': n,
})
level_buckets = defaultdict(int)
for att in attempts:
label = _normalize_cefr(att.cefr_level) \
or _cefr_for_band(att.overall_band)
# --- CEFR distribution via read_group by cefr_level ---------
dist_rows = Att.read_group(
domain + [('cefr_level', '!=', False)],
fields=[],
groupby=['cefr_level'],
)
level_buckets = {}
for row in dist_rows:
label = _normalize_cefr(row.get('cefr_level'))
if label:
level_buckets[label] += 1
level_buckets[label] = level_buckets.get(label, 0) + row.get('__count', 0)
palette = {
'Pre-A1': 'hsl(0, 0%, 55%)',
'A1': 'hsl(0, 72%, 51%)',
@@ -310,35 +349,39 @@ class ReportsController(http.Controller):
for lvl in ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')
]
entity_buckets = defaultdict(lambda: [0.0, 0, 'Unassigned'])
for att in attempts:
if att.overall_band is None:
continue
eid = att.entity_id.id or 0
entity_buckets[eid][0] += att.overall_band
entity_buckets[eid][1] += 1
entity_buckets[eid][2] = att.entity_id.name or 'Unassigned'
# --- Entity comparison via read_group by entity_id ----------
entity_rows = Att.read_group(
domain + [('overall_band', '!=', False)],
fields=['overall_band:avg'],
groupby=['entity_id'],
)
comparison = []
for eid, (total, n, name) in entity_buckets.items():
for row in entity_rows:
entity = row.get('entity_id') or (None, 'Unassigned')
eid, ename = (entity[0], entity[1]) if isinstance(entity, (list, tuple)) else (None, 'Unassigned')
avg_val = row.get('overall_band') or 0
comparison.append({
'entity_id': eid or None,
'entity_name': name,
'avg': round((total / n) * 10, 1) if n else 0,
'attempts': n,
'entity_id': eid,
'entity_name': ename or 'Unassigned',
'avg': round(avg_val * 10, 1),
'attempts': row.get('__count') or 0,
})
comparison.sort(key=lambda r: -r['avg'])
return _json_response({
total_considered = Att.search_count(domain)
payload = {
'by_module': by_module,
'trend': trend,
'distribution': distribution,
'comparison': comparison,
'meta': {
'attempts_considered': len(attempts),
'attempts_considered': total_considered,
'threshold': threshold,
'months': months,
},
})
}
_cache_put(cache_key, payload, ttl_seconds=30)
return _json_response(payload)
except Exception as e:
_logger.exception('stats-corporate report failed')
return _error_response(str(e), 500)

View File

@@ -37,7 +37,7 @@ class TimetableController(http.Controller):
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})
return _json_response({'items': data, 'data': data, 'total': len(data)})
except Exception as e:
_logger.exception('list_timetable')
return _error_response(str(e), 500)

View File

@@ -192,7 +192,7 @@ class UsersRolesController(http.Controller):
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})
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
@@ -299,7 +299,7 @@ class UsersRolesController(http.Controller):
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)})
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as e:
return _error_response(str(e), 500)
@@ -417,7 +417,7 @@ class UsersRolesController(http.Controller):
'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})
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
_logger.exception('list_users_with_roles')
return _error_response(str(e), 500)

View File

@@ -9,3 +9,4 @@ from . import course_ext
from . import asset
from . import ticket
from . import training
from . import paymob_order

View File

@@ -0,0 +1,92 @@
"""Paymob payment order tracking.
We keep an auditable trail of every checkout we initiate with Paymob so we
can (a) show users the status of their payments, (b) reconcile against the
Paymob dashboard, and (c) prove/dispute webhook events with the signed
HMAC we stored.
Lifecycle:
* ``draft`` — row created but no Paymob API call made yet
* ``initiated`` — authentication + order registration succeeded, we have a
``payment_key`` we can redirect the user to
* ``paid`` — the ``TRANSACTION`` webhook fired with ``success=true``
and a verified HMAC
* ``failed`` — webhook fired with ``success=false``
* ``cancelled`` — user abandoned / timed out (set manually or by cron)
"""
from odoo import fields, models
class EncoachPaymobOrder(models.Model):
_name = "encoach.paymob.order"
_description = "Paymob payment order"
_order = "create_date desc"
_rec_name = "reference"
reference = fields.Char(
required=True, index=True,
help="Our merchant order reference; included in the Paymob request.",
)
user_id = fields.Many2one(
"res.users", required=True, ondelete="restrict", index=True,
)
partner_id = fields.Many2one("res.partner", ondelete="set null")
# Commerce fields
amount_cents = fields.Integer(
required=True,
help="Amount in smallest currency unit (piastres for EGP).",
)
currency = fields.Char(required=True, default="EGP")
description = fields.Char()
product_ref = fields.Char(
help="Free-text link back to the thing being bought "
"(e.g. 'subscription:pro-monthly').",
)
# Paymob identifiers
paymob_order_id = fields.Char(index=True)
payment_key = fields.Text()
payment_key_expires_at = fields.Datetime()
integration_id = fields.Char(
help="Which Paymob integration was used (card / wallet / kiosk...)",
)
# State
state = fields.Selection(
[
("draft", "Draft"),
("initiated", "Initiated"),
("paid", "Paid"),
("failed", "Failed"),
("cancelled", "Cancelled"),
],
default="draft", required=True, index=True,
)
last_event_at = fields.Datetime()
last_event_payload = fields.Text(
help="JSON of the latest webhook payload we received for this order.",
)
last_event_hmac = fields.Char(
help="HMAC we verified against the payload; stored for audit.",
)
def mark_paid(self, payload, hmac):
self.ensure_one()
self.write({
"state": "paid",
"last_event_at": fields.Datetime.now(),
"last_event_payload": payload,
"last_event_hmac": hmac,
})
def mark_failed(self, payload, hmac):
self.ensure_one()
self.write({
"state": "failed",
"last_event_at": fields.Datetime.now(),
"last_event_payload": payload,
"last_event_hmac": hmac,
})

View File

@@ -1,4 +1,8 @@
from odoo import fields, models
import logging
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class EncoachTicket(models.Model):
@@ -35,3 +39,129 @@ class EncoachTicket(models.Model):
entity_id = fields.Many2one('encoach.entity', string='Entity')
resolved_at = fields.Datetime('Resolved At')
STATUS_LABELS = {
'open': 'Open',
'in_progress': 'In Progress',
'resolved': 'Resolved',
'closed': 'Closed',
}
def _notify_ticket_event(self, event, *, old_status=None, new_status=None,
old_assignee=None, new_assignee=None):
"""Post a chatter message and push a bus event for status/assignee changes.
Uses :class:`bus.bus` when available so both the reporter and the new
assignee see near real-time updates in the frontend, and always falls
back to ``mail.thread`` chatter so the change is auditable even when
the bus is unavailable (e.g. during cron jobs or tests).
"""
self.ensure_one()
try:
if event == 'status':
body = _("Status: %s%s") % (
self.STATUS_LABELS.get(old_status, old_status or ''),
self.STATUS_LABELS.get(new_status, new_status or ''),
)
elif event == 'assignee':
old_name = old_assignee.display_name if old_assignee else _('Unassigned')
new_name = new_assignee.display_name if new_assignee else _('Unassigned')
body = _("Assignee: %s%s") % (old_name, new_name)
else:
return
self.message_post(body=body, subtype_xmlid='mail.mt_note')
except Exception:
_logger.exception("ticket %s: chatter notify failed", self.id)
try:
bus = self.env['bus.bus'].sudo()
recipients = set()
if self.reporter_id:
recipients.add(('res.partner', self.reporter_id.partner_id.id))
for user in (old_assignee, new_assignee, self.assignee_id):
if user and user.partner_id:
recipients.add(('res.partner', user.partner_id.id))
payload = {
'type': 'encoach.ticket',
'event': event,
'ticket_id': self.id,
'subject': self.subject,
'status': self.status,
'assignee_id': self.assignee_id.id or False,
'old_status': old_status,
'new_status': new_status,
}
for channel in recipients:
bus._sendone(channel, 'encoach.ticket/notify', payload)
except Exception:
_logger.debug("ticket %s: bus push skipped", self.id, exc_info=True)
def write(self, vals):
tracked = {}
if vals.get('status') or vals.get('assignee_id') is not None:
for rec in self:
tracked[rec.id] = {
'status': rec.status,
'assignee': rec.assignee_id,
}
res = super().write(vals)
if not tracked:
return res
now = fields.Datetime.now()
for rec in self:
before = tracked.get(rec.id) or {}
old_status = before.get('status')
old_assignee = before.get('assignee')
if 'status' in vals and old_status != rec.status:
if rec.status in ('resolved', 'closed') and not rec.resolved_at:
rec.resolved_at = now
rec._notify_ticket_event(
'status', old_status=old_status, new_status=rec.status,
)
if 'assignee_id' in vals and (old_assignee or rec.assignee_id) and \
(old_assignee.id if old_assignee else False) != (rec.assignee_id.id or False):
rec._notify_ticket_event(
'assignee', old_assignee=old_assignee, new_assignee=rec.assignee_id,
)
return res
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec in records:
try:
rec.message_post(
body=_("Ticket opened by %s") % rec.reporter_id.display_name,
subtype_xmlid='mail.mt_note',
)
except Exception:
_logger.debug("ticket %s: initial chatter failed", rec.id, exc_info=True)
return records
@api.model
def _auto_init(self):
res = super()._auto_init()
cr = self.env.cr
for name, ddl in (
(
'encoach_ticket_entity_status_idx',
"CREATE INDEX IF NOT EXISTS encoach_ticket_entity_status_idx "
"ON encoach_ticket (entity_id, status) WHERE entity_id IS NOT NULL",
),
(
'encoach_ticket_reporter_status_idx',
"CREATE INDEX IF NOT EXISTS encoach_ticket_reporter_status_idx "
"ON encoach_ticket (reporter_id, status)",
),
(
'encoach_ticket_assignee_status_idx',
"CREATE INDEX IF NOT EXISTS encoach_ticket_assignee_status_idx "
"ON encoach_ticket (assignee_id, status) WHERE assignee_id IS NOT NULL",
),
):
try:
cr.execute(ddl)
except Exception:
_logger.warning("could not create index %s", name, exc_info=True)
return res

View File

@@ -23,3 +23,5 @@ access_encoach_vocab_item_all,encoach.vocab.item.all,model_encoach_vocab_item,ba
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
access_encoach_paymob_order_user,encoach.paymob.order.user,model_encoach_paymob_order,base.group_user,1,0,1,0
access_encoach_paymob_order_admin,encoach.paymob.order.admin,model_encoach_paymob_order,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
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
26 access_encoach_paymob_order_user encoach.paymob.order.user model_encoach_paymob_order base.group_user 1 0 1 0
27 access_encoach_paymob_order_admin encoach.paymob.order.admin model_encoach_paymob_order base.group_system 1 1 1 1

View File

@@ -5,7 +5,7 @@
'summary': 'Computerized Adaptive Testing (CAT) placement engine with CEFR mapping',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': ['encoach_core', 'encoach_exam_template'],
'depends': ['encoach_core', 'encoach_exam_template', 'encoach_ai'],
'data': [
'security/ir.model.access.csv',
'views/cat_session_views.xml',

View File

@@ -8,6 +8,7 @@ from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
from odoo.addons.encoach_ai.services.cefr_mapper import theta_to_cefr as _theta_to_cefr
_logger = logging.getLogger(__name__)
@@ -15,18 +16,6 @@ LEARNING_RATE = 0.3
SEM_THRESHOLD = 0.3
MAX_QUESTIONS = 40
THETA_CEFR = [
(-3.0, 'pre_a1'), (-2.0, 'a1'), (-1.0, 'a2'),
(0.0, 'b1'), (1.0, 'b2'), (2.0, 'c1'), (3.0, 'c2'),
]
def _theta_to_cefr(theta):
for boundary, level in THETA_CEFR:
if theta <= boundary:
return level
return 'c2'
def _irt_probability(theta, a, b, c):
"""3PL IRT probability."""

View File

@@ -1,83 +1,20 @@
class CefrMapper:
"""Maps IRT theta values to CEFR levels and IELTS band scores."""
"""Thin re-export of the canonical CEFR mapper for backward compat.
THETA_TO_CEFR = [
(-4.0, -2.5, 'pre_a1'),
(-2.5, -1.5, 'a1'),
(-1.5, -0.5, 'a2'),
(-0.5, 0.5, 'b1'),
(0.5, 1.5, 'b2'),
(1.5, 2.5, 'c1'),
(2.5, 4.0, 'c2'),
]
The canonical implementation lives in ``encoach_ai.services.cefr_mapper``.
This shim is kept so existing imports like
``from odoo.addons.encoach_placement.services.cefr_mapper import CefrMapper``
continue to work. See P0.9 in §21 of docs/PROJECT_SUMMARY.md.
"""
CEFR_TO_BAND = {
'pre_a1': 2.0,
'a1': 3.0,
'a2': 4.0,
'b1': 5.0,
'b2': 6.5,
'c1': 7.5,
'c2': 9.0,
}
CEFR_LABELS = {
'pre_a1': 'Pre-A1 (Beginner)',
'a1': 'A1 (Elementary)',
'a2': 'A2 (Pre-Intermediate)',
'b1': 'B1 (Intermediate)',
'b2': 'B2 (Upper-Intermediate)',
'c1': 'C1 (Advanced)',
'c2': 'C2 (Proficient)',
}
@staticmethod
def theta_to_cefr(theta):
for low, high, level in CefrMapper.THETA_TO_CEFR:
if low <= theta < high:
return level
return 'c2' if theta >= 2.5 else 'pre_a1'
@staticmethod
def theta_to_band(theta):
cefr = CefrMapper.theta_to_cefr(theta)
base_band = CefrMapper.CEFR_TO_BAND.get(cefr, 5.0)
for low, high, level in CefrMapper.THETA_TO_CEFR:
if level == cefr:
range_width = high - low
if range_width > 0:
position = (theta - low) / range_width
else:
position = 0.5
cefr_list = list(CefrMapper.CEFR_TO_BAND.keys())
idx = cefr_list.index(cefr)
next_band = CefrMapper.CEFR_TO_BAND.get(
cefr_list[min(idx + 1, len(cefr_list) - 1)], base_band + 1.0
)
band = base_band + position * (next_band - base_band)
return round(band * 2) / 2
return base_band
@staticmethod
def band_to_cefr(band):
if band < 2.5:
return 'pre_a1'
if band < 3.5:
return 'a1'
if band < 4.5:
return 'a2'
if band < 5.5:
return 'b1'
if band < 7.0:
return 'b2'
if band < 8.0:
return 'c1'
return 'c2'
@staticmethod
def get_cefr_label(cefr_code):
return CefrMapper.CEFR_LABELS.get(cefr_code, cefr_code)
from odoo.addons.encoach_ai.services.cefr_mapper import ( # noqa: F401
CefrMapper,
band_to_cefr,
theta_to_cefr,
theta_to_band,
cefr_to_band,
normalize_cefr,
cefr_label,
THETA_TO_CEFR,
CEFR_TO_BAND,
CEFR_LABELS,
)

View File

@@ -21,11 +21,7 @@ BAND_TO_CEFR = [
]
def _band_to_cefr(band):
for threshold, level in BAND_TO_CEFR:
if band >= threshold:
return level
return 'pre_a1'
from odoo.addons.encoach_ai.services.cefr_mapper import band_to_cefr as _band_to_cefr # noqa: E402
def _question_to_student_dict(q):

View File

@@ -16,11 +16,7 @@ BAND_TO_CEFR = [
]
def _band_to_cefr(band):
for threshold, level in BAND_TO_CEFR:
if band >= threshold:
return level
return 'pre_a1'
from odoo.addons.encoach_ai.services.cefr_mapper import band_to_cefr as _band_to_cefr # noqa: E402
def _recompute_bands(attempt):

View File

@@ -1,4 +1,8 @@
from odoo import models, fields
import logging
from odoo import api, models, fields
_logger = logging.getLogger(__name__)
class EncoachStudentAttempt(models.Model):
@@ -39,3 +43,31 @@ class EncoachStudentAttempt(models.Model):
score_ids = fields.One2many('encoach.score', 'attempt_id')
autosave_data = fields.Text()
feedback_ids = fields.One2many('encoach.feedback', 'attempt_id')
@api.model
def _auto_init(self):
res = super()._auto_init()
cr = self.env.cr
for name, ddl in (
(
'encoach_student_attempt_student_status_idx',
"CREATE INDEX IF NOT EXISTS encoach_student_attempt_student_status_idx "
"ON encoach_student_attempt (student_id, status)",
),
(
'encoach_student_attempt_exam_status_idx',
"CREATE INDEX IF NOT EXISTS encoach_student_attempt_exam_status_idx "
"ON encoach_student_attempt (exam_id, status) WHERE exam_id IS NOT NULL",
),
(
'encoach_student_attempt_entity_released_idx',
"CREATE INDEX IF NOT EXISTS encoach_student_attempt_entity_released_idx "
"ON encoach_student_attempt (entity_id, released_at) "
"WHERE entity_id IS NOT NULL AND released_at IS NOT NULL",
),
):
try:
cr.execute(ddl)
except Exception:
_logger.warning("could not create index %s", name, exc_info=True)
return res

View File

@@ -102,7 +102,8 @@ class EncoachTaxonomyController(http.Controller):
try:
recs = request.env['encoach.subject'].sudo().search([])
items = [_subject_dict(s) for s in recs]
return _json_response({'data': items, 'subjects': items})
return _json_response({'items': items, 'data': items,
'subjects': items, 'total': len(items)})
except Exception as e:
_logger.exception('list_subjects')
return _error_response(str(e), 500)
@@ -205,7 +206,9 @@ class EncoachTaxonomyController(http.Controller):
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]})
items = [_domain_dict(d) for d in recs]
return _json_response({'items': items, 'data': items,
'total': len(items)})
except Exception as e:
return _error_response(str(e), 500)
@@ -290,7 +293,9 @@ class EncoachTaxonomyController(http.Controller):
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]})
items = [_topic_dict(t) for t in recs]
return _json_response({'items': items, 'data': items,
'total': len(items)})
except Exception as e:
return _error_response(str(e), 500)

View File

@@ -28,9 +28,17 @@ class EncoachEmbedding(models.Model):
content_text = fields.Text()
metadata_json = fields.Text(default='{}')
course_id = fields.Integer(string='Course ID', index=True)
subject_id = fields.Integer(string='Subject ID', index=True)
entity_id = fields.Integer(string='Entity ID', index=True)
taxonomy = fields.Char(string='Taxonomy', index=True)
content_hash = fields.Char(string='Content SHA256', index=True)
chunk_index = fields.Integer(string='Chunk #', default=0)
chunk_total = fields.Integer(string='Total Chunks', default=1)
_content_unique = models.Constraint(
'UNIQUE(content_type, content_id)',
'Each content item can only have one embedding.',
'UNIQUE(content_type, content_id, chunk_index)',
'Each content item chunk can only have one embedding.',
)
@api.model
@@ -82,27 +90,46 @@ class EncoachEmbedding(models.Model):
return index_all(self.env)
@api.model
def similarity_search(self, query_vector, *, content_type=None, limit=10):
"""Find similar embeddings using cosine distance."""
def similarity_search(self, query_vector, *, content_type=None, limit=10,
course_id=None, subject_id=None, entity_id=None,
taxonomy=None):
"""Find similar embeddings using cosine distance.
Optional metadata filters (course/subject/entity/taxonomy) are applied
as SQL equality predicates to narrow the candidate set before ranking.
"""
vec_str = '[' + ','.join(str(v) for v in query_vector) + ']'
where = "WHERE embedding IS NOT NULL"
params = [vec_str, limit]
conditions = ["embedding IS NOT NULL"]
extra_params = []
if content_type:
where += " AND content_type = %s"
params = [vec_str, content_type, limit]
conditions.append("content_type = %s")
extra_params.append(content_type)
if course_id:
conditions.append("course_id = %s")
extra_params.append(int(course_id))
if subject_id:
conditions.append("subject_id = %s")
extra_params.append(int(subject_id))
if entity_id:
conditions.append("entity_id = %s")
extra_params.append(int(entity_id))
if taxonomy:
conditions.append("taxonomy = %s")
extra_params.append(taxonomy)
where = "WHERE " + " AND ".join(conditions)
query = f"""
SELECT id, content_type, content_id, content_text, metadata_json,
course_id, subject_id, entity_id, taxonomy,
chunk_index, chunk_total,
1 - (embedding <=> %s::vector) AS similarity
FROM encoach_embedding
{where}
ORDER BY embedding <=> %s::vector
LIMIT %s
"""
if content_type:
self.env.cr.execute(query, (vec_str, content_type, vec_str, limit))
else:
self.env.cr.execute(query, (vec_str, vec_str, limit))
params = [vec_str] + extra_params + [vec_str, limit]
self.env.cr.execute(query, params)
results = []
for row in self.env.cr.dictfetchall():
@@ -117,6 +144,12 @@ class EncoachEmbedding(models.Model):
'content_id': row['content_id'],
'text': row['content_text'],
'metadata': metadata,
'course_id': row['course_id'] or None,
'subject_id': row['subject_id'] or None,
'entity_id': row['entity_id'] or None,
'taxonomy': row['taxonomy'] or None,
'chunk_index': row['chunk_index'],
'chunk_total': row['chunk_total'],
'similarity': round(row['similarity'], 4),
})
return results

View File

@@ -1,13 +1,81 @@
"""Embedding service — encode text and manage vector storage."""
import hashlib
import json
import logging
import re
import time
_logger = logging.getLogger(__name__)
_model_instance = None
CHUNK_CHAR_LIMIT = 2000
CHUNK_OVERLAP = 200
_PARA_SPLIT = re.compile(r"\n\s*\n+")
_SENT_SPLIT = re.compile(r"(?<=[.!?])\s+")
def _chunk_text(text, *, limit=CHUNK_CHAR_LIMIT, overlap=CHUNK_OVERLAP):
"""Split long text into semantically-aware chunks of at most ``limit`` chars.
Preserves paragraph and sentence boundaries where possible. Each chunk
after the first retains ``overlap`` chars of tail context for continuity.
Short inputs return a single-chunk list unchanged.
"""
text = (text or "").strip()
if not text:
return []
if len(text) <= limit:
return [text]
chunks = []
buf = ""
for para in _PARA_SPLIT.split(text):
para = para.strip()
if not para:
continue
candidate = (buf + "\n\n" + para) if buf else para
if len(candidate) <= limit:
buf = candidate
continue
if buf:
chunks.append(buf)
buf = ""
if len(para) <= limit:
buf = para
continue
for sent in _SENT_SPLIT.split(para):
sent = sent.strip()
if not sent:
continue
cand = (buf + " " + sent) if buf else sent
if len(cand) <= limit:
buf = cand
continue
if buf:
chunks.append(buf)
if len(sent) <= limit:
buf = sent
else:
for i in range(0, len(sent), limit - overlap):
chunks.append(sent[i:i + limit])
buf = ""
if buf:
chunks.append(buf)
if overlap > 0 and len(chunks) > 1:
with_overlap = [chunks[0]]
for i in range(1, len(chunks)):
tail = chunks[i - 1][-overlap:]
with_overlap.append((tail + " " + chunks[i]).strip())
chunks = with_overlap
return chunks
def _sha256(text):
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()
def _get_model():
"""Lazy-load the sentence-transformers model (cached across calls)."""
@@ -47,42 +115,77 @@ class EmbeddingService:
return [e.tolist() for e in embeddings]
def upsert(self, content_type, content_id, text, metadata=None):
"""Encode and store (or update) a single embedding.
"""Encode and store (or update) one embedding per text chunk.
Long ``text`` is split by :func:`_chunk_text` into chunks of at most
``CHUNK_CHAR_LIMIT`` chars. Each chunk is stored as a separate row
keyed by ``(content_type, content_id, chunk_index)``. Dedicated
metadata columns (course_id, subject_id, entity_id, taxonomy,
content_hash) are populated from ``metadata`` when present.
Returns:
encoach.embedding record
list of encoach.embedding records (one per chunk). Empty list
if input text is blank.
"""
if not text or not text.strip():
return None
return []
existing = self.Embedding.search([
metadata = dict(metadata or {})
chunks = _chunk_text(text)
if not chunks:
return []
vectors = self.encode(chunks)
existing_all = self.Embedding.search([
('content_type', '=', content_type),
('content_id', '=', content_id),
], limit=1)
])
by_idx = {rec.chunk_index: rec for rec in existing_all}
stale = [rec for rec in existing_all if rec.chunk_index >= len(chunks)]
if stale:
self.Embedding.browse([r.id for r in stale]).unlink()
vectors = self.encode([text])
meta_str = json.dumps(metadata or {})
records = []
total = len(chunks)
for idx, (chunk, vec) in enumerate(zip(chunks, vectors)):
chunk_hash = _sha256(chunk)
base_vals = {
'content_text': chunk[:10000],
'metadata_json': json.dumps(metadata),
'course_id': int(metadata.get('course_id') or 0) or False,
'subject_id': int(metadata.get('subject_id') or 0) or False,
'entity_id': int(metadata.get('entity_id') or 0) or False,
'taxonomy': metadata.get('taxonomy') or False,
'content_hash': chunk_hash,
'chunk_index': idx,
'chunk_total': total,
}
rec = by_idx.get(idx)
if rec:
if rec.content_hash != chunk_hash or rec.chunk_total != total:
rec.write(base_vals)
rec.set_embedding(vec)
else:
rec.write({k: v for k, v in base_vals.items()
if k in ('metadata_json', 'chunk_total')})
else:
rec = self.Embedding.create({
'content_type': content_type,
'content_id': content_id,
**base_vals,
})
rec.set_embedding(vec)
records.append(rec)
return records
if existing:
existing.write({
'content_text': text[:10000],
'metadata_json': meta_str,
})
existing.set_embedding(vectors[0])
return existing
record = self.Embedding.create({
'content_type': content_type,
'content_id': content_id,
'content_text': text[:10000],
'metadata_json': meta_str,
})
record.set_embedding(vectors[0])
return record
def search(self, query, *, content_type=None, limit=10):
def search(self, query, *, content_type=None, limit=10,
course_id=None, subject_id=None, entity_id=None,
taxonomy=None):
"""Semantic search — encode query and find similar content.
Optional metadata filters narrow the candidate pool to a specific
course / subject / entity / taxonomy before ranking.
Returns:
list of dicts with text, metadata, similarity score
"""
@@ -95,6 +198,10 @@ class EmbeddingService:
vectors[0],
content_type=content_type,
limit=limit,
course_id=course_id,
subject_id=subject_id,
entity_id=entity_id,
taxonomy=taxonomy,
)
latency = int((time.time() - t0) * 1000)
_logger.info("Vector search for '%s' returned %d results in %dms",

View File

@@ -11,6 +11,8 @@ MODEL_CONFIG = [
'text_field': 'name',
'description_field': 'description',
'metadata_fields': [],
'course_field': 'id',
'subject_field': 'subject_id',
},
{
'model': 'encoach.resource',
@@ -18,13 +20,19 @@ MODEL_CONFIG = [
'text_field': 'name',
'description_field': 'content',
'metadata_fields': ['type', 'cefr_level', 'difficulty'],
'course_field': 'course_id',
'subject_field': 'subject_id',
'entity_field': 'entity_id',
'taxonomy_field': 'type',
},
{
'model': 'encoach.question',
'content_type': 'question',
'text_field': 'name',
'text_field': 'stem',
'description_field': None,
'metadata_fields': ['question_type', 'difficulty', 'skill'],
'subject_field': 'subject_id',
'taxonomy_field': 'skill',
},
{
'model': 'encoach.course.module',
@@ -32,6 +40,8 @@ MODEL_CONFIG = [
'text_field': 'name',
'description_field': 'description',
'metadata_fields': ['skill'],
'course_field': 'course_id',
'taxonomy_field': 'skill',
},
{
'model': 'encoach.ai.generation.log',
@@ -39,6 +49,7 @@ MODEL_CONFIG = [
'text_field': 'brief',
'description_field': 'generated_content',
'metadata_fields': ['course_type', 'status'],
'taxonomy_field': 'course_type',
},
{
'model': 'encoach.chapter.material',
@@ -46,10 +57,21 @@ MODEL_CONFIG = [
'text_field': 'name',
'description_field': 'description',
'metadata_fields': ['type'],
'course_field': 'course_id',
'taxonomy_field': 'type',
},
]
def _safe_id(record, field):
if not field or not hasattr(record, field):
return None
val = getattr(record, field)
if hasattr(val, 'id'):
return val.id or None
return int(val) if val else None
def _get_text(record, config):
"""Extract indexable text from a record."""
parts = []
@@ -69,13 +91,32 @@ def _get_text(record, config):
def _get_metadata(record, config):
"""Extract metadata dict from a record."""
"""Extract metadata dict from a record, including structured
course/subject/entity/taxonomy fields used by the RAG retriever."""
meta = {}
for f in config.get('metadata_fields', []):
if hasattr(record, f):
val = getattr(record, f)
if val:
meta[f] = str(val) if not isinstance(val, (int, float, bool)) else val
course_id = _safe_id(record, config.get('course_field'))
if course_id:
meta['course_id'] = course_id
subject_id = _safe_id(record, config.get('subject_field'))
if subject_id:
meta['subject_id'] = subject_id
entity_id = _safe_id(record, config.get('entity_field'))
if entity_id:
meta['entity_id'] = entity_id
taxonomy_field = config.get('taxonomy_field')
if taxonomy_field and hasattr(record, taxonomy_field):
tx = getattr(record, taxonomy_field)
if tx:
meta['taxonomy'] = (
str(tx.display_name) if hasattr(tx, 'display_name') else str(tx)
)
return meta

View File

@@ -0,0 +1,24 @@
# Config file .coveragerc
# adapt the include for your project
[report]
include =
*/openeducat/openeducat_erp/*
omit =
*/tests/*
*__init__.py
# Regexes for lines to exclude from consideration
exclude_lines =
# Have to re-enable the standard pragma
pragma: no cover
# Don't complain about null context checking
if context is None:
# Don't complain about odoo basic imports checking
from odoo import models, fields, api
# Don't complain about odoo basic imports checking
from odoo*

View File

@@ -0,0 +1,6 @@
# Normalise line endings:
* text=auto
# Prevent certain files from being exported:
.gitattributes export-ignore
.gitignore export-ignore

View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,36 @@
*.py[cod]
*.settings
# C extensions
*.so
# Packages
*.egg
*.egg-info
dist
build
eggs
parts
bin
var
sdist
develop-eggs
.installed.cfg
lib
lib64
# Installer logs
pip-log.txt
# Unit test / coverage reports
.coverage
.tox
nosetests.xml
# Translations
*.mo
# Mr Developer
.mr.developer.cfg
.project
.pydevproject
.idea

View File

@@ -0,0 +1,149 @@
exclude: |
(?x)
# NOT INSTALLABLE ADDONS
# END NOT INSTALLABLE ADDONS
# Files and folders generated by bots, to avoid loops
^setup/|/static/description/index\.html$|
# We don't want to mess with tool-generated files
.svg$|/tests/([^/]+/)?cassettes/|^.copier-answers.yml$|^.github/|
# Maybe reactivate this when all README files include prettier ignore tags?
^README\.md$|
# Library files can have extraneous formatting (even minimized)
/static/(src/)?lib/|
# Repos using Sphinx to generate docs don't need prettying
^docs/_templates/.*\.html$|
# You don't usually want a bot to modify your legal texts
(LICENSE.*|COPYING.*)
default_language_version:
python: python3
node: "16.17.0"
repos:
- repo: local
hooks:
# These files are most likely copier diff rejection junks; if found,
# review them manually, fix the problem (if needed) and remove them
- id: forbidden-files
name: forbidden files
entry: found forbidden files; remove them
language: fail
files: "\\.rej$"
- id: en-po-files
name: en.po files cannot exist
entry: found a en.po file
language: fail
files: '[a-zA-Z0-9_]*/i18n/en\.po$'
- repo: https://github.com/oca/maintainer-tools
rev: 4cd2b852214dead80822e93e6749b16f2785b2fe
hooks:
# update the NOT INSTALLABLE ADDONS section above
- id: oca-update-pre-commit-excluded-addons
# - id: oca-fix-manifest-website
# args: ["https://github.com/OCA/website"]
- repo: https://github.com/myint/autoflake
rev: v1.6.1
hooks:
- id: autoflake
args:
- --expand-star-imports
- --ignore-init-module-imports
- --in-place
#- --remove-unused-variables
- --remove-all-unused-imports
- --remove-duplicate-keys
# - repo: https://github.com/psf/black
# rev: 22.8.0
# hooks:
# - id: black
# - repo: https://github.com/pre-commit/mirrors-prettier
# rev: v2.7.1
# hooks:
# - id: prettier
# name: prettier (with plugin-xml)
# additional_dependencies:
# - "prettier@2.7.1"
# - "@prettier/plugin-xml@2.2.0"
# args:
# - --plugin=@prettier/plugin-xml
# files: \.(css|htm|html|js|json|jsx|less|md|scss|toml|ts|xml|yaml|yml)$
# - repo: https://github.com/pre-commit/mirrors-eslint
# rev: v8.24.0
# hooks:
# - id: eslint
# verbose: true
# args:
# - --color
# - --fix
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: trailing-whitespace
# exclude autogenerated files
exclude: lib/|.*\.rst|.*\.pot|README.*
- id: end-of-file-fixer
# exclude autogenerated files
exclude: lib/|.*\.rst|.*\.pot|README.*
- id: debug-statements
- id: fix-encoding-pragma
args: ["--remove"]
- id: check-case-conflict
- id: check-docstring-first
# - id: check-executables-have-shebangs
- id: check-merge-conflict
# exclude files where underlines are not distinguishable from merge conflicts
exclude: /README\.rst$|^docs/.*\.rst$
- id: check-symlinks
- id: check-xml
# - id: mixed-line-ending
# args: ["--fix=lf"]
- repo: https://github.com/asottile/pyupgrade
rev: v2.38.2
hooks:
- id: pyupgrade
args: ["--keep-percent-format"]
- repo: https://github.com/OCA/odoo-pre-commit-hooks
rev: v0.0.27
hooks:
- id: oca-checks-odoo-module
# - id: oca-checks-po
# args: ["--fix"]
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
name: isort except __init__.py
args:
- --settings=.
exclude: /__init__\.py$
# - repo: https://github.com/acsone/setuptools-odoo
# rev: 3.1.8
# hooks:
# - id: setuptools-odoo-make-default
# - id: setuptools-odoo-get-requirements
# args:
# - --output
# - requirements.txt
# - --header
# - "# generated from manifests external_dependencies"
- repo: https://github.com/PyCQA/flake8
rev: 6.1.0
hooks:
- id: flake8
name: flake8
# args: ['--max-line-length=88','--ignore=E123,E133,E226,E241,E242,F811,F601,W503,W504,E203','--exclude=__unported__,__init__.py,__manifest__.py,examples']
additional_dependencies: ["flake8-bugbear==23.9.16", "flake8-print==5.0.0", "importlib-metadata<6.0.0"]
entry: flake8 --config=cfg_run_flake8.cfg
- repo: https://github.com/OCA/pylint-odoo
rev: v9.3.6
hooks:
# C8103
# - id: pylint_odoo
# name: pylint with optional checks
# args:
# - --rcfile=.pylintrc
# - --exit-zero
# verbose: true
- id: pylint_odoo
# entry: pylint --rcfile=cfg_run_pylint.cfg
args:
- --rcfile=cfg_run_pylint.cfg
# args: ['--rcfile=cfg_run_pylint.cfg']

View File

@@ -0,0 +1,178 @@
For copyright information, please see the COPYRIGHT file.
OpenEduCat is published under the GNU LESSER GENERAL PUBLIC LICENSE, Version 3
(LGPLv3), as included below. Since the LGPL is a set of additional
permissions on top of the GPL, the text of the GPL is included at the
bottom as well.
Some external libraries and contributions bundled with OpenEduCat may be
publishedunder other GPL-compatible licenses. For these, please refer to the
relevant source files and/or license files, in the source code tree.
**************************************************************************
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

View File

@@ -0,0 +1,115 @@
# OpenEduCat Community Edition 🎓
[![License: LGPL v3](https://img.shields.io/badge/License-LGPL%20v3-blue.svg)](LICENSE)
[![GitHub stars](https://img.shields.io/github/stars/openeducat/openeducat_erp.svg)](https://github.com/openeducat/openeducat_erp/stargazers)
## Introduction 🚀
OpenEduCat is a powerful, feature-rich **Open Source Educational ERP** designed to streamline academic and administrative processes in educational institutions. Whether youre managing admissions, academics, finance, or human resources, OpenEduCat provides an integrated platform that empowers your institution with flexibility and innovation. Join our community to transform education management and embrace the future of learning! 🌟
---
## Table of Contents
- [Features 🚀📚](#features-)
- [Demo & Live Links 🌐](#demo--live-links-)
- [Installation](#installation)
- [Documentation 📖](#documentation-)
- [Community & Support 🤝](#community--support-)
- [Roadmap 🗺️](#roadmap-)
- [License 📄](#license-)
- [Contact 📞](#contact-)
---
## Features 🚀📚
OpenEduCat offers a comprehensive suite of features tailored for modern educational institutions:
- **Admissions & Registration** 🎟️: Simplify enrollment and registration processes.
- **Student Information Management** 👨‍🎓👩‍🎓: Manage student profiles, academic history, and personal details.
- **Course & Batch Management** 📚: Organize courses, batches, and scheduling with ease.
- **Examination Management** 📝: Streamline exam scheduling, evaluation, and result processing.
- **Fee & Finance Management** 💰: Automate fee collection, invoicing, and financial reporting.
- **Attendance & Timetable** ⏰: Keep track of attendance and manage class schedules efficiently.
- **Library Management** 📖: Handle book lending, cataloging, and member management.
- **Transport & Hostel Management** 🚍🏠: Oversee transportation logistics and hostel accommodations.
- **Communication Tools** 📢: Enhance collaboration with integrated messaging and notifications.
- **Reporting & Analytics** 📊: Generate insightful reports for data-driven decision-making.
- **HR & Payroll Management** 👥: Manage staff records, payroll, and performance reviews.
- **Customizable & Modular** 🔧: Adapt or extend modules to meet your institutions unique needs.
- **Secure & Scalable** 🔒: Robust security features ensure your data is protected while scaling with your growth.
For a full list of features, please visit our [Features Page](https://openeducat.org/features) 😊
---
## Demo & Live Links 🌐
Experience OpenEduCat firsthand:
- **Online Demo**: [Try our live demo](https://openeducat.org/demo) 🎥
- **Official Website**: [Visit OpenEduCat.org](https://openeducat.org) 🌟
- **Community Meetings & Webinars**:
- [Join our next community meeting](https://openeducat.org/meeting) 🤝
- [Register for upcoming webinars](https://webinars.openeducat.org/events) 🎤
---
## Installation 🛠️
- Follow these steps to set up OpenEduCat Community Edition (https://doc.openeducat.org/administration/install.html)
---
## Documentation 📖
Learn more about OpenEduCat:
- **Documentation Portal**: [OpenEduCat Documentation](https://doc.openeducat.org/)
- **User Guides**: Comprehensive guides to help you master the system quickly.
---
## Community & Support 🤝
Join our active and vibrant community:
- **Discussion Forum**: [OpenEduCat Forum](https://openeducat.org/forum)
- **Issue Tracker**: Report bugs and request features on [GitHub Issues](https://github.com/openeducat/openeducat_erp/issues)
- **Community Chat**: Connect with peers on our [Community Chat](https://community.openeducat.org)
- **Social Media**:
- LinkedIN: [@OpenEduCat Company Page](https://www.linkedin.com/company/openeducat-inc/)
- Instagram: [@OpenEduCat Profile](https://www.instagram.com/openeducat)
- Twitter: [@OpenEduCat](https://twitter.com/openeducat)
- Facebook: [OpenEduCat Facebook Page](https://facebook.com/openeducat)
---
## Roadmap 🗺️
Were continuously evolving! Heres a glimpse of whats coming:
- **Enhanced Mobile Experience** 📱: Optimizing for a seamless mobile interface.
- **New Modules** 🆕: Introducing additional modules based on community feedback.
- **Performance Optimization** ⚡: Continuous improvements for faster and smoother operations.
- **Extended Integrations** 🔗: More integrations with popular third-party services.
- **User Experience Enhancements** 🎨: Regular UI/UX updates to make navigation even easier.
Stay tuned for future updates and contribute to shaping our roadmap!
---
## License 📄
OpenEduCat is distributed under the **LGPL-3.0 License**. See the [LICENSE](LICENSE) file for more details.
---
## Contact 📞
Have questions or need support? Get in touch:
- **Email**: [support@openeducat.org](mailto:support@openeducat.org)
- **Forum**: [OpenEduCat Forum](https://openeducat.org/forum)
- **Twitter**: [@OpenEduCat](https://twitter.com/openeducat)
---
Thank you for choosing **OpenEduCat** empowering educational institutions with open source technology. We appreciate your support and look forward to your contributions! 🙌
*Happy Learning & Coding! 💻🎉*

View File

@@ -0,0 +1,4 @@
[flake8]
ignore = E123,E133,E226,E241,E242,F811,F601,W503,W504,E203
max-line-length = 88
exclude = __unported__,__init__.py,__manifest__.py,examples

View File

@@ -0,0 +1,57 @@
[MASTER]
ignore=CVS,.git,scenarios,.bzr,static,openeducat_bigbluebutton
persistent=yes
load-plugins=pylint_odoo
[MESSAGES CONTROL]
disable=all
# Reference of pylint-odoo messages:
# https://github.com/OCA/pylint-odoo/blob/v9.3.6/pylint_odoo/checkers/no_modules.py
enable=C0901, # too-complex
W0402, # deprecated-module
W7901, # dangerous-filter-wo-user
W7902, # duplicate-xml-record-id
W7905, # create-user-wo-reset-password
W7906, # duplicate-id-csv
W7908, # missing-newline-extrafiles
W7909, # redundant-modulename-xml
W7910, # wrong-tabs-instead-of-spaces
W7930, # file-not-used
W7940, # dangerous-view-replace-wo-priority
W7950, # odoo-addons-relative-import
W8101, # api-one-multi-together
W8102, # copy-wo-api-one
W8103, # translation-field
W8104, # api-one-deprecated
W8106, # method-required-super
W8201, # incoherent-interpreter-exec-perm
W8202, # use-vim-comment
C8103, # manifest-deprecated-key
C8104, # class-camelcase
C8108, # method-compute
C8109, # method-search
C8110, # method-inverse
C8201, # no-utf8-coding-comment
R7980, # consider-merging-classes-inherited
R8101, # openerp-exception-warning
R8110, # old-api7-method-defined
E7902, # xml-syntax-error
pointless-string-statement,
redefined-builtin
[REPORTS]
msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg}
output-format=colorized
reports=no
[FORMAT]
indent-string=' '
[SIMILARITIES]
ignore-comments=yes
ignore-docstrings=yes
[MISCELLANEOUS]
notes=

View File

@@ -0,0 +1 @@
This module provide feature of Activity Management.

View File

@@ -0,0 +1,22 @@
###############################################################################
#
# OpenEduCat Inc
# Copyright (C) 2009-TODAY OpenEduCat Inc(<https://www.openeducat.org>).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
###############################################################################
from . import models
from . import wizard

View File

@@ -0,0 +1,51 @@
###############################################################################
#
# OpenEduCat Inc
# Copyright (C) 2009-TODAY OpenEduCat Inc(<https://www.openeducat.org>).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
###############################################################################
{
'name': 'OpenEduCat Activity',
'version': '19.0.1.0',
'license': 'LGPL-3',
'category': 'Education',
"sequence": 3,
'summary': 'Manage Activities',
'complexity': "easy",
'author': 'OpenEduCat Inc',
'website': 'https://www.openeducat.org',
'depends': ['openeducat_core'],
'data': [
'security/op_security.xml',
'security/ir.model.access.csv',
'data/activity_type_data.xml',
'wizard/student_migrate_wizard_view.xml',
'views/activity_view.xml',
'views/activity_type_view.xml',
'views/student_view.xml',
'menus/op_menu.xml'
],
'demo': [
'demo/activity_demo.xml',
],
'images': [
'static/description/openeducat-activity_banner.jpg',
],
'installable': True,
'auto_install': False,
'application': True,
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="op_activity_type_1" model="op.activity.type">
<field name="name">Presentation</field>
</record>
<record id="op_activity_type_2" model="op.activity.type">
<field name="name">Late Application</field>
</record>
<record id="op_activity_type_3" model="op.activity.type">
<field name="name">Migration</field>
</record>
</odoo>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="op_activity_1" model="op.activity">
<field name="description">Good presentation given</field>
<field name="date"
eval="(DateTime.today() - relativedelta(months=1)).strftime('%Y-%m-14 %H:%M')" />
<field name="faculty_id" ref="openeducat_core.op_faculty_1" />
<field name="student_id" ref="openeducat_core.op_student_1" />
<field name="type_id" ref="op_activity_type_1" />
</record>
<record id="op_activity_2" model="op.activity">
<field name="description">Reached 1 hour late</field>
<field name="date"
eval="(DateTime.today() - relativedelta(months=1)).strftime('%Y-%m-12 %H:%M')" />
<field name="faculty_id" ref="openeducat_core.op_faculty_2" />
<field name="student_id" ref="openeducat_core.op_student_2" />
<field name="type_id" ref="op_activity_type_2" />
</record>
<record id="openeducat_core.op_user_faculty" model="res.users">
<field name="group_ids"
eval="[(4,ref('openeducat_activity.group_activity_user'))]"/>
</record>
</odoo>

View File

@@ -0,0 +1,448 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
msgid "Academic Year"
msgstr "العام الدراسي"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "الإجراء مطلوب"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
msgid "Active"
msgstr "نشيط"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "أنشطة"
#. module: openeducat_activity
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
msgid "Activity"
msgstr "نشاط"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "عدد النشاط"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "زخرفة استثناء النشاط"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "عرض الرسم البياني للنشاط"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
msgid "Activity Log"
msgstr "سجل النشاط"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
msgid "Activity Logs"
msgstr "سجلات النشاط"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "محور النشاط"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "حالة النشاط"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity_type
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
msgid "Activity Type"
msgstr "نوع النشاط"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "رمز نوع النشاط"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
msgid "Activity Types"
msgstr "أنواع النشاط"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "يجب أن يكون نوع النشاط فريدًا!"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
msgid "Archived"
msgstr "مؤرشف"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "عدد المرفقات"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same Program!"
msgstr ""
"لا يمكن الترحيل، لأن الدورات التدريبية المحددة لا تشترك في نفس البرنامج!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same parent course!"
msgstr ""
"لا يمكن الترحيل، نظرًا لأن الدورات التدريبية المحددة لا تشترك في نفس الدورة "
"التدريبية الأصلية!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "لا يمكن الهجرة، تابع للحصول على قبول جديد"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "يلغي"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "اكتملت الدورة؟"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
msgid "Created by"
msgstr "تم إنشاؤها بواسطة"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
msgid "Created on"
msgstr "تم الإنشاء بتاريخ"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
msgid "Date"
msgstr "تاريخ"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
msgid "Description"
msgstr "وصف"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
msgid "Display Name"
msgstr "اسم العرض"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "كلية"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "المتابعون"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "المتابعون (الشركاء)"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr "رمز الخط الرائع على سبيل المثال. مهام كرة القدم"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "إلى الأمام"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "من الدورة"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "From Course must not be same as To Course!"
msgstr "يجب ألا يكون \"من الدورة التدريبية\" هو نفسه \"إلى الدورة التدريبية\"!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "لديه رسالة"
#. module: openeducat_activity
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
msgid "Helps you manage your institutes different-different users."
msgstr "يساعدك على إدارة معاهدك بمستخدمين مختلفين."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
msgid "ID"
msgstr "بطاقة تعريف"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "رمز"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "أيقونة للإشارة إلى نشاط الاستثناء."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "إذا تم تحديدها، فإن الرسائل الجديدة تتطلب انتباهك."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr "إذا تم تحديده، فإن بعض الرسائل تحتوي على خطأ في التسليم."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "هو تابع"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
msgid "Last Updated by"
msgstr "آخر تحديث بواسطة"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
msgid "Last Updated on"
msgstr "آخر تحديث بتاريخ"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "مدير"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "خطأ في تسليم الرسالة"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "رسائل"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "الموعد النهائي لنشاطي"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "اسم"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "الموعد النهائي للنشاط التالي"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "ملخص النشاط التالي"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "نوع النشاط التالي"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "عدد الإجراءات"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "عدد الأخطاء"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "عدد الرسائل التي تتطلب اتخاذ إجراء"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "عدد الرسائل التي بها خطأ في التسليم"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "امتياز نشاط Openeducat"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "مواضيع اختيارية"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "المستخدم المسؤول"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "خطأ في تسليم الرسائل القصيرة"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"الحالة على أساس الأنشطة\n"
"متأخر: لقد مر تاريخ الاستحقاق بالفعل\n"
"اليوم: تاريخ النشاط هو اليوم\n"
"المخطط: الأنشطة المستقبلية."
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_student
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
msgid "Student"
msgstr "طالب"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "النشاط الطلابي"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "مجال معرفات الطلاب"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "هجرة الطالب"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "الهجرة الطلابية"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "طلاب)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "شروط"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "إلى الدفعة"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "إلى الدورة"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "نوع نشاط الاستثناء المسجل."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "مستخدم"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "صالحة للدورات"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "رسائل الموقع"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "تاريخ اتصالات الموقع"

View File

@@ -0,0 +1,447 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
msgid "Academic Year"
msgstr "Akademisk år"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Handling nødvendig"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
msgid "Active"
msgstr "Aktiv"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Aktiviteter"
#. module: openeducat_activity
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
msgid "Activity"
msgstr "Aktivitet"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Aktivitetstælling"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Aktivitet Undtagelse Dekoration"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Visning af aktivitetsgraf"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
msgid "Activity Log"
msgstr "Aktivitetslog"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
msgid "Activity Logs"
msgstr "Aktivitetslogs"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Aktivitetspivot"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Aktivitetstilstand"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity_type
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
msgid "Activity Type"
msgstr "Aktivitetstype"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Ikon for aktivitetstype"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
msgid "Activity Types"
msgstr "Aktivitetstyper"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "Aktivitetstypen skal være unik!"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
msgid "Archived"
msgstr "Arkiveret"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Antal vedhæftede filer"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same Program!"
msgstr "Kan ikke migrere, da udvalgte kurser ikke deler samme program!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same parent course!"
msgstr "Kan ikke migrere, da udvalgte kurser ikke deler samme forældrekursus!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Kan ikke migrere. Fortsæt for ny optagelse"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Ophæve"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Kurset gennemført?"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
msgid "Created by"
msgstr "Skabt af"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
msgid "Created on"
msgstr "Oprettet på"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
msgid "Date"
msgstr "Dato"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
msgid "Description"
msgstr "Beskrivelse"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
msgid "Display Name"
msgstr "Vist navn"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Fakultet"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Tilhængere"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Følgere (partnere)"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr "Font fantastisk ikon f.eks. fa-opgaver"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Forward"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Fra Kursus"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "From Course must not be same as To Course!"
msgstr "Fra kursus må ikke være det samme som Til kursus!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Har besked"
#. module: openeducat_activity
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
msgid "Helps you manage your institutes different-different users."
msgstr ""
"Hjælper dig med at administrere dine institutter forskellige-forskellige "
"brugere."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
msgid "ID"
msgstr "ID"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Ikon"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Ikon for at angive en undtagelsesaktivitet."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Hvis markeret, kræver nye beskeder din opmærksomhed."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr "Hvis markeret, har nogle meddelelser en leveringsfejl."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "Er Tilhænger"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
msgid "Last Updated by"
msgstr "Sidst opdateret af"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
msgid "Last Updated on"
msgstr "Sidst opdateret den"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Manager"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Fejl ved levering af besked"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Beskeder"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Deadline for min aktivitet"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Navn"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Næste aktivitetsfrist"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Næste aktivitetsoversigt"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Næste aktivitetstype"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Antal handlinger"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Antal fejl"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Antal meddelelser, der kræver handling"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "Antal meddelelser med leveringsfejl"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Openeducat Aktivitetsprivilegium"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Valgfrie emner"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Ansvarlig bruger"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "SMS-leveringsfejl"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"Status baseret på aktiviteter\n"
"Forfalden: Forfaldsdatoen er allerede overskredet\n"
"I dag: Aktivitetsdatoen er i dag\n"
"Planlagt: Fremtidige aktiviteter."
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_student
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
msgid "Student"
msgstr "Studerende"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Elevaktivitet"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Student IDs domæne"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "Studenter migrerer"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "Studenter migration"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "Elev(er)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Vilkår"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Til Batch"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Til kursus"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "Type af den registrerede undtagelsesaktivitet."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Bruger"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Gælder for kurser"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Hjemmeside beskeder"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Hjemmeside kommunikation historie"

View File

@@ -0,0 +1,454 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
msgid "Academic Year"
msgstr "Akademisches Jahr"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Handlungsbedarf"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
msgid "Active"
msgstr "Aktiv"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Aktivitäten"
#. module: openeducat_activity
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
msgid "Activity"
msgstr "Aktivität"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Aktivitätsanzahl"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Aktivitäts-Ausnahmedekoration"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Aktivitätsdiagrammansicht"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
msgid "Activity Log"
msgstr "Aktivitätsprotokoll"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
msgid "Activity Logs"
msgstr "Aktivitätsprotokolle"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Aktivitäts-Pivot"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Aktivitätsstatus"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity_type
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
msgid "Activity Type"
msgstr "Aktivitätstyp"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Aktivitätstyp-Symbol"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
msgid "Activity Types"
msgstr "Aktivitätstypen"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "Der Aktivitätstyp muss eindeutig sein!"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
msgid "Archived"
msgstr "Archiviert"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Anzahl der Anhänge"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same Program!"
msgstr ""
"Die Migration ist nicht möglich, da die ausgewählten Kurse nicht dasselbe "
"Programm haben!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same parent course!"
msgstr ""
"Die Migration ist nicht möglich, da ausgewählte Kurse nicht denselben "
"übergeordneten Kurs haben!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Die Migration ist nicht möglich. Fahren Sie mit der Neuaufnahme fort"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Stornieren"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Kurs abgeschlossen?"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
msgid "Created by"
msgstr "Erstellt von"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
msgid "Created on"
msgstr "Erstellt am"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
msgid "Date"
msgstr "Datum"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
msgid "Description"
msgstr "Beschreibung"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
msgid "Display Name"
msgstr "Anzeigename"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Fakultät"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Anhänger"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Follower (Partner)"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr "Fantastisches Schriftartensymbol, z. B. Fett-Aufgaben"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Nach vorne"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Vom Kurs"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "From Course must not be same as To Course!"
msgstr "Von Kurs darf nicht mit Zu Kurs identisch sein!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Hat Nachricht"
#. module: openeducat_activity
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
msgid "Helps you manage your institutes different-different users."
msgstr ""
"Hilft Ihnen, die unterschiedlichen Benutzer Ihrer Institute zu verwalten."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
msgid "ID"
msgstr "AUSWEIS"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Symbol"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Symbol zur Anzeige einer Ausnahmeaktivität."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr ""
"Wenn diese Option aktiviert ist, erfordern neue Nachrichten Ihre "
"Aufmerksamkeit."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr ""
"Wenn diese Option aktiviert ist, liegt bei einigen Nachrichten ein "
"Zustellungsfehler vor."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "Ist Follower"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
msgid "Last Updated by"
msgstr "Zuletzt aktualisiert von"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
msgid "Last Updated on"
msgstr "Zuletzt aktualisiert am"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Manager"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Fehler bei der Nachrichtenzustellung"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Nachrichten"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Meine Aktivitätsfrist"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Name"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Frist für die nächste Aktivität"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Zusammenfassung der nächsten Aktivität"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Nächster Aktivitätstyp"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Anzahl der Aktionen"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Anzahl der Fehler"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Anzahl der Nachrichten, die Maßnahmen erfordern"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "Anzahl der Nachrichten mit Zustellungsfehlern"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Openeducat-Aktivitätsprivileg"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Wahlfächer"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Verantwortlicher Benutzer"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Fehler bei der SMS-Zustellung"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"Status basierend auf Aktivitäten\n"
"Überfällig: Fälligkeitsdatum ist bereits überschritten\n"
"Heute: Aktivitätsdatum ist heute\n"
"Geplant: Zukünftige Aktivitäten."
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_student
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
msgid "Student"
msgstr "Student"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Schüleraktivität"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Domäne für Studentenausweise"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "Studentenmigration"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "Studentenmigration"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "Student(en)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Bedingungen"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Zum Stapeln"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Zum Kurs"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "Typ der erfassten Ausnahmeaktivität."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Benutzer"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Gültig für Kurse"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Website-Nachrichten"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Kommunikationsverlauf der Website"

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