25 Commits

Author SHA1 Message Date
Yamen Ahmad
62075c30aa merge: pull production deploy workflow from main into v4 2026-04-30 14:28:03 +04:00
Yamen Ahmad
d5b1987bba feat(platform): full institutional roadmap — exam security, live sessions, handwritten, personalized AI plans, Turnitin, lesson polish
Backend (encoach_lms_api):
- New models: encoach.exam.security.event, encoach.live.session,
  encoach.live.session.participant, encoach.handwritten.submission.
- Extended: encoach.exam.custom (security_level, single_attempt,
  suspicious_event_threshold), encoach.course.plan (is_personalized,
  personalized_for_id, weakness_summary), encoach.entity (turnitin_*).
- New controllers / 24 endpoints:
  * exam_security: POST /api/exam/security/event,
    GET /api/exam/single-attempt-check,
    GET /api/teacher/exam/<id>/security/events,
    GET /api/teacher/exam/attempt/<id>/security/events,
    GET /api/teacher/exam/<id>/live.
  * live_sessions: full CRUD + /notify, /join, /leave, /end,
    /recording, /remove-participant, /attendance, /ics
    (RFC-5545 iCalendar with VALARM + ATTENDEE/ORGANIZER, attached to
    invite emails so Gmail/Outlook/Apple Mail prompt to add).
  * personalized_plan: GET/POST /api/student/personalized-plan
    (CEFR / weeks / focus / grammar_focus payloads).
  * handwritten: student upload + AI grading (pytesseract OCR +
    OpenAI scoring) + teacher review override.
  * reports_export: CSV + PDF export for student-performance and
    record reports.
  * turnitin: per-entity settings (admin), test connection, submit
    text. Real upload stubbed pending institutional API key.
- Manifest: depends now lists encoach_scoring, encoach_exam_template,
  encoach_ai_course so security/live-session models load cleanly.
- teacher_insights.py: fixed assignment traversal via op.batch.course_id,
  res.users mapping, and capped rate_completed at 100% (unique
  student/exam pairs).
- reports.py: branch_id / subject_id / gender filters wired into the
  Reports stack.

Frontend:
- New services: examSecurity, liveSession, personalizedPlan,
  handwritten, turnitin, reportsExport.
- New pages: LiveSessionsPage, LiveSessionRoom (Jitsi IframeAPI embed
  with host bar — Mute all / Cams off / per-row Ask-to-unmute /
  Remove + log audit reason, kickParticipant after backend audit POST),
  TurnitinSettings (admin), TeacherTestSessionConsole, AddToCalendar
  per-card .ics button.
- New components: PersonalizedPlanCard (Quick generate + Customize
  dialog: CEFR, weeks, focus, 9-checkbox grammar), AITutorAvatar,
  HandwrittenSubmissionPanel, ImageLightbox, InfographicBlock
  (six-tone callout with key-based auto-detect — did_you_know,
  key_takeaway, tip, objective, common_mistakes, etc.), ExportButtons.
- useExamSecurity hook: tab/blur, copy/paste/cut, fullscreen exit,
  suspicious shortcuts; debounced auto-post + auto-lock callback.
- StudentCourseDetail / TeacherCourseInsights: discussion now
  embedded as a per-course tab.
- StudentCourses: unified My Courses + My Course Plan with
  is_mandatory color-coding + Accredited Core / Supplementary
  sections (always rendered, with empty states).
- MaterialBookView: special-cases image keys into ImageLightbox,
  routes infographic-style keys into colored InfographicBlock cards,
  splits long strings into paragraphs, rounded-2xl shadow-sm wrapper.
- MaterialViewer: ImageViewer now uses lightbox, ArticleViewer is
  now a real <article class="prose">.
- Layout shells: Live Sessions in Student/Teacher sidebars, Turnitin
  in Admin sidebar; EN + AR i18n keys.
- App.tsx: lazy routes for /live, /live/:id,
  /teacher/exam/:assignmentId/console, /admin/turnitin.
- Visual identity refresh: warm cream bg, white sidebar with charcoal
  active pills, dark CTA buttons, bright orange accents,
  rounded-2xl cards.

Verification:
- All 12 new endpoints smoke-tested via curl (200 + sane payloads).
- /api/live-sessions/<id>/ics returns valid 23-line VCALENDAR.
- Frontend tsc --noEmit clean.
- Odoo restarted successfully (registry loaded in 0.77s, no errors).
- docs/PROJECT_SUMMARY.md updated with both today's polish + the
  full Phase 2/3/4 rollout for the record.

Status: 27/29 institutional requirements fully wired. Turnitin
upload + LMS-side breakout/poll persistence remain partial (see
PROJECT_SUMMARY for details).

Made-with: Cursor
2026-04-30 14:06:48 +04:00
DevOps
747e911829 ci: add production deploy workflow for encoach_v4 monorepo 2026-04-28 00:39:41 +02:00
Yamen Ahmad
744cede1a3 docs(security): redact live OpenAI key, point to ir.config_parameter storage
The previous local-only edit appended a working sk-proj-* key in plain text.
Replaced with a placeholder referencing the Odoo `encoach_ai.openai_api_key`
config parameter (the actual storage). The visible key should be rotated.

Made-with: Cursor
2026-04-28 00:39:37 +04:00
Yamen Ahmad
3b62075d7e feat(platform): course-plan student visibility, multi-voice TTS, OCR sources, branches
Backend (encoach_ai_course):
- workbook_attempt model + scoring + REST endpoints for student attempts
- dialogue_parser splits scripts by speaker, classifies gender, strips labels
- media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation,
  manual media upload endpoint (audio/image/video) with size validation
- source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs,
  page-streaming to stay under memory limit
- exercise_extractor + rag_context for RAG-grounded interactive workbooks
- course_plan_pipeline: v2 generator that grounds week material on indexed
  sources and persists grounded_on_json metadata
- security: access rules for new models

Backend (encoach_lms_api):
- branches model + controller (entity-scoped LMS branches)
- classroom_ext + course_ext (assignment + section workflow)
- classrooms controller: students/teachers/assign-course endpoints

Frontend:
- StudentDashboard: surface assigned AI course plans alongside enrollments;
  enrolled-courses stat now counts plans+enrollments
- InteractiveWorkbook + PlanReader components
- AdminCoursePlanDetail: media drawer with upload buttons (audio/image/video),
  hidden file inputs, upload mutation
- AdminBranches page + sidebar entry
- coursePlan/lms/classrooms services + types updated for new endpoints
- i18n: studentDash.myCoursePlans/noCoursePlans (en + ar)

Infra & docs:
- odoo.conf: bump memory limits to 4G/5G for OCR + sentence-transformers
- .gitignore: ignore *.tsbuildinfo
- docs/ASSIGNMENT_WORKFLOW.{md,pdf}
- smoke_*.py end-to-end tests for assignment workflow, entity isolation,
  course-plan RAG pipeline

Made-with: Cursor
2026-04-28 00:17:51 +04:00
Yamen Ahmad
a6f2140a9d docs(summary): record 2026-04-27 course-plan rollout and OCR indexing fix
Update PROJECT_SUMMARY with the Professional Interactive Course Plans delivery, scanned-PDF OCR indexing remediation, regression/browser verification results, and the current OpenAI insufficient_quota fallback behavior.

Made-with: Cursor
2026-04-27 22:35:00 +04:00
Yamen Ahmad
35ccc0dfb2 feat(platform): ship AI fallback stack and entity-scoped course planning
Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform.

Made-with: Cursor
2026-04-26 02:34:52 +04:00
Yamen Ahmad
096b042daf feat(course-plan): RAG sources + multi-modal media + assignments + student view
Builds the §24 product on top of the LangGraph runtime from §22:

Phase A (Sources / RAG)
  - encoach.course.plan.source model (file | url | text)
  - SourceIndexer extracts PDF (pypdf), DOCX (python-docx), HTML, plain
    text and embeds chunks via the existing pgvector pipeline scoped to
    plan_id, so resources.search only returns the plan's own corpus
  - Endpoints: list/create/upload/reindex/delete + plan-scoped retrieval

Phase B (Deliverables)
  - services.deliverables.compute_deliverables walks the plan, derives
    {planned, generated, ready} per week from material + media state
  - GET /api/ai/course-plan/<id>/deliverables drives the new wizard
    preview step and the live progress strip on the detail page

Phase C (Multi-modal media)
  - encoach.course.plan.media model + MediaService:
    audio: AWS Polly (default) or ElevenLabs
    image: OpenAI DALL-E 3, capped per plan via system parameter
    video: local ffmpeg subprocess (image + audio -> MP4 1280x720)
  - Three new agent tools (media.synthesize_audio / generate_image /
    compose_video), wired into course_week_materials and a new
    course_media_director agent
  - Endpoints per material + week-level batch generator

Phase D (Assignments)
  - encoach.course.plan.assignment supports mode='batch' (op.batch) or
    mode='students' (res.users), with due_date + message + state
  - REST endpoints to list / create / delete assignments

Phase E (Student view)
  - /api/student/course-plans + /api/student/course-plans/<id>
    enforce visibility via assignment.expand_user_ids()
  - New /student/course-plans list + read-only drilldown rendering
    audio/image/video tiles from /web/content/<attachment_id>

Cross-cutting
  - encoach.ai.tool.category: + media (so the new tools register)
  - encoach.embedding gains a plan_id filter for plan-scoped RAG
  - Wizard adds Sources + Multimedia steps; AdminCoursePlanDetail
    rewritten with DeliverablesStrip + SourcesCard + AssignmentsCard +
    per-material MediaDrawer
  - ~280 new EN + AR i18n keys (full RTL coverage)
  - smoke_course_plan.py exercises every phase via odoo-bin shell;
    last run: PASS A/B/D/E + DALL-E 3 image (753 KB), Polly audio
    fails cleanly when AWS creds aren't configured (expected)

Documentation: §24 added to docs/PROJECT_SUMMARY.md with phase-by-phase
artefact list, endpoints, smoke test, ops notes, and gotchas.

Made-with: Cursor
2026-04-25 17:13:01 +04:00
Yamen Ahmad
0ed7f88cab fix(security): remove comment from ir.model.access.csv causing import error
Made-with: Cursor
2026-04-25 14:59:27 +04:00
Yamen Ahmad
ed8e75d88c feat(course-plan): GE1-style AI course planning with deliverables, resources, media, assignments
Based on UTAS GE1 Course Outline structure (Reading/Writing 10hrs + Listening/Speaking 8hrs)

New Models:
- encoach.course.plan.deliverable: Explicit learning outcome tracking by week/skill
- encoach.course.plan.resource.dep: Resource dependencies (textbooks, videos, etc.)
- encoach.course.plan.assignment: Assign plans to classes/students with progress tracking
- encoach.course.plan.assignment.deliverable: Per-student deliverable completion status

Extended Models:
- course.plan.material: Added media fields (media_type, media_asset_url, media_asset_id,
  media_generation_prompt, media_metadata_json) for rich content
- New material types: video_lesson, audio_recording, image_visual, interactive, assessment

AI Agent Tools (agent_tools.py):
- deliverables.detect: Parse course outlines (like GE1 PDF) and extract structured outcomes
- deliverables.fetch: Get deliverables for AI to reference when generating
- resources.fetch: Check available resources before generating content
- resources.save: Persist resource dependencies
- media.suggest_visuals: AI suggests images/diagrams for materials
- media.generate_image: Generate educational images (DALL-E integration ready)
- media.generate_audio: Generate TTS audio (ElevenLabs/Polly integration ready)
- assignment.*: Create assignments and track progress

Pipeline Enhancements (course_plan_pipeline.py):
- generate_deliverables_from_outline(): Parse PDF/text outlines into structured deliverables
- generate_week_materials_with_resources(): Resource-aware content generation
- suggest_media_for_material(): AI visual aid suggestions
- generate_media_for_material(): Actual image/audio generation

New AI Agents (agents_defaults.xml):
- deliverable_detector: Parses GE1-style outlines, extracts deliverables week-by-week
- media_generator: Creates images/audio for teaching materials
- Updated course_planner & course_week_materials with resource tools

REST APIs (course_plan.py):
POST /api/ai/course-plan/<id>/deliverables/detect - Parse outline
GET  /api/ai/course-plan/<id>/deliverables - List deliverables
PUT  /api/ai/course-plan/deliverables/<id> - Update status

GET  /api/ai/course-plan/<id>/resources - List resources
POST /api/ai/course-plan/<id>/resources - Add resource

POST /api/ai/course-plan/materials/<id>/media/suggest - Get visual suggestions
POST /api/ai/course-plan/materials/<id>/media/generate - Generate image/audio

POST /api/ai/course-plan/<id>/assignments - Assign to class/student
GET  /api/ai/course-plan/<id>/assignments - List assignments
GET  /api/ai/course-plan/assignments/<id> - Get with progress
PUT  /api/ai/course-plan/assignments/<id>/deliverables/<del_id> - Update status

Security: Added ir.model.access.csv entries for all new models
Made-with: Cursor
2026-04-25 14:57:04 +04:00
Yamen Ahmad
971e9860c8 ci(frontend): add Gitea staging deploy workflow under frontend/ (monorepo mirror of encoach_frontend New_v2 main)
Made-with: Cursor
2026-04-25 11:55:20 +04:00
Yamen Ahmad
8d173b93cb merge(devops): integrate main (staging CI deploy workflow + PR history) 2026-04-25 11:52:28 +04:00
a5a3a2dc62 ci: add auto-deploy workflow to staging 2026-04-25 09:24:33 +02:00
Yamen Ahmad
fa6f4976c3 chore(ops): add scripts/backup-db.sh — Odoo-format DB backup
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
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
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
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
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
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
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
b1b3d20eb4 Merge pull request 'fix(deploy): vendor OpenEduCat Community 19.0 under backend/' (#6) from v4 into main
Reviewed-on: devops/encoach_backend_new_v2#6
2026-04-20 08:08:32 +02:00
Yamen Ahmad
bab588b9da fix(deploy): vendor OpenEduCat Community 19.0 under backend/
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
e33a9a61bb Merge pull request 'v4' (#5) from v4 into main
Reviewed-on: devops/encoach_backend_new_v2#5
2026-04-19 16:16:33 +02:00
Yamen Ahmad
e1f059069f feat(i18n,rtl): full Arabic localization + RTL sweep across all layouts
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
7024197c7b Merge pull request 'v4' (#4) from v4 into main
Reviewed-on: devops/encoach_backend_new_v2#4
2026-04-19 12:33:30 +02:00
1567 changed files with 419649 additions and 1325 deletions

47
.cursorindexingignore Normal file
View File

@@ -0,0 +1,47 @@
# =====================================================================
# .cursorindexingignore — files Cursor should NOT index for semantic
# search, but that you can still open or @-mention on demand.
#
# Use this for vendored / third-party / generated code that is
# occasionally useful to read but should not pollute search results
# or be sent to the model on every agent turn.
#
# (Note: .gitignore is already honored by Cursor's indexer, so we
# only list things NOT already in .gitignore here.)
# =====================================================================
# ---------- Vendored OpenEduCat ERP (LGPL-3, tracked in repo) ----------
# 48 MB of third-party Odoo modules. Required at runtime but rarely
# needs to be in semantic search context.
backend/openeducat_erp-19.0/
# ---------- Other vendored snapshots inside new_project/ --------------
# Some of these are gitignored already; listed defensively.
new_project/enterprise-19/
new_project/openeducat_erp-19.0/
new_project/encoach_frontend_new_v1/
# ---------- Translation catalogs (huge, low semantic value) ------------
# .po / .pot files are massive and rarely useful to the agent.
**/i18n/*.po
**/i18n/*.pot
**/i18n_extra/*.po
**/i18n_extra/*.pot
# ---------- Vendored static assets inside Odoo modules -----------------
# These are minified third-party JS/CSS, fonts, icons, screenshots —
# never useful for code understanding.
**/static/lib/**
**/static/fonts/**
**/static/description/**
**/static/src/img/**
**/static/src/scss/lib/**
# ---------- Generated docs ---------------------------------------------
**/doc/_build/
**/docs/_build/
# ---------- Database / Odoo backup dumps ------------------------------
*.dump
*.sql.gz
*.sql.xz

View File

@@ -0,0 +1,74 @@
name: Deploy EnCoach
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Pull latest code
run: |
cd /opt/encoach/encoach_v4
git fetch origin main
git reset --hard origin/main
- name: Apply server-side patches
run: |
cd /opt/encoach/encoach_v4
# Fix pip for Debian Bookworm (developer runs macOS — his Dockerfile doesn't need these flags)
sed -i \
's|RUN pip3 install --no-cache-dir|RUN pip3 install --break-system-packages --no-cache-dir --ignore-installed typing_extensions|' \
backend/Dockerfile || true
# Ensure required Python packages are present
grep -q "sentence_transformers" backend/requirements.txt || \
echo "sentence_transformers>=2.7.0" >> backend/requirements.txt
echo "Dockerfile pip line:"; grep "pip3" backend/Dockerfile
echo "Extra packages:"; grep -E "langgraph|sentence" backend/requirements.txt
- name: Copy odoo.conf into build context
run: cp /opt/encoach/overrides/odoo.conf /opt/encoach/encoach_v4/backend/odoo.conf
- name: Build backend image
run: |
cd /opt/encoach/encoach_v4
docker compose -f docker-compose.yml -f /opt/encoach/overrides/encoach.override.yml build odoo
- name: Build frontend image
run: |
cd /opt/encoach/encoach_v4
docker compose -f docker-compose.yml -f /opt/encoach/overrides/encoach.override.yml build frontend
- name: Cleanup build context
run: rm -f /opt/encoach/encoach_v4/backend/odoo.conf
- name: Run DB migrations
run: |
MODULES=$(docker exec encoach-v4-db psql -U odoo -d encoach_v2 -tAc \
"SELECT string_agg(name, ',') FROM ir_module_module WHERE state='installed' AND name LIKE 'encoach%';")
echo "Upgrading: $MODULES"
docker run --rm \
--network encoach_v4_default \
-v /opt/encoach/overrides/odoo.conf:/etc/odoo/odoo.conf:ro \
-v /opt/encoach/encoach_v4/backend:/mnt/extra-addons:ro \
-v /opt/encoach/encoach_v4/backend/openeducat_erp-19.0/openeducat_erp-19.0:/mnt/extra-addons/openeducat_erp-19.0:ro \
-v encoach_backend_new_v2_odoo-web-data:/var/lib/odoo \
encoach-backend:latest \
odoo -u "$MODULES" --stop-after-init 2>&1 | tail -5
- name: Restart services
run: |
cd /opt/encoach/encoach_v4
docker compose -f docker-compose.yml -f /opt/encoach/overrides/encoach.override.yml \
up -d --no-deps odoo frontend
- name: Smoke test
run: |
for i in $(seq 1 12); do
CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8069/api/health)
[ "$CODE" = "200" ] && echo "Health OK: HTTP $CODE" && exit 0
echo "Attempt $i: HTTP $CODE — waiting 10s..."
sleep 10
done
echo "Health check failed after 2 min" && exit 1

12
.gitignore vendored
View File

@@ -26,6 +26,7 @@ pgdata_bak_*/
frontend/.vite/
frontend/dist/
frontend/node_modules/
*.tsbuildinfo
# IDE
.vscode/
@@ -88,9 +89,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 +110,6 @@ htmlcov/
# Poetry
poetry.lock
# Odoo DB backups (local only, not source-controlled)
backups/

Binary file not shown.

View File

@@ -17,12 +17,17 @@
"author": "EnCoach",
"depends": ["base", "encoach_core", "encoach_api"],
"external_dependencies": {
"python": ["openai", "boto3"],
"python": ["openai", "boto3", "langgraph", "langchain_core"],
# Soft deps used only by free media fallbacks; the platform still
# boots and works fine without them — see services/free_image.py
# and services/free_tts.py for graceful import-failure handling.
# Add to a real requirements file: ``pip install Pillow gTTS``.
},
"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

@@ -3,3 +3,5 @@ from . import coach_controller
from . import media_controller
from . import prompt_controller
from . import feedback_controller
from . import agents_controller
from . import ai_settings_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

@@ -24,6 +24,25 @@ 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."""
@@ -37,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:
@@ -69,7 +88,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())
result = ai.generate_insights(
body.get("data", {}),
insight_type=body.get("type", "general"),
@@ -85,7 +104,7 @@ class AIController(http.Controller):
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"},
@@ -103,7 +122,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())
narrative = ai.generate_report_narrative(
body.get("report_type", "performance"),
body.get("data", {}),
@@ -119,7 +138,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())
result = ai.batch_optimize(
body.get("items", []),
optimization_type=body.get("type", "schedule"),
@@ -135,7 +154,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())
skill = body.get("skill", "writing")
if skill == "speaking":
result = ai.grade_speaking(
@@ -160,7 +179,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())
result = ai.generate_content_dedup(
body.get("content_type", "reading_passage"),
body.get("brief", {}),
@@ -204,7 +223,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())
messages = [
{"role": "system", "content": (
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
@@ -224,7 +243,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())
messages = [
{"role": "system", "content": (
"Create a personalized learning plan. Return JSON: "
@@ -246,7 +265,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())
messages = [
{"role": "system", "content": (
"Generate a course outline. Return JSON: {\"chapters\": "
@@ -264,7 +283,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())
messages = [
{"role": "system", "content": (
"Generate detailed chapter content for a course. Return JSON: "
@@ -283,7 +302,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())
messages = [
{"role": "system", "content": (
"Create an assessment rubric. Return JSON: {\"rubric\": "
@@ -350,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")
@@ -480,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
@@ -1353,7 +1372,7 @@ class AIController(http.Controller):
except KeyError:
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
initial_status = "published" if skip_approval else "draft"
initial_status = "published" if skip_approval else "pending_approval"
exam_vals = {
"title": title,
"label": label,
@@ -1539,11 +1558,46 @@ class AIController(http.Controller):
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"],
@@ -1618,7 +1672,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())
messages = [
{"role": "system", "content": (
"You are an educational materials expert. Suggest learning materials "
@@ -1639,7 +1693,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())
result = ai.generate_content(
body.get("content_type", "explanation"),
{"topic_id": topic_id, **body},

View File

@@ -0,0 +1,292 @@
"""Admin endpoints for AI provider selection and API-key management.
* ``GET /api/ai/settings/providers`` — current provider per capability,
redacted view of which API keys are present (booleans only — keys are
*never* echoed back), and the list of allowed providers per capability.
* ``PATCH /api/ai/settings/providers`` — write provider choices and/or
API keys to ``ir.config_parameter``. Settings take effect on the very
next request (no caching), so admins can flip providers without an
Odoo restart.
The controller is admin-gated:
* The caller must be authenticated (``@jwt_required``).
* The caller must have ``user_type == 'admin'`` *or* be in the
``base.group_system`` group. Anything else returns 403.
API-key fields are write-only over the wire. Sending an empty string
clears the key; omitting the field leaves it unchanged. The GET response
returns ``{"openai_key_set": true | false, ...}`` markers so the UI can
render a "saved · click to replace" state without ever leaking the
secret value.
"""
from __future__ import annotations
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, _error_response, _get_json_body,
)
from odoo.addons.encoach_ai.services import provider_router
_logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Configuration tables
# ---------------------------------------------------------------------------
# Provider keys per capability — mirrors provider_router.CAPABILITIES but
# adds UI labels and the "kind" so the frontend can render appropriate
# icons / disclaimers.
_PROVIDER_OPTIONS = {
'text': [
{'value': 'openai', 'label': 'OpenAI (GPT-4o)', 'kind': 'paid'},
{'value': 'mock', 'label': 'Mock (deterministic stub)', 'kind': 'free'},
],
'image': [
{'value': 'auto', 'label': 'Auto (paid → free fallback)', 'kind': 'auto'},
{'value': 'openai', 'label': 'OpenAI (DALL-E 3)', 'kind': 'paid'},
{'value': 'pillow', 'label': 'Pillow placeholder (offline)', 'kind': 'free'},
{'value': 'unsplash', 'label': 'Unsplash Source (free, network)', 'kind': 'free'},
{'value': 'mock', 'label': 'Mock card', 'kind': 'free'},
],
'audio': [
{'value': 'auto', 'label': 'Auto (paid → free fallback)', 'kind': 'auto'},
{'value': 'polly', 'label': 'AWS Polly (neural)', 'kind': 'paid'},
{'value': 'elevenlabs', 'label': 'ElevenLabs (multilingual)', 'kind': 'paid'},
{'value': 'gtts', 'label': 'gTTS (free, network)', 'kind': 'free'},
{'value': 'silent', 'label': 'Silent stub (offline)', 'kind': 'free'},
],
'video': [
{'value': 'auto', 'label': 'Auto', 'kind': 'auto'},
{'value': 'ffmpeg', 'label': 'ffmpeg slideshow (image+audio)', 'kind': 'free'},
{'value': 'static', 'label': 'Static placeholder image', 'kind': 'free'},
],
}
# API-key params managed by this endpoint. Keys are write-only — the
# response only ever returns ``<name>_set: bool``.
_KEY_PARAMS = {
'openai_api_key': 'encoach_ai.openai_api_key',
'aws_access_key': 'encoach_ai.aws_access_key',
'aws_secret_key': 'encoach_ai.aws_secret_key',
'aws_region': 'encoach_ai.aws_region',
'elevenlabs_api_key': 'encoach_ai.elevenlabs_api_key',
'gptzero_api_key': 'encoach_ai.gptzero_api_key',
# Paymob (payments) — included so all platform secrets live in one UI
'paymob_api_key': 'encoach.paymob.api_key',
'paymob_integration_id': 'encoach.paymob.integration_id',
'paymob_iframe_id': 'encoach.paymob.iframe_id',
'paymob_hmac_secret': 'encoach.paymob.hmac_secret',
}
# These params don't carry secrets so we expose their plaintext values.
_PLAIN_PARAMS = {
'aws_region': 'encoach_ai.aws_region',
'openai_model': 'encoach_ai.openai_model',
'openai_fast_model': 'encoach_ai.openai_fast_model',
'elevenlabs_model': 'encoach_ai.elevenlabs_model',
'request_timeout': 'encoach_ai.request_timeout',
'max_retries': 'encoach_ai.max_retries',
'enabled': 'encoach_ai.enabled',
}
# ---------------------------------------------------------------------------
# Authorization
# ---------------------------------------------------------------------------
def _is_admin(env):
"""Return True if the calling user is an EnCoach admin or system admin."""
user = env.user
if not user or not user.id:
return False
if user.has_group('base.group_system'):
return True
user_type = getattr(user, 'user_type', None)
return user_type == 'admin'
# ---------------------------------------------------------------------------
# Serialization helpers
# ---------------------------------------------------------------------------
def _read_state(env):
"""Build the full settings payload (no secrets in the output)."""
Param = env['ir.config_parameter'].sudo()
providers = {}
for cap, options in _PROVIDER_OPTIONS.items():
providers[cap] = {
'active': provider_router.get_active_provider(env, cap),
'options': options,
'paid_with_credentials': provider_router.get_paid_provider_keys(
env, cap,
),
}
keys_set = {}
for short, full_param in _KEY_PARAMS.items():
# ``aws_region`` happens to live in both maps — it's not a secret,
# so we surface its value in ``plain`` and *also* mark it as set so
# the UI can show the field consistently.
val = Param.get_param(full_param)
keys_set[short] = bool(val)
plain = {short: Param.get_param(p, '')
for short, p in _PLAIN_PARAMS.items()}
return {
'providers': providers,
'keys_set': keys_set,
'plain': plain,
}
def _is_clear_marker(value):
"""A trimmed, empty string explicitly clears the param."""
return isinstance(value, str) and value.strip() == ''
# ---------------------------------------------------------------------------
# Controller
# ---------------------------------------------------------------------------
class AISettingsController(http.Controller):
"""REST surface for the AI Provider Settings admin page."""
@http.route('/api/ai/settings/providers',
type='http', auth='none', methods=['GET', 'OPTIONS'],
csrf=False)
@jwt_required
def get_providers(self, **kw):
if not _is_admin(request.env):
return _error_response('Admin access required', 403)
try:
return _json_response({'data': _read_state(request.env)})
except Exception as exc:
_logger.exception('ai_settings.get failed')
return _error_response(str(exc), 500)
@http.route('/api/ai/settings/providers',
type='http', auth='none', methods=['PATCH', 'POST', 'PUT'],
csrf=False)
@jwt_required
def patch_providers(self, **kw):
if not _is_admin(request.env):
return _error_response('Admin access required', 403)
try:
body = _get_json_body() or {}
Param = request.env['ir.config_parameter'].sudo()
# 1. Update active provider per capability — validate that the
# chosen value is one of the offered options to keep junk
# out of ir.config_parameter.
provider_updates = body.get('providers') or {}
invalid = []
for cap, value in provider_updates.items():
if cap not in _PROVIDER_OPTIONS:
invalid.append(f'unknown capability: {cap}')
continue
allowed = {o['value'] for o in _PROVIDER_OPTIONS[cap]}
if value not in allowed:
invalid.append(f'{cap}: {value!r} not in {sorted(allowed)}')
continue
Param.set_param(provider_router.CAPABILITIES[cap]['param'], value)
if invalid:
return _error_response(
'Invalid provider selections: ' + '; '.join(invalid), 400,
)
# 2. Update API keys — write-only. An empty string clears the
# param; omitting the field leaves it untouched.
key_updates = body.get('keys') or {}
for short, value in key_updates.items():
if short not in _KEY_PARAMS:
continue
full_param = _KEY_PARAMS[short]
if value is None:
continue
if _is_clear_marker(value):
Param.set_param(full_param, '')
else:
# Trim whitespace to defend against a copy-paste with
# a trailing newline that would silently break SDK auth.
Param.set_param(full_param, str(value).strip())
# 3. Plain-value updates (model names, region, timeout, ...).
plain_updates = body.get('plain') or {}
for short, value in plain_updates.items():
if short not in _PLAIN_PARAMS:
continue
Param.set_param(_PLAIN_PARAMS[short], '' if value is None
else str(value))
# Audit log so admins can see who flipped providers.
try:
_logger.info(
'ai_settings.update by user_id=%s providers=%s '
'keys_changed=%s plain_changed=%s',
request.env.user.id,
list(provider_updates.keys()),
[k for k in key_updates.keys() if k in _KEY_PARAMS],
list(plain_updates.keys()),
)
except Exception:
pass
return _json_response({'data': _read_state(request.env)})
except Exception as exc:
_logger.exception('ai_settings.patch failed')
return _error_response(str(exc), 500)
@http.route('/api/ai/settings/providers/test',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def test_provider(self, **kw):
"""Quick "is this configured?" probe for the UI's Test button.
Body: ``{"capability": "image" | "audio" | "text"}``.
Returns the resolved provider chain plus a flag for each entry
indicating whether credentials are present. We deliberately do
NOT make a real network call — we just resolve the chain and
check for credentials so the test is instant and free.
"""
if not _is_admin(request.env):
return _error_response('Admin access required', 403)
try:
body = _get_json_body() or {}
capability = body.get('capability') or 'image'
if capability not in provider_router.CAPABILITIES:
return _error_response(
f'Unknown capability: {capability}', 400,
)
chain = provider_router.resolve_chain(request.env, capability)
paid_with_creds = set(
provider_router.get_paid_provider_keys(request.env, capability),
)
entries = []
for prov in chain:
if prov in provider_router.CAPABILITIES[capability]['paid']:
ok = prov in paid_with_creds
note = 'credentials configured' if ok else 'no API key'
else:
ok = True # free providers are always available
note = 'free fallback'
entries.append({'provider': prov, 'ok': ok, 'note': note})
return _json_response({
'capability': capability,
'active': provider_router.get_active_provider(
request.env, capability),
'chain': entries,
})
except Exception as exc:
_logger.exception('ai_settings.test failed')
return _error_response(str(exc), 500)

View File

@@ -23,12 +23,25 @@ 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="none", methods=["POST"], csrf=False)

View File

@@ -0,0 +1,410 @@
<?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>
<!-- Media generation -->
<record id="ai_tool_media_audio" model="encoach.ai.tool">
<field name="key">media.synthesize_audio</field>
<field name="name">Synthesize narration audio</field>
<field name="category">media</field>
<field name="mutates" eval="True"/>
<field name="description">Render an MP3 narration of a material's text using AWS Polly (default) or ElevenLabs. Used for listening_script and speaking_prompt materials. Returns the media id, status and a /web/content URL when ready.</field>
<field name="schema_json">{"type":"object","properties":{"material_id":{"type":"integer"},"voice":{"type":"string"},"language":{"type":"string","default":"en-GB"},"gender":{"type":"string","enum":["female","male"]},"provider":{"type":"string","enum":["polly","elevenlabs"]}},"required":["material_id"]}</field>
<field name="sequence">120</field>
</record>
<record id="ai_tool_media_image" model="encoach.ai.tool">
<field name="key">media.generate_image</field>
<field name="name">Generate illustration (DALL-E)</field>
<field name="category">media</field>
<field name="mutates" eval="True"/>
<field name="description">Generate a single PNG illustration with OpenAI DALL-E 3 for a course-plan material. Used for reading hero images and vocabulary flashcards. Honours the per-plan image budget (encoach_ai_course.image_budget_per_plan).</field>
<field name="schema_json">{"type":"object","properties":{"material_id":{"type":"integer"},"prompt":{"type":"string"},"size":{"type":"string","enum":["1024x1024","1024x1792","1792x1024"]},"style":{"type":"string","enum":["natural","vivid"]},"quality":{"type":"string","enum":["standard","hd"]}},"required":["material_id"]}</field>
<field name="sequence">130</field>
</record>
<record id="ai_tool_media_video" model="encoach.ai.tool">
<field name="key">media.compose_video</field>
<field name="name">Compose slideshow video (ffmpeg)</field>
<field name="category">media</field>
<field name="mutates" eval="True"/>
<field name="description">Combine an existing audio narration with a still image into an MP4 (1280x720) using a local ffmpeg subprocess. Auto-creates audio and/or image first if the material lacks them. Falls back gracefully when ffmpeg is missing on the server.</field>
<field name="schema_json">{"type":"object","properties":{"material_id":{"type":"integer"}},"required":["material_id"]}</field>
<field name="sequence">140</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'),
ref('ai_tool_media_audio'),
ref('ai_tool_media_image'),
])]"/>
</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>
<!-- 8. Course media director -->
<record id="ai_agent_course_media_director" model="encoach.ai.agent">
<field name="key">course_media_director</field>
<field name="name">Course Media Director</field>
<field name="description">Given a generated week of teaching materials, decides which media (audio narration, illustrations, slideshow video) each material needs and orchestrates their generation through the media tools.</field>
<field name="model">gpt-4o-mini</field>
<field name="fallback_model">gpt-4o</field>
<field name="temperature">0.3</field>
<field name="max_tokens">2000</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">80</field>
<field name="system_prompt">You are the multimedia director for an English language course. Given the materials of one week, you decide what media each material needs, then call the matching tools.
Default policy:
- listening_script -> media.synthesize_audio (mandatory) + media.generate_image (optional, scene illustration) + media.compose_video (optional, slideshow)
- speaking_prompt -> media.synthesize_audio for the model answer (optional)
- reading_text -> media.generate_image for a hero illustration (optional)
- vocabulary_list -> media.generate_image for the first 1-3 terms (use vocab term in the prompt)
- writing_prompt / grammar_lesson -> usually no media
Rules:
- Always check if a material already has ready media of a given kind before regenerating; if so, skip.
- Stop after issuing the planned tool calls; never invent material ids.
- When done, emit a short text summary listing what was generated (media_id, status, error if any).</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_media_audio'),
ref('ai_tool_media_image'),
ref('ai_tool_media_video'),
])]"/>
</record>
<!-- Default per-plan image budget. Adjust in System Parameters. -->
<record id="ai_default_image_budget" model="ir.config_parameter">
<field name="key">encoach_ai_course.image_budget_per_plan</field>
<field name="value">60</field>
</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

@@ -2,4 +2,5 @@ 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,358 @@
"""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"),
("media", "Media generation"),
("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

@@ -89,7 +89,7 @@ class EncoachAIFeedback(models.Model):
"encoach.entity", ondelete="set null", index=True,
)
course_id = fields.Many2one(
"encoach.course", ondelete="set null", index=True,
"op.course", ondelete="set null", index=True,
)
# ------------------------------------------------------------------

View File

@@ -5,3 +5,7 @@ access_ai_prompt_admin,encoach.ai.prompt admin,model_encoach_ai_prompt,base.grou
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
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

@@ -7,3 +7,8 @@ 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
from . import provider_router # capability -> active-provider resolver
from . import free_image # offline Pillow-based image placeholder
from . import free_tts # gTTS + silent-MP3 audio fallbacks

View File

@@ -0,0 +1,531 @@
"""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
should_revise: bool # set by review node when a revise pass is queued
# =============================================================================
# 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):
# INVARIANT: every AgentRuntime is per-request and constructs a
# fresh OpenAIService, which reads ir.config_parameter on every
# __init__. Combined with MediaService → provider_router (also
# uncached), this guarantees that flipping a provider in the
# admin UI takes effect on the very next request — no Odoo
# restart, no cache invalidation. Don't introduce any
# module-level or class-level provider caches here.
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 quality tools against the LLM output and (maybe) queue a revision.
We do all state mutations here — adding the critique message and
bumping ``revisions_used`` — and only signal the router with a
boolean ``should_revise``. LangGraph routing functions are
treated as pure: any state changes made there are discarded, so
keeping the router pure prevents an infinite revise loop where
the counter never actually increments.
"""
# If the LLM step already errored out, don't waste a quality
# check on the empty/garbage output and don't try to "revise" —
# another LLM call would just hit the same permanent failure.
if state.get("error"):
return {**state, "quality_issues": [], "should_revise": False}
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])
revisions_used = state.get("revisions_used") or 0
max_rev = max(0, int(self.agent.max_revisions or 0))
if issues and revisions_used < max_rev:
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}
]
return {
**state,
"messages": messages,
"quality_issues": issues,
"revisions_used": revisions_used + 1,
"should_revise": True,
}
return {
**state,
"quality_issues": issues,
"should_revise": False,
}
def _route_after_review(self, state: AgentState) -> str:
# Pure router: only inspect state, never mutate. The decision
# was prepared in ``_node_review``.
if state.get("error"):
return "done"
return "revise" if state.get("should_revise") else "done"
# 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,418 @@
"""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, plan_id: int | None = None,
**_: Any) -> dict:
"""Semantic search over the LMS resource library.
When ``plan_id`` is provided, retrieval is scoped to that plan's
indexed reference sources only (uploaded PDFs, URLs, inline text).
Without ``plan_id`` we search the global 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,
)
try:
svc = EmbeddingService(env)
if plan_id:
results = svc.search(
query or "",
content_type="course_plan_source",
entity_id=int(plan_id),
limit=int(limit or 5),
)
else:
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, "plan_id": plan_id, "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)}
# --- Media generation tools ---------------------------------------------------
#
# These bridge an agent decision (e.g. "this listening lesson needs an MP3")
# into the MediaService implementation. They are mutating tools — the seed
# row in agents_defaults.xml has ``mutates=True`` so the runtime wraps them
# in a savepoint.
@register("media.synthesize_audio")
def _media_synthesize_audio(env, material_id: int, voice: str = "",
language: str = "en-GB",
gender: str = "female",
provider: str = "polly", **_: Any) -> dict:
Material = env["encoach.course.plan.material"].sudo() \
if "encoach.course.plan.material" in env else None
if Material is None:
return {"error": "course_plan_material_model_missing"}
rec = Material.browse(int(material_id))
if not rec.exists():
return {"error": "material_not_found"}
from odoo.addons.encoach_ai_course.services.media_service import MediaService
svc = MediaService(env)
media = svc.synthesize_audio(
rec, voice=voice or None, language=language or "en-GB",
gender=gender or "female", provider=provider or "polly",
)
return {
"media_id": media.id,
"status": media.status,
"download_url": media.download_url,
"error": media.error or None,
}
@register("media.generate_image")
def _media_generate_image(env, material_id: int, prompt: str = "",
size: str = "1024x1024",
style: str = "natural",
quality: str = "standard", **_: Any) -> dict:
Material = env["encoach.course.plan.material"].sudo() \
if "encoach.course.plan.material" in env else None
if Material is None:
return {"error": "course_plan_material_model_missing"}
rec = Material.browse(int(material_id))
if not rec.exists():
return {"error": "material_not_found"}
from odoo.addons.encoach_ai_course.services.media_service import MediaService
svc = MediaService(env)
media = svc.generate_image(
rec, custom_prompt=prompt or None,
size=size or "1024x1024",
style=style or "natural",
quality=quality or "standard",
)
return {
"media_id": media.id,
"status": media.status,
"download_url": media.download_url,
"error": media.error or None,
}
@register("media.compose_video")
def _media_compose_video(env, material_id: int, **_: Any) -> dict:
Material = env["encoach.course.plan.material"].sudo() \
if "encoach.course.plan.material" in env else None
if Material is None:
return {"error": "course_plan_material_model_missing"}
rec = Material.browse(int(material_id))
if not rec.exists():
return {"error": "material_not_found"}
from odoo.addons.encoach_ai_course.services.media_service import MediaService
svc = MediaService(env)
media = svc.compose_video(rec)
return {
"media_id": media.id,
"status": media.status,
"download_url": media.download_url,
"error": media.error or None,
}

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

@@ -0,0 +1,150 @@
"""Offline image placeholder generator using Pillow.
Used as a free fallback when DALL-E (or any paid image API) is missing
credentials, returns a billing/quota error, or is otherwise unavailable.
The resulting PNG is a clean education-themed gradient card with the
title overlaid — good enough to keep the LMS UI populated until a real
image is generated.
Pillow is the only runtime dependency (already a transitive dep of Odoo
through ``reportlab`` on most installs). If Pillow is not importable we
raise so the caller knows to skip this provider and try the next one.
"""
from __future__ import annotations
import io
import logging
import random
_logger = logging.getLogger(__name__)
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError: # pragma: no cover — Pillow is a soft dep
Image = None
ImageDraw = None
ImageFont = None
# Soft education palettes (top-left, bottom-right gradient pairs)
_PALETTES = [
((59, 130, 246), (147, 197, 253)), # blue
((16, 185, 129), (110, 231, 183)), # emerald
((245, 158, 11), (252, 211, 77)), # amber
((139, 92, 246), (196, 181, 253)), # violet
((236, 72, 153), (249, 168, 212)), # pink
((20, 184, 166), (153, 246, 228)), # teal
((99, 102, 241), (165, 180, 252)), # indigo
((220, 38, 38), (252, 165, 165)), # rose
]
def _parse_size(size):
try:
w, h = (int(p) for p in str(size).lower().split('x'))
except Exception:
return 1024, 1024
return max(64, min(2048, w)), max(64, min(2048, h))
def _wrap(draw, text, font, max_width, max_lines=6):
words = (text or '').split()
lines, current = [], ''
for word in words:
candidate = (current + ' ' + word).strip()
if draw.textlength(candidate, font=font) <= max_width:
current = candidate
else:
if current:
lines.append(current)
current = word
if len(lines) >= max_lines:
return lines
if current and len(lines) < max_lines:
lines.append(current)
return lines
def _load_font(preferred, size):
"""Try a list of fonts; fall back to the bundled default at any size."""
for name in preferred:
try:
return ImageFont.truetype(name, size=size)
except Exception:
continue
return ImageFont.load_default()
def render_placeholder(title, *, subtitle=None, size='1024x1024', seed=None):
"""Return PNG bytes for a placeholder card.
Args:
title: Main title text rendered large and centred.
subtitle: Optional smaller line below the title (e.g. CEFR level,
week label) — pass ``None`` to skip.
size: ``"WIDTHxHEIGHT"`` string, e.g. ``"1024x1024"``.
seed: Optional seed for palette selection so the same title yields
the same gradient repeatably.
"""
if Image is None:
raise RuntimeError(
'Pillow not installed — pip install Pillow to enable the free '
'image fallback.'
)
w, h = _parse_size(size)
rng = random.Random(seed if seed is not None else (title or '').lower())
top, bottom = rng.choice(_PALETTES)
img = Image.new('RGB', (w, h), top)
px = img.load()
for y in range(h):
t = y / max(1, h - 1)
r = int(top[0] * (1 - t) + bottom[0] * t)
g = int(top[1] * (1 - t) + bottom[1] * t)
b = int(top[2] * (1 - t) + bottom[2] * t)
for x in range(w):
px[x, y] = (r, g, b)
draw = ImageDraw.Draw(img)
title_font = _load_font(
['DejaVuSans-Bold.ttf', 'Arial Bold.ttf', 'arial.ttf'],
int(h * 0.07),
)
sub_font = _load_font(
['DejaVuSans.ttf', 'Arial.ttf', 'arial.ttf'],
int(h * 0.035),
)
margin = int(w * 0.08)
max_text_w = w - 2 * margin
title_lines = _wrap(draw, title or 'Untitled', title_font, max_text_w)
line_h = int(h * 0.085)
total_h = line_h * len(title_lines)
y = (h - total_h) // 2
for line in title_lines:
line_w = draw.textlength(line, font=title_font)
x = (w - line_w) // 2
draw.text((x + 2, y + 2), line, font=title_font, fill=(0, 0, 0))
draw.text((x, y), line, font=title_font, fill=(255, 255, 255))
y += line_h
if subtitle:
sub_w = draw.textlength(subtitle, font=sub_font)
sx = (w - sub_w) // 2
sy = y + int(h * 0.02)
draw.text((sx + 1, sy + 1), subtitle, font=sub_font, fill=(0, 0, 0))
draw.text((sx, sy), subtitle, font=sub_font, fill=(255, 255, 255))
# Subtle EnCoach watermark badge so generated assets are easy to spot
badge_font = _load_font(['DejaVuSans.ttf', 'Arial.ttf'], int(h * 0.022))
badge = 'EnCoach · placeholder'
bw = draw.textlength(badge, font=badge_font)
draw.text(
(w - margin - bw, h - margin),
badge, font=badge_font, fill=(255, 255, 255),
)
buf = io.BytesIO()
img.save(buf, format='PNG', optimize=True)
return buf.getvalue()

View File

@@ -0,0 +1,128 @@
"""Free / offline text-to-speech fallbacks.
Two providers, ordered most-useful-first:
1. ``gtts`` — Google Translate TTS. Free and surprisingly natural, but
requires outbound network access. We try this first when
the paid provider is exhausted.
2. ``silent`` — A pre-encoded silent MP3 (~1 second). Used as a last
resort so that downstream consumers (notably the video
composer) still receive valid audio bytes and don't crash.
The shape of the return dict matches what ``PollyService.synthesize``
returns (``audio``, ``content_type``, ``voice``, ``characters``) so
callers can swap providers transparently.
"""
from __future__ import annotations
import io
import logging
import struct
_logger = logging.getLogger(__name__)
try: # pragma: no cover — gTTS is a soft dep
from gtts import gTTS
except ImportError:
gTTS = None
def _build_silent_wav(duration_seconds: float = 1.0,
sample_rate: int = 8000) -> bytes:
"""Construct a valid PCM WAV byte string with all-zero samples.
WAV is trivially constructable from primitives so we can produce it
without any external library. ffmpeg accepts WAV as readily as MP3,
so the rest of the pipeline is unaffected by the format choice.
"""
n_samples = max(1, int(duration_seconds * sample_rate))
samples = b'\x00\x00' * n_samples # 16-bit mono silence
data_size = len(samples)
fmt_chunk = (
b'fmt '
+ struct.pack('<I', 16) # PCM fmt chunk size
+ struct.pack('<H', 1) # PCM
+ struct.pack('<H', 1) # mono
+ struct.pack('<I', sample_rate)
+ struct.pack('<I', sample_rate * 2) # byte rate
+ struct.pack('<H', 2) # block align
+ struct.pack('<H', 16) # bits per sample
)
data_chunk = b'data' + struct.pack('<I', data_size) + samples
return (
b'RIFF'
+ struct.pack('<I', 36 + data_size)
+ b'WAVE'
+ fmt_chunk
+ data_chunk
)
# Map our internal language codes to (gTTS lang, gTTS tld) tuples. The
# tld controls accent (co.uk vs com vs com.au) so picking carefully here
# gives the listening exam a more authentic accent.
_GTTS_LANG_MAP = {
'en-GB': ('en', 'co.uk'),
'en-US': ('en', 'com'),
'en-AU': ('en', 'com.au'),
'en-IN': ('en', 'co.in'),
'en': ('en', 'co.uk'),
'ar': ('ar', 'com'),
'ar-EG': ('ar', 'com'),
'ar-SA': ('ar', 'com'),
'fr': ('fr', 'fr'),
'fr-FR': ('fr', 'fr'),
'es': ('es', 'es'),
'de': ('de', 'de'),
'tr': ('tr', 'com'),
'fa': ('fa', 'com'),
'ur': ('ur', 'com'),
'hi': ('hi', 'co.in'),
'zh': ('zh-CN', 'com'),
'ja': ('ja', 'com'),
}
def synthesize_with_gtts(text, *, language='en-GB'):
"""Synthesize ``text`` to MP3 bytes using gTTS.
Raises ``RuntimeError`` if gTTS is not installed; the caller is
expected to catch and try the next provider in the chain.
"""
if gTTS is None:
raise RuntimeError(
'gTTS not installed — pip install gTTS to enable the free '
'audio fallback.'
)
short = (text or '')[:4500]
if not short.strip():
return synthesize_silent()
lang, tld = _GTTS_LANG_MAP.get(language, ('en', 'co.uk'))
buf = io.BytesIO()
tts = gTTS(text=short, lang=lang, tld=tld, slow=False)
tts.write_to_fp(buf)
return {
'audio': buf.getvalue(),
'content_type': 'audio/mpeg',
'voice': f'gtts-{lang}-{tld}',
'characters': len(short),
}
def synthesize_silent(duration_seconds=1):
"""Return a minimal valid silent audio stub.
Returns a PCM WAV (which ffmpeg accepts identically to MP3) of the
requested duration. Used when even gTTS is unreachable and we just
need *some* valid audio so the video composer doesn't fail and the
media row can still be marked ``ready``.
"""
payload = _build_silent_wav(duration_seconds=duration_seconds)
return {
'audio': payload,
'content_type': 'audio/wav',
'voice': 'silent-stub',
'characters': 0,
}

View File

@@ -12,10 +12,45 @@ 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")
@@ -29,13 +64,77 @@ class OpenAIService:
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, timeout=self.request_timeout)
# The SDK retries internally up to 2 times by default with exponential
# backoff, but we already do that ourselves in `_retry_with_backoff`.
# Stacking both meant a single quota error could trigger 9+ retries
# over several minutes before the controller could return — leaving
# the frontend's `Generate plan` button hanging. We disable the
# SDK's retries and let our own loop (which knows about
# insufficient_quota) be the single source of truth.
self.client = _openai_mod.OpenAI(
api_key=api_key,
timeout=self.request_timeout,
max_retries=0,
)
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-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):
# Skip writing if the request transaction has already been
# aborted/rolled back (typically when an upstream caller caught
# the AI exception, re-raised, and the surrounding `try/except`
# in our route handler is about to return 500). Trying to insert
# in that state raises `psycopg2.InterfaceError: cursor already
# closed` and pollutes the log with a misleading second stack
# trace that hides the real upstream failure.
cr = getattr(self.env, "cr", None)
if cr is None or getattr(cr, "closed", False):
return
try:
self.env["encoach.ai.log"].sudo().create({
"service": "openai",
@@ -50,15 +149,41 @@ class OpenAIService:
"input_preview": (inp or "")[:500],
"output_preview": (out or "")[:500],
})
except Exception:
_logger.warning("Failed to log AI call", exc_info=True)
except Exception as exc:
# Most common case is psycopg2.InterfaceError when the txn
# has already been rolled back by a higher-level handler.
# Don't include `exc_info=True` for that one — it's noise.
err_str = str(exc).lower()
if "cursor already closed" in err_str or "current transaction is aborted" in err_str:
_logger.debug("Skipping AI log write — txn already aborted (%s)", action)
else:
_logger.warning("Failed to log AI call", exc_info=True)
def _check_enabled(self):
if not self.enabled:
raise RuntimeError("AI is disabled — enable in Settings > AI Configuration")
# Errors that will never resolve by retrying. These are user / billing
# / configuration conditions: retrying just wastes wall-clock time and
# leaves the frontend hanging on the wizard "Finish" button.
_NON_RETRYABLE_MARKERS = (
"insufficient_quota",
"invalid_api_key",
"incorrect_api_key",
"account_deactivated",
"billing_hard_limit_reached",
"model_not_found",
"context_length_exceeded",
)
def _retry_with_backoff(self, fn, action, model):
"""Execute fn with exponential backoff retries."""
"""Execute ``fn`` with exponential backoff retries.
Permanent failures (quota exhausted, bad API key, etc.) are raised
on the first attempt; transient ones (true rate-limit, 5xx) are
retried up to ``self.max_retries``. The OpenAI SDK is configured
with ``max_retries=0`` so this loop is the only retry layer.
"""
last_exc = None
for attempt in range(self.max_retries):
try:
@@ -66,6 +191,12 @@ class OpenAIService:
except Exception as exc:
last_exc = exc
err_str = str(exc).lower()
if any(m in err_str for m in self._NON_RETRYABLE_MARKERS):
_logger.warning(
"AI permanent failure for %s (no retry): %s",
action, exc,
)
raise
is_rate_limit = "rate" in err_str or "429" in err_str
is_server_error = "500" in err_str or "502" in err_str or "503" in err_str
if not (is_rate_limit or is_server_error) or attempt == self.max_retries - 1:
@@ -82,6 +213,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():
@@ -108,6 +240,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():
@@ -133,6 +266,60 @@ class OpenAIService:
"""Use the fast/cheap model for classification, tagging, simple tasks."""
return self.chat(messages, model=self.fast_model, **kwargs)
def generate_image(self, prompt, *, size="1024x1024", style="natural",
quality="standard", model=None):
"""Generate an image with DALL-E 3 and return raw PNG bytes.
Returns:
dict {"image": bytes, "revised_prompt": str, "model": str,
"size": str, "style": str}.
Raises ``RuntimeError`` if OpenAI is not configured. Network /
moderation failures bubble up as the SDK's exceptions; callers
should catch and record them.
"""
self._check_enabled()
if not self.client:
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
image_model = model or self._get_param(
"encoach_ai.openai_image_model", "dall-e-3",
)
t0 = time.time()
try:
resp = self.client.images.generate(
model=image_model,
prompt=prompt or "",
n=1,
size=size,
style=style,
quality=quality,
response_format="b64_json",
timeout=self.request_timeout,
)
data = resp.data[0]
import base64 as _b64
img_bytes = _b64.b64decode(data.b64_json)
self._log(
"generate_image", image_model, None,
int((time.time() - t0) * 1000),
inp=(prompt or "")[:500],
out=f"{len(img_bytes)}B {size} {style}",
)
return {
"image": img_bytes,
"revised_prompt": getattr(data, "revised_prompt", "") or "",
"model": image_model,
"size": size,
"style": style,
}
except Exception as exc:
self._log(
"generate_image", image_model, None,
int((time.time() - t0) * 1000),
status="error", error=str(exc),
)
raise
def grade_writing(self, rubric, task_text, response_text):
"""Grade a writing response using GPT with a rubric."""
messages = [

View File

@@ -0,0 +1,187 @@
"""Resolve the active AI provider per capability.
Settings are read fresh from ``ir.config_parameter`` on every call — there
is intentionally NO module-level cache so flipping a provider in the admin
UI takes effect on the next request without restarting Odoo.
Public surface:
* :func:`get_active_provider(env, capability)` — returns the provider key
configured for ``capability`` (one of ``text|image|audio|video``).
* :func:`classify_provider_error(exc)` — turns an arbitrary exception from
any third-party SDK into one of ``quota|auth|network|other`` so callers
can decide whether to fall back automatically.
* :class:`ProviderQuotaError`, :class:`ProviderAuthError` — typed errors
that callers may raise when they want a strongly-typed signal.
The capability tables also enumerate the **allowed free providers** per
capability — these are the ones the fallback chain uses when the paid
provider returns a quota or auth error.
"""
from __future__ import annotations
import logging
_logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Capabilities & provider names
# ---------------------------------------------------------------------------
#
# ``auto`` means "pick the first paid provider that's configured, else fall
# back to the first free provider that works". Admins who want to *force* a
# specific provider should pick its name explicitly in the UI.
CAPABILITIES = {
'text': {
'param': 'encoach.ai.text_provider',
'default': 'openai',
'paid': ['openai'],
'free': ['mock'],
},
'image': {
'param': 'encoach.ai.image_provider',
'default': 'auto',
'paid': ['openai'],
'free': ['pillow', 'unsplash', 'mock'],
},
'audio': {
'param': 'encoach.ai.audio_provider',
'default': 'auto',
'paid': ['polly', 'elevenlabs'],
'free': ['gtts', 'silent'],
},
'video': {
'param': 'encoach.ai.video_provider',
'default': 'auto',
'paid': [],
'free': ['ffmpeg', 'static'],
},
}
# ---------------------------------------------------------------------------
# Typed errors
# ---------------------------------------------------------------------------
class ProviderQuotaError(RuntimeError):
"""Raised when a provider returns a billing/quota error.
Examples include OpenAI ``insufficient_quota``, AWS Polly
``ThrottlingException``, ElevenLabs character-limit rejection, and any
HTTP 402/429. Callers should treat this as a soft failure and try the
next provider in the fallback chain.
"""
class ProviderAuthError(RuntimeError):
"""Raised when a provider refuses authentication (missing/invalid key)."""
# ---------------------------------------------------------------------------
# Resolution
# ---------------------------------------------------------------------------
def get_active_provider(env, capability):
"""Read the currently-active provider key for ``capability``.
Always reads from ``ir.config_parameter`` — never cached.
"""
cap = CAPABILITIES[capability]
return env['ir.config_parameter'].sudo().get_param(
cap['param'], cap['default'],
) or cap['default']
def get_paid_provider_keys(env, capability):
"""Return the list of paid providers for the capability that have an
API key configured. Used to decide whether to skip straight to free
fallbacks when ``auto`` is selected but no paid keys exist.
"""
Param = env['ir.config_parameter'].sudo()
available = []
for provider in CAPABILITIES[capability]['paid']:
if _provider_has_credentials(Param, provider):
available.append(provider)
return available
def _provider_has_credentials(Param, provider):
if provider == 'openai':
return bool(Param.get_param('encoach_ai.openai_api_key'))
if provider == 'polly':
return bool(Param.get_param('encoach_ai.aws_access_key')) and bool(
Param.get_param('encoach_ai.aws_secret_key')
)
if provider == 'elevenlabs':
return bool(Param.get_param('encoach_ai.elevenlabs_api_key'))
return False
def resolve_chain(env, capability, *, requested=None):
"""Return an ordered list of providers to try for ``capability``.
Args:
capability: ``'text' | 'image' | 'audio' | 'video'``.
requested: optional explicit provider override (e.g. body param).
When supplied it goes to the front of the chain.
"""
cap = CAPABILITIES[capability]
chain = []
selected = (requested or get_active_provider(env, capability) or '').strip()
if selected and selected != 'auto':
chain.append(selected)
# Paid providers with credentials, then free fallbacks
if selected == 'auto' or not selected:
for p in get_paid_provider_keys(env, capability):
if p not in chain:
chain.append(p)
for p in cap['free']:
if p not in chain:
chain.append(p)
return chain
# ---------------------------------------------------------------------------
# Error classification
# ---------------------------------------------------------------------------
_QUOTA_TOKENS = (
'insufficient_quota', 'quota', 'billing',
'429', '402',
'rate limit', 'rate_limit', 'rate-limit',
'throttlingexception', 'thresholdexceeded',
'limit_exceeded', 'character limit', 'too many requests',
)
_AUTH_TOKENS = (
'invalid_api_key', 'incorrect api key', 'authentication',
'unauthorized', 'access denied', '401', '403',
'authenticationerror', 'permissiondenied', 'invalid api key',
'missing api key',
)
def classify_provider_error(exc):
"""Map any provider exception to one of: ``quota|auth|network|other``."""
msg = (str(exc) or '').lower()
name = type(exc).__name__.lower()
blob = msg + ' ' + name
if any(t in blob for t in _QUOTA_TOKENS):
return 'quota'
if any(t in blob for t in _AUTH_TOKENS):
return 'auth'
if 'timeout' in blob or 'connection' in blob or 'network' in msg:
return 'network'
return 'other'
def should_fallback(exc):
"""Whether the caller should try the next provider in the chain."""
return classify_provider_error(exc) in ('quota', 'auth', 'network')

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,7 @@
from . import ai_generation_log
from . import ai_ielts_generation_log
from . import course_plan
from . import course_plan_source
from . import course_plan_media
from . import course_plan_assignment
from . import workbook_attempt

View File

@@ -0,0 +1,426 @@
"""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'),
('interactive_workbook', 'Interactive Workbook'),
('other', 'Other'),
]
# Phase 1 (2026-04-29): teachers asked to be able to drop a quiz, a
# test, a supplementary resource, or a graded assignment into any
# week of the delivery plan. ``material_type`` keeps describing *what
# kind of teaching content this is* (reading text, listening script,
# …); the new ``kind`` field describes *how the student interacts
# with it* — read, take a test, attempt a quiz, submit work — and is
# what the weekly-plan UI surfaces as a top-level filter.
MATERIAL_KIND_SELECTION = [
('content', 'Reading / Lesson'),
('quiz', 'Quiz'),
('test', 'Graded Test'),
('resource', 'Supplementary Resource'),
('assignment', 'Assignment'),
]
class CoursePlan(models.Model):
_name = 'encoach.course.plan'
_description = 'AI-generated Course Plan'
_order = 'create_date desc, id desc'
name = fields.Char(required=True)
entity_id = fields.Many2one(
'encoach.entity',
string='Entity',
ondelete='set null',
index=True,
help='Owning entity/organization for LMS isolation.',
)
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')
# Same flag as op.course.is_mandatory so the merged "My Learning"
# page can group both kinds of records under the same two
# sections (Accredited Core vs Supplementary).
is_mandatory = fields.Boolean(
string='Mandatory',
default=False,
index=True,
help='Accredited core plan (mandatory). When True the plan is '
'grouped under "Accredited Core" on the student learning '
'page and tagged as Mandatory.',
)
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',
)
source_ids = fields.One2many(
'encoach.course.plan.source', 'plan_id', string='Reference sources',
)
media_ids = fields.One2many(
'encoach.course.plan.media', 'plan_id', string='Generated media',
)
assignment_ids = fields.One2many(
'encoach.course.plan.assignment', 'plan_id', string='Assignments',
)
week_count = fields.Integer(compute='_compute_counts', store=False)
material_count = fields.Integer(compute='_compute_counts', store=False)
source_count = fields.Integer(compute='_compute_counts', store=False)
media_count = fields.Integer(compute='_compute_counts', store=False)
assignment_count = fields.Integer(compute='_compute_counts', store=False)
@api.depends('week_ids', 'material_ids', 'source_ids', 'media_ids', 'assignment_ids')
def _compute_counts(self):
for rec in self:
rec.week_count = len(rec.week_ids)
rec.material_count = len(rec.material_ids)
rec.source_count = len(rec.source_ids)
rec.media_count = len(rec.media_ids)
rec.assignment_count = len(rec.assignment_ids)
@api.model_create_multi
def create(self, vals_list):
user = self.env.user.sudo()
default_entity = user.entity_ids[:1].id if hasattr(user, 'entity_ids') else False
for vals in vals_list:
if vals.get('entity_id') is None and default_entity:
vals['entity_id'] = default_entity
return super().create(vals_list)
# ------------------------------------------------------------------
# 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,
include_sources=False, include_media=False,
include_assignments=False):
self.ensure_one()
data = {
'id': self.id,
'name': self.name,
'entity_id': self.entity_id.id if self.entity_id else None,
'entity_name': self.entity_id.name if self.entity_id else '',
'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 '',
'is_mandatory': bool(self.is_mandatory),
'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),
'source_count': len(self.source_ids),
'media_count': len(self.media_ids),
'assignment_count': len(self.assignment_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]
if include_sources:
data['sources'] = [s.to_api_dict() for s in self.source_ids]
if include_media:
data['media'] = [m.to_api_dict() for m in self.media_ids]
if include_assignments:
data['assignments'] = [a.to_api_dict() for a in self.assignment_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',
)
# Phase 1 (2026-04-29): top-level interaction kind. Reading-style
# materials default to ``content``; teachers can flip a row to
# ``quiz`` / ``test`` / ``resource`` / ``assignment`` from the
# weekly plan editor and link the appropriate ``exam_template_id``
# / ``resource_id`` / ``assignment_id`` on the same row.
kind = fields.Selection(
MATERIAL_KIND_SELECTION,
required=True,
default='content',
index=True,
help='How the student interacts with this material: read it, '
'take a graded test, attempt a quiz, download a resource, '
'or submit an assignment.',
)
exam_template_id = fields.Many2one(
'encoach.exam.template',
string='Linked exam',
ondelete='set null',
help='Used when kind in (quiz, test). Points at the exam '
'template the student should take from this row.',
)
resource_id = fields.Many2one(
'encoach.resource',
string='Linked resource',
ondelete='set null',
help='Used when kind = resource. Points at a library resource '
'(PDF, video, link) the student should consult.',
)
is_graded = fields.Boolean(
string='Graded',
default=False,
help='When enabled, the row counts toward course grades. '
'Auto-set to True for kind=test, optional for assignment.',
)
due_date = fields.Date(
string='Due date',
help='Optional deadline. Surfaced on the student weekly view.',
)
title = fields.Char(required=True)
is_static = fields.Boolean(
default=False,
help='When enabled, regenerate-week keeps this material untouched.',
)
share_date = fields.Date(
help='Optional date when the material becomes visible/shared.',
)
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.',
)
grounded_on_json = fields.Text(
help='JSON list of source citations used to ground this material via '
'RAG: [{source_id, title, chunks_used}]. Surfaced in the UI as '
'a "Grounded on N references" badge.',
)
extracted_from_json = fields.Text(
help='JSON {source_id, page_hint, …} when the material was mined '
'from an indexed PDF/DOCX by ExerciseExtractor. Distinct from '
'grounded_on_json which records *retrieval* rather than '
'*extraction* provenance.',
)
media_ids = fields.One2many(
'encoach.course.plan.media', 'material_id', string='Generated media',
)
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_media=True):
self.ensure_one()
grounded = self._loads(self.grounded_on_json, [])
extracted = self._loads(self.extracted_from_json, None)
out = {
'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',
'kind': self.kind or 'content',
'is_graded': bool(self.is_graded),
'due_date': self.due_date.isoformat() if self.due_date else None,
'exam_template_id': self.exam_template_id.id if self.exam_template_id else None,
'exam_template_name': self.exam_template_id.name if self.exam_template_id else None,
'resource_id': self.resource_id.id if self.resource_id else None,
'resource_name': self.resource_id.name if self.resource_id else None,
'title': self.title or '',
'is_static': bool(self.is_static),
'share_date': self.share_date.isoformat() if self.share_date else None,
'summary': self.summary or '',
'body': self._loads(self.body_json, {}),
'body_text': self.body_text or '',
'grounded_on': grounded if isinstance(grounded, list) else [],
'extracted_from': extracted if isinstance(extracted, dict) else None,
}
if include_media:
out['media'] = [m.to_api_dict() for m in self.media_ids]
return out

View File

@@ -0,0 +1,150 @@
"""Course-plan assignments: link a generated plan to a class or students.
Two flavours of assignment:
* ``mode='batch'`` — all current and future students of an ``op.batch``
see the plan in their student dashboard.
* ``mode='students'`` — only the chosen ``res.users`` (linked through
the standard ``op.student`` portal user) see it.
The assignment is the visibility unit; it does NOT mutate
enrollment, grades, or schedules. Teachers can attach a due date and a
short message that appears on the student card.
"""
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
ASSIGNMENT_MODE_SELECTION = [
('batch', 'Class / Batch'),
('students', 'Specific students'),
('entities', 'Entities'),
]
ASSIGNMENT_STATE_SELECTION = [
('active', 'Active'),
('archived', 'Archived'),
]
class CoursePlanAssignment(models.Model):
_name = 'encoach.course.plan.assignment'
_description = 'Course Plan Assignment'
_order = 'create_date desc, id desc'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
mode = fields.Selection(ASSIGNMENT_MODE_SELECTION, required=True, default='batch')
batch_id = fields.Many2one(
'op.batch', ondelete='set null', string='Class / batch',
)
student_user_ids = fields.Many2many(
'res.users', 'course_plan_assignment_student_rel',
'assignment_id', 'user_id', string='Specific students',
)
entity_ids = fields.Many2many(
'encoach.entity', 'course_plan_assignment_entity_rel',
'assignment_id', 'entity_id', string='Entities',
)
assigned_by_id = fields.Many2one(
'res.users', default=lambda self: self.env.user, string='Assigned by',
)
due_date = fields.Date()
message = fields.Text(help='Optional note shown to the student.')
state = fields.Selection(ASSIGNMENT_STATE_SELECTION, default='active')
student_count = fields.Integer(compute='_compute_student_count', store=False)
@api.depends('mode', 'batch_id', 'student_user_ids', 'entity_ids')
def _compute_student_count(self):
Enroll = self.env['op.student.course'].sudo()
Batch = self.env['op.batch'].sudo()
for rec in self:
if rec.mode == 'students':
rec.student_count = len(rec.student_user_ids)
continue
if rec.mode == 'entities':
user_ids = set()
for entity in rec.entity_ids:
for user in entity.user_ids:
if user and user.id:
user_ids.add(user.id)
rec.student_count = len(user_ids)
continue
if not rec.batch_id:
rec.student_count = 0
continue
try:
course_id = Batch.browse(rec.batch_id.id).course_id.id
if course_id:
rec.student_count = Enroll.search_count([
('course_id', '=', course_id),
('batch_id', '=', rec.batch_id.id),
])
else:
rec.student_count = 0
except Exception:
rec.student_count = 0
def expand_user_ids(self):
"""Return the ``res.users`` ids that should see this plan."""
self.ensure_one()
if self.mode == 'students':
return self.student_user_ids.ids
if self.mode == 'entities':
user_ids = []
for entity in self.entity_ids:
for user in entity.user_ids:
if user and user.id:
user_ids.append(user.id)
return list(set(user_ids))
if self.mode == 'batch' and self.batch_id:
try:
Enroll = self.env['op.student.course'].sudo()
course_id = self.batch_id.course_id.id
rows = Enroll.search([
('course_id', '=', course_id),
('batch_id', '=', self.batch_id.id),
])
user_ids = []
for row in rows:
student = getattr(row, 'student_id', None)
if not student:
continue
user = getattr(student, 'user_id', None)
if user and user.id:
user_ids.append(user.id)
return list(set(user_ids))
except Exception:
_logger.exception('expand_user_ids failed for assignment %s', self.id)
return []
return []
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'plan_id': self.plan_id.id,
'plan_name': self.plan_id.name or '',
'mode': self.mode or 'batch',
'batch_id': self.batch_id.id if self.batch_id else None,
'batch_name': self.batch_id.name if self.batch_id else '',
'student_user_ids': self.student_user_ids.ids,
'student_user_names': [u.name for u in self.student_user_ids],
'entity_ids': self.entity_ids.ids,
'entity_names': [e.name for e in self.entity_ids],
'student_count': self.student_count or 0,
'assigned_by_id': self.assigned_by_id.id if self.assigned_by_id else None,
'assigned_by_name': self.assigned_by_id.name if self.assigned_by_id else '',
'due_date': self.due_date.isoformat() if self.due_date else None,
'message': self.message or '',
'state': self.state or 'active',
'created_at': self.create_date.isoformat() if self.create_date else None,
}

View File

@@ -0,0 +1,157 @@
"""Multimedia training assets generated for a course-plan material.
Each material (a reading text, listening script, vocab list, etc.) can
have one or more linked media rows: an MP3 narration of a listening
script, a DALL-E illustration for a vocab card, an MP4 slideshow video
combining the two, and so on. Bytes live on ``ir.attachment`` so the
existing filestore + access controls + URL serving (``/web/content``)
all work for free.
Media generation is triggered explicitly through REST endpoints — we
never generate inline during plan creation because each call has a
non-trivial latency and cost.
"""
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
MEDIA_KIND_SELECTION = [
('audio', 'Audio'),
('image', 'Image'),
('video', 'Video'),
]
MEDIA_PROVIDER_SELECTION = [
# ── Paid providers ──
('polly', 'AWS Polly'),
('elevenlabs', 'ElevenLabs'),
('openai_image', 'OpenAI (DALL-E)'),
('elai', 'Elai.io'),
# ── Free fallbacks (Phase 24.1) ──
('pillow', 'Pillow placeholder (offline)'),
('unsplash', 'Unsplash Source (free)'),
('gtts', 'gTTS (free TTS)'),
('silent', 'Silent stub'),
('static', 'Static image as video'),
('mock', 'Mock'),
# ── Composers / manual ──
('ffmpeg', 'ffmpeg (slideshow)'),
('manual', 'Manual upload'),
# Sentinel value used while the chain is still resolving — written
# transiently by MediaService.create() and overwritten with the
# successful provider name once a fallback step succeeds.
('auto', 'Auto (fallback chain)'),
]
MEDIA_STATUS_SELECTION = [
('queued', 'Queued'),
('generating', 'Generating'),
('ready', 'Ready'),
('failed', 'Failed'),
]
class CoursePlanMedia(models.Model):
_name = 'encoach.course.plan.media'
_description = 'Course Plan Multimedia Asset'
_order = 'kind, id desc'
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,
)
material_id = fields.Many2one(
'encoach.course.plan.material', ondelete='cascade', index=True,
)
kind = fields.Selection(MEDIA_KIND_SELECTION, required=True)
provider = fields.Selection(MEDIA_PROVIDER_SELECTION, required=True)
title = fields.Char()
source_text = fields.Text(
help='The text fed to the generator (TTS script, image prompt, etc.).',
)
voice = fields.Char()
language = fields.Char(default='en-GB')
style = fields.Char(help='DALL-E style or video template label.')
attachment_id = fields.Many2one(
'ir.attachment', ondelete='set null', string='Binary',
)
mime_type = fields.Char()
size_bytes = fields.Integer()
duration_seconds = fields.Float()
width = fields.Integer()
height = fields.Integer()
download_url = fields.Char(
compute='_compute_media_urls', store=False,
help='Authenticated REST URL that serves the binary as an attachment '
'(``Content-Disposition: attachment``). Frontend appends '
'``?token=<jwt>`` for download buttons.',
)
preview_url = fields.Char(
compute='_compute_media_urls', store=False,
help='Authenticated REST URL that serves the binary inline so it can '
'be used as the ``src`` for ``<img>`` / ``<audio>`` / ``<video>`` '
'elements. Frontend appends ``?token=<jwt>``.',
)
status = fields.Selection(MEDIA_STATUS_SELECTION, default='queued')
error = fields.Char()
cost_cents = fields.Integer(
default=0,
help='Approximate provider cost in USD cents at generation time, '
'best-effort accounting for budget caps.',
)
@api.depends('attachment_id')
def _compute_media_urls(self):
# Both URLs hit the same JWT-protected streaming endpoint exposed by
# ``encoach_ai_course.controllers.course_plan.CoursePlanController.
# stream_media``. The endpoint accepts the JWT either as a Bearer
# header (REST clients) or as a ``?token=`` query param so plain
# ``<img>`` / ``<audio>`` / ``<video>`` tags can render the asset
# without us proxying every request through fetch + blob URLs.
# ``download=1`` only changes the Content-Disposition: attachment
# header so the same route serves both inline previews and explicit
# downloads.
for rec in self:
if not rec.id:
rec.download_url = ''
rec.preview_url = ''
continue
base = f'/api/ai/course-plan/media/{rec.id}/raw'
rec.preview_url = base
rec.download_url = f'{base}?download=1'
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,
'material_id': self.material_id.id if self.material_id else None,
'kind': self.kind or '',
'provider': self.provider or '',
'title': self.title or '',
'voice': self.voice or '',
'language': self.language or '',
'style': self.style or '',
'mime_type': self.mime_type or '',
'size_bytes': self.size_bytes or 0,
'duration_seconds': self.duration_seconds or 0.0,
'width': self.width or 0,
'height': self.height or 0,
'attachment_id': self.attachment_id.id if self.attachment_id else None,
'download_url': self.download_url,
'preview_url': self.preview_url,
'status': self.status or 'queued',
'error': self.error or '',
'cost_cents': self.cost_cents or 0,
'created_at': self.create_date.isoformat() if self.create_date else None,
}

View File

@@ -0,0 +1,182 @@
"""Reference source attached to a course plan.
Each source is one of: an uploaded file (PDF, DOCX, TXT), a URL the
agent should crawl, or a chunk of inline text. Indexing extracts the
text, chunks it, and pushes the chunks into ``encoach.embedding`` so
the LangGraph ``course_planner`` and ``course_week_materials`` agents
can ground their generation on these sources via the existing
``resources.search`` tool, scoped to the plan.
We deliberately use the existing pgvector store rather than a new one:
that way the same retrieval pipeline (embedding model, chunker, cosine
similarity ranker) is shared, and admins can look up "what did the AI
read" with the same dashboards.
"""
import base64
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
SOURCE_KIND_SELECTION = [
('file', 'File'),
('url', 'URL'),
('text', 'Inline text'),
# ``resource`` is a soft-link to the central ``encoach.resource``
# library so the admin can re-use a PDF / DOCX / link they already
# uploaded under /admin/resources without re-uploading the binary.
# The indexer dereferences the link at extraction time; the binary
# itself stays in the library record so a single edit / rotation
# propagates to every plan that grounds on it.
('resource', 'Library resource'),
]
SOURCE_STATUS_SELECTION = [
('pending', 'Pending'),
('indexing', 'Indexing'),
('indexed', 'Indexed'),
('failed', 'Failed'),
]
class CoursePlanSource(models.Model):
_name = 'encoach.course.plan.source'
_description = 'Course Plan Reference Source'
_order = 'sequence asc, id asc'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
sequence = fields.Integer(default=10)
name = fields.Char(required=True, help='Human-readable label.')
kind = fields.Selection(SOURCE_KIND_SELECTION, required=True, default='file')
# Filled when ``kind == 'file'`` — base64 payload mirrors how
# encoach.resource and ir.attachment already do it.
file = fields.Binary(string='Uploaded file', attachment=True)
file_name = fields.Char(string='File name')
mime_type = fields.Char(string='MIME type')
url = fields.Char(string='Source URL')
inline_text = fields.Text(string='Inline text')
# Optional pointer to the central /admin/resources library. When set
# the indexer pulls the binary / URL / inline text from the linked
# ``encoach.resource`` instead of re-storing it on this row, which
# avoids duplicating large PDFs across every plan that grounds on
# the same library item.
resource_id = fields.Many2one(
'encoach.resource',
string='Library resource',
ondelete='set null',
help='Link to a resource already uploaded under /admin/resources. '
'The indexer reads the binary from the library at extraction '
'time so updates to the library propagate to every plan.',
)
auto_index = fields.Boolean(
default=True,
help='If true, indexing runs automatically on create. '
'Disable to upload first, review, then index manually.',
)
status = fields.Selection(SOURCE_STATUS_SELECTION, default='pending')
error = fields.Char()
chunks_count = fields.Integer(default=0, string='Chunks')
extracted_chars = fields.Integer(default=0, string='Extracted chars')
indexed_at = fields.Datetime()
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec in records:
if rec.auto_index:
try:
rec.action_index()
except Exception as exc:
# If indexing crashes before SourceIndexer has a chance
# to mark the row as ``failed`` (e.g. an unexpected
# import or DB error) we MUST still set the status to
# ``failed``; otherwise the source sits in ``pending``
# forever and the deliverables UI can't show progress.
_logger.exception('Auto-index failed for source %s', rec.id)
try:
rec.write({
'status': 'failed',
'error': str(exc)[:500],
})
except Exception:
pass
return records
def action_index(self):
"""(Re-)extract text and push chunks to the vector store.
Wraps each per-record indexing call so a failure on one source
doesn't abort the loop and leave later records orphaned.
"""
from odoo.addons.encoach_ai_course.services.source_indexer import (
SourceIndexer,
)
indexer = SourceIndexer(self.env)
for rec in self:
try:
indexer.index(rec)
except Exception as exc:
_logger.exception('Index failed for source %s', rec.id)
try:
rec.write({
'status': 'failed',
'error': str(exc)[:500],
})
except Exception:
pass
return True
def unlink(self):
try:
from odoo.addons.encoach_vector.services.embedding_service import (
EmbeddingService,
)
svc = EmbeddingService(self.env)
for rec in self:
svc.delete('course_plan_source', rec.id)
except Exception:
_logger.exception('Failed to delete embeddings for sources')
return super().unlink()
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'plan_id': self.plan_id.id,
'name': self.name or '',
'kind': self.kind or 'file',
'file_name': self.file_name or '',
'mime_type': self.mime_type or '',
'url': self.url or '',
'has_inline_text': bool(self.inline_text),
'resource_id': self.resource_id.id if self.resource_id else None,
'resource_name': self.resource_id.name if self.resource_id else '',
'resource_type': (
self.resource_id.type if self.resource_id else ''
),
'status': self.status or 'pending',
'error': self.error or '',
'chunks_count': self.chunks_count or 0,
'extracted_chars': self.extracted_chars or 0,
'indexed_at': self.indexed_at.isoformat() if self.indexed_at else None,
'created_at': self.create_date.isoformat() if self.create_date else None,
}
def get_decoded_file(self):
"""Return raw bytes of the uploaded file, or ``b''`` if none."""
self.ensure_one()
if not self.file:
return b''
try:
return base64.b64decode(self.file)
except Exception:
return b''

View File

@@ -0,0 +1,337 @@
"""Per-student workbook attempt — server-side scoring + persistence.
A student opens an ``interactive_workbook`` material in
``InteractiveWorkbook.tsx``, types/picks/drags answers, and either
clicks "Check answers" (debounced save) or "Submit final" (final save).
Each save creates or updates one ``encoach.course.plan.workbook.attempt``
row keyed by ``(material_id, student_id, attempt_number)``.
The score is always recomputed server-side from the persisted answers
so a tampered client cannot inflate the score. The grading function
handles each of the six exercise types defined in the plan schema:
* ``gap_fill`` — case-insensitive equality, ``alt`` list match.
* ``multiple_choice`` — exact match against ``answer`` (string or letter).
* ``match_pairs`` — set-equality of pair tuples.
* ``reorder_words`` — token-by-token equality (whitespace-tolerant).
* ``transformation`` — equality with normalised punctuation, ``alt``
fallback, capitalisation tolerated.
* ``short_answer`` — glob-style ``accepted`` template (``*`` matches
any phrase) plus exact ``answer`` fallback.
Entity isolation
----------------
Every attempt carries the same ``entity_id`` as the parent plan. A SQL
constraint mirrors the LMS isolation pattern: a student in entity A
cannot create an attempt against a material owned by entity B.
"""
from __future__ import annotations
import json
import logging
import re
from odoo import api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------
# Scoring helpers — pure functions so the unit tests can hit them
# directly without spinning up an ORM.
# ----------------------------------------------------------------------
def _norm_text(s) -> str:
if s is None:
return ''
return re.sub(r'\s+', ' ', str(s)).strip().lower()
def _norm_punct(s) -> str:
"""Normalise final punctuation/quotes for transformation checks."""
return re.sub(r'[\s\.\?\!,]+$', '', _norm_text(s))
def _glob_match(pattern: str, value: str) -> bool:
"""Tiny ``*``-only glob matcher used by short-answer templates."""
pat = _norm_text(pattern)
val = _norm_text(value)
if not pat:
return False
if '*' not in pat:
return pat == val
rx = '^' + re.escape(pat).replace(r'\*', r'.*') + '$'
return bool(re.match(rx, val))
def _pairs_equal(answer, given) -> bool:
"""Set-equality check for match_pairs answers."""
def _to_set(p):
out = set()
for it in (p or []):
if isinstance(it, (list, tuple)) and len(it) == 2:
try:
out.add((int(it[0]), int(it[1])))
except (TypeError, ValueError):
return None
return out
a = _to_set(answer)
g = _to_set(given)
if a is None or g is None:
return False
return a == g
def _check_one(ex: dict, given) -> bool:
"""Return True iff ``given`` is a correct answer for ``ex``."""
t = (ex.get('type') or '').lower()
if t == 'gap_fill':
if given is None:
return False
accepted = [ex.get('answer')] + list(ex.get('alt') or [])
n = _norm_text(given)
return any(_norm_text(a) == n for a in accepted if a is not None)
if t == 'multiple_choice':
opts = ex.get('options') or []
ans = ex.get('answer')
# The "answer" in the schema can be the literal option string
# ("Paris") OR a single letter ("A","B",…) — accept either.
if ans is None:
return False
if isinstance(given, str):
n = _norm_text(given)
if n == _norm_text(ans):
return True
# Letter-mode: convert "A"/"B" to index → option string.
if isinstance(ans, str) and len(ans) == 1 and ans.isalpha():
idx = ord(ans.upper()) - ord('A')
if 0 <= idx < len(opts) and _norm_text(opts[idx]) == n:
return True
if isinstance(given, str) and len(given) == 1 and given.isalpha():
idx = ord(given.upper()) - ord('A')
if 0 <= idx < len(opts) and _norm_text(opts[idx]) == _norm_text(ans):
return True
return False
if t == 'match_pairs':
return _pairs_equal(ex.get('answer'), given)
if t == 'reorder_words':
if given is None:
return False
# Accept either a list of tokens or a joined string.
if isinstance(given, list):
given_str = ' '.join(str(t) for t in given)
else:
given_str = str(given)
return _norm_text(given_str) == _norm_text(ex.get('answer'))
if t == 'transformation':
if given is None:
return False
accepted = [ex.get('answer')] + list(ex.get('alt') or [])
n = _norm_punct(given)
return any(_norm_punct(a) == n for a in accepted if a is not None)
if t == 'short_answer':
if given is None or str(given).strip() == '':
return False
accepted = list(ex.get('accepted') or [])
if ex.get('answer'):
accepted.append(str(ex['answer']))
return any(_glob_match(p, given) for p in accepted)
# Unknown / un-checkable type — never auto-mark correct.
return False
def score_exercises(exercises: list[dict], answers: dict) -> dict:
"""Grade ``answers`` against ``exercises``. Pure / safe / deterministic."""
items: list[dict] = []
correct = 0
for ex in exercises or []:
eid = str(ex.get('id') or '')
if not eid:
continue
given = answers.get(eid) if isinstance(answers, dict) else None
ok = _check_one(ex, given)
if ok:
correct += 1
items.append({
'id': eid,
'correct': bool(ok),
'expected': ex.get('answer'),
'given': given,
})
total = len(items)
return {
'items': items,
'correct': correct,
'total': total,
'percent': round((correct / total) * 100, 1) if total else 0.0,
}
# ----------------------------------------------------------------------
# Odoo model
# ----------------------------------------------------------------------
class CoursePlanWorkbookAttempt(models.Model):
_name = 'encoach.course.plan.workbook.attempt'
_description = 'Course-plan interactive workbook attempt'
_order = 'submitted_at desc, id desc'
_rec_name = 'display_name'
material_id = fields.Many2one(
'encoach.course.plan.material',
required=True, ondelete='cascade', index=True,
)
plan_id = fields.Many2one(
related='material_id.plan_id', store=True, index=True,
)
student_id = fields.Many2one(
'res.users', required=True, ondelete='cascade', index=True,
string='Student user',
)
entity_id = fields.Many2one(
'encoach.entity', index=True, string='Entity',
help='Mirrors the parent plan\'s entity for LMS isolation.',
)
answers_json = fields.Text(
default='{}',
help='Per-exercise dict keyed by exercise ID '
'— shape mirrors the schema output by InteractiveWorkbook.tsx.',
)
score_json = fields.Text(
default='{}',
help='Server-side grading output: '
'{items: [{id, correct, expected, given}], correct, total, percent}.',
)
correct_count = fields.Integer(default=0, string='Correct')
total_count = fields.Integer(default=0, string='Total')
percent = fields.Float(default=0.0, digits=(5, 1))
submitted_at = fields.Datetime(
help='Set when the student clicks "Submit final". '
'Until then the row is editable on every "Check answers".',
)
last_updated_at = fields.Datetime(default=fields.Datetime.now)
attempt_number = fields.Integer(default=1)
is_final = fields.Boolean(default=False)
display_name = fields.Char(compute='_compute_display_name', store=False)
_sql_constraints = [
(
'workbook_attempt_unique',
'unique(material_id, student_id, attempt_number)',
'Only one workbook attempt per (material, student, attempt#).',
),
]
# ------------------------------------------------------------------
@api.depends('material_id', 'student_id', 'attempt_number')
def _compute_display_name(self):
for rec in self:
mat = rec.material_id.title or 'Workbook'
who = rec.student_id.name or rec.student_id.login or '?'
rec.display_name = f'{mat}{who} (attempt {rec.attempt_number})'
# ------------------------------------------------------------------
@api.constrains('material_id', 'entity_id')
def _check_entity_isolation(self):
for rec in self:
plan = rec.material_id.plan_id
plan_entity = plan.entity_id.id if (plan and plan.entity_id) else False
if rec.entity_id and plan_entity and rec.entity_id.id != plan_entity:
raise ValidationError(
'Workbook attempt entity must match its plan\'s entity.'
)
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
mat = self.env['encoach.course.plan.material'].sudo().browse(
int(vals.get('material_id') or 0),
)
if mat and mat.exists() and not vals.get('entity_id'):
vals['entity_id'] = (
mat.plan_id.entity_id.id if mat.plan_id and mat.plan_id.entity_id
else False
)
return super().create(vals_list)
# ------------------------------------------------------------------
@staticmethod
def _exercises_from_material(material) -> list[dict]:
try:
body = json.loads(material.body_json or '{}')
except (TypeError, ValueError):
return []
ex = body.get('exercises')
if isinstance(ex, list):
return ex
# Some skill bodies (grammar / vocabulary) embed the workbook
# one level deeper. Search a couple of known keys before giving up.
nested = (
(body.get('interactive_workbook') or {}).get('exercises')
if isinstance(body.get('interactive_workbook'), dict) else None
)
if isinstance(nested, list):
return nested
return []
# ------------------------------------------------------------------
def grade(self, answers: dict, *, finalize: bool = False) -> dict:
"""Re-grade ``answers`` server-side and persist on this row."""
self.ensure_one()
exercises = self._exercises_from_material(self.material_id)
score = score_exercises(exercises, answers or {})
vals = {
'answers_json': json.dumps(answers or {}, ensure_ascii=False),
'score_json': json.dumps(score, ensure_ascii=False),
'correct_count': score['correct'],
'total_count': score['total'],
'percent': score['percent'],
'last_updated_at': fields.Datetime.now(),
}
if finalize:
vals['submitted_at'] = fields.Datetime.now()
vals['is_final'] = True
self.write(vals)
return score
# ------------------------------------------------------------------
def to_api_dict(self) -> dict:
self.ensure_one()
try:
answers = json.loads(self.answers_json or '{}')
except (TypeError, ValueError):
answers = {}
try:
score = json.loads(self.score_json or '{}')
except (TypeError, ValueError):
score = {}
return {
'id': self.id,
'material_id': self.material_id.id,
'plan_id': self.plan_id.id if self.plan_id else None,
'student_id': self.student_id.id,
'student_name': self.student_id.name or self.student_id.login or '',
'attempt_number': self.attempt_number,
'is_final': bool(self.is_final),
'submitted_at': (
self.submitted_at.isoformat() if self.submitted_at else None
),
'last_updated_at': (
self.last_updated_at.isoformat() if self.last_updated_at else None
),
'correct_count': self.correct_count,
'total_count': self.total_count,
'percent': self.percent,
'answers': answers,
'score': score,
}

View File

@@ -1,3 +1,10 @@
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
access_encoach_course_plan_source_user,encoach.course.plan.source.user,model_encoach_course_plan_source,base.group_user,1,1,1,1
access_encoach_course_plan_media_user,encoach.course.plan.media.user,model_encoach_course_plan_media,base.group_user,1,1,1,1
access_encoach_course_plan_assignment_user,encoach.course.plan.assignment.user,model_encoach_course_plan_assignment,base.group_user,1,1,1,1
access_encoach_course_plan_workbook_attempt_user,encoach.course.plan.workbook.attempt.user,model_encoach_course_plan_workbook_attempt,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
7 access_encoach_course_plan_source_user encoach.course.plan.source.user model_encoach_course_plan_source base.group_user 1 1 1 1
8 access_encoach_course_plan_media_user encoach.course.plan.media.user model_encoach_course_plan_media base.group_user 1 1 1 1
9 access_encoach_course_plan_assignment_user encoach.course.plan.assignment.user model_encoach_course_plan_assignment base.group_user 1 1 1 1
10 access_encoach_course_plan_workbook_attempt_user encoach.course.plan.workbook.attempt.user model_encoach_course_plan_workbook_attempt base.group_user 1 1 1 1

View File

@@ -1,2 +1,8 @@
from .english_pipeline import EnglishPipeline
from .ielts_pipeline import IeltsPipeline
from .course_plan_pipeline import CoursePlanPipeline
from .source_indexer import SourceIndexer
from .rag_context import RAGContextBuilder
from .exercise_extractor import ExerciseExtractor
from .media_service import MediaService
from .deliverables import compute_deliverables

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,158 @@
"""Compute the deliverables list and progress for a course plan.
A deliverable is a single concrete artefact the AI should eventually
produce. Each week's planned skills (from ``items_json``) maps to a
deliverable, and each generated text material can in turn produce
multimedia children:
* listening_script -> 1 audio narration + 1 illustration + 1 video
* reading_text -> 1 hero illustration
* speaking_prompt -> 1 model-answer audio
* vocabulary_list -> 1 image per term (capped at 8 per list)
The frontend uses this to draw the Deliverables Preview before
generation and the progress strip on the detail page after.
"""
from __future__ import annotations
import json
SKILL_TO_MATERIAL_TYPE = {
'reading': 'reading_text',
'writing': 'writing_prompt',
'listening': 'listening_script',
'speaking': 'speaking_prompt',
'grammar': 'grammar_lesson',
'vocabulary': 'vocabulary_list',
}
MATERIAL_MEDIA_PLAN = {
'listening_script': [
{'kind': 'audio', 'mandatory': True, 'note': 'Narrated MP3 of the script'},
{'kind': 'image', 'mandatory': False, 'note': 'Illustration for the listening lesson'},
{'kind': 'video', 'mandatory': False, 'note': 'Slide-style MP4 with audio'},
],
'reading_text': [
{'kind': 'image', 'mandatory': False, 'note': 'Hero illustration for the passage'},
],
'speaking_prompt': [
{'kind': 'audio', 'mandatory': False, 'note': 'Model-answer narration'},
],
'vocabulary_list': [
{'kind': 'image', 'mandatory': False, 'note': 'Flashcard image (one per term, capped)'},
],
}
VOCAB_IMAGE_CAP = 8
def _safe_loads(raw, default):
if not raw:
return default
try:
return json.loads(raw)
except (TypeError, ValueError):
return default
def compute_deliverables(plan):
"""Return ``{summary, weeks: [...]}`` describing what the plan owes.
Status logic per deliverable:
* ``planned`` — no material row yet for that {week, material_type}.
* ``generated``— material row exists but no media, or media not
applicable for the type.
* ``ready`` — material has at least one ``ready`` media child for
every mandatory-or-cap-of-1 modality and one ``ready`` media for
vocab cap (we don't gate on every term).
The frontend only needs the counts, but per-week breakdown is also
returned so it can render a checklist.
"""
weeks_payload = []
materials_by_week = {}
for m in plan.material_ids:
materials_by_week.setdefault(m.week_id.id if m.week_id else 0, []).append(m)
counts = {'planned': 0, 'generated': 0, 'ready': 0}
media_counts = {'audio': 0, 'image': 0, 'video': 0}
media_ready_counts = {'audio': 0, 'image': 0, 'video': 0}
for week in plan.week_ids.sorted('week_number'):
items = _safe_loads(week.items_json, [])
ws_materials = materials_by_week.get(week.id, [])
materials_by_type = {m.material_type: m for m in ws_materials}
deliverables = []
for item in items:
skill = (item.get('skill') or '').lower()
mtype = SKILL_TO_MATERIAL_TYPE.get(skill)
if not mtype:
continue
material = materials_by_type.get(mtype)
entry = {
'skill': skill,
'material_type': mtype,
'material_id': material.id if material else None,
'title': material.title if material else '',
'media': [],
'status': 'planned',
}
if material:
entry['status'] = 'generated'
planned_media = MATERIAL_MEDIA_PLAN.get(mtype, [])
ready_for_each_kind = {p['kind']: False for p in planned_media}
for child in material.media_ids:
media_counts[child.kind] = media_counts.get(child.kind, 0) + 1
if child.status == 'ready':
media_ready_counts[child.kind] = (
media_ready_counts.get(child.kind, 0) + 1
)
if child.kind in ready_for_each_kind:
ready_for_each_kind[child.kind] = True
entry['media'].append({
'id': child.id, 'kind': child.kind,
'status': child.status, 'provider': child.provider,
})
if planned_media and all(
ready_for_each_kind.get(p['kind'], False)
for p in planned_media if p.get('mandatory')
):
entry['status'] = 'ready'
elif not planned_media:
entry['status'] = 'ready'
counts[entry['status']] += 1
deliverables.append(entry)
weeks_payload.append({
'week_number': week.week_number,
'date_label': week.date_label or '',
'unit': week.unit or '',
'focus': week.focus or '',
'items_total': len(items),
'deliverables': deliverables,
})
total = sum(counts.values())
percent = round((counts['ready'] / total) * 100, 1) if total else 0.0
return {
'summary': {
'total': total,
'planned': counts['planned'],
'generated': counts['generated'],
'ready': counts['ready'],
'percent_ready': percent,
'media': {
'audio': media_counts['audio'],
'image': media_counts['image'],
'video': media_counts['video'],
'audio_ready': media_ready_counts['audio'],
'image_ready': media_ready_counts['image'],
'video_ready': media_ready_counts['video'],
},
},
'weeks': weeks_payload,
}

View File

@@ -0,0 +1,247 @@
"""Parse a listening-script into per-speaker segments with gender hints.
The course-plan AI emits listening scripts in a "screenplay" form::
Host: Welcome to Travel Tales. Today we have Sarah ...
Sarah: Hi! Yes, I spent two weeks in Italy ...
Host: That sounds amazing. What was your favourite city?
Sarah: Venice was incredible.
Naively feeding that whole blob to a TTS engine reads the speaker
labels aloud ("Host colon Welcome ... Sarah colon Hi") and uses one
monotone voice for both characters. That's painful for listening
practice and breaks the very pedagogical signal a dialogue is supposed
to carry — turn-taking, contrasted voices, gendered roles.
This module extracts the labels, classifies each speaker by gender, and
returns a clean ``[{speaker, gender, text}, ...]`` list so the TTS
pipeline can:
1. Strip the labels from the rendered audio (``Host:`` is never spoken).
2. Synthesize each turn with a voice matching the speaker's gender
(Host with a male voice, Sarah with a female voice).
3. Concatenate the per-turn clips so a 4-turn dialogue plays as a real
conversation rather than a single narration.
For monologues (no ``Name:`` prefix anywhere in the script) the parser
returns one segment with ``speaker=None``, so callers can keep the
single-voice fast-path without special-casing.
"""
from __future__ import annotations
import re
from typing import Iterable
# A "speaker tag" is a 13 word capitalized name followed by a colon at
# either start-of-string or right after sentence-final punctuation. We
# anchor with a positive lookbehind so we don't match colons that appear
# inside dialogue ("She said: 'No.'") — only those that actually mark a
# turn boundary.
_SPEAKER_PREFIX = re.compile(
r'(?:^|(?<=[.!?]\s)|(?<=\n))'
r'(?P<name>[A-Z][A-Za-z\'.-]+(?:\s[A-Z][A-Za-z\'.-]+){0,2})'
r'\s*:\s*'
)
# Pragmatic name → gender lookup. Covers the names the LLM picks 95% of
# the time when generating English-language listening dialogues. Anything
# missing falls back to alternation by encounter order, which always
# produces alternating male/female voices for the typical 2-speaker
# classroom dialogue.
_FEMALE_NAMES = frozenset({
# English first names
'sarah', 'anna', 'maria', 'sophie', 'sophia', 'emma', 'lisa', 'jane',
'clare', 'claire', 'olivia', 'mia', 'emily', 'grace', 'ava', 'isabella',
'chloe', 'julia', 'laura', 'sofia', 'amy', 'rachel', 'nora', 'ella',
'katie', 'kate', 'hannah', 'lucy', 'mary', 'susan', 'jessica',
'rebecca', 'alice', 'helen', 'sandra', 'linda', 'barbara', 'nancy',
'karen', 'betty', 'diana', 'victoria', 'natalie', 'natasha', 'irina',
'rose', 'lily', 'ruby', 'molly', 'amelia', 'zoe', 'beth', 'eve',
# Arabic / regional first names common in the platform's audience
'aisha', 'fatima', 'layla', 'noor', 'zainab', 'yasmin', 'salma',
'nadia', 'mariam', 'amal', 'hala', 'huda', 'rania', 'reem',
# Generic role / kinship terms that imply female
'female', 'woman', 'girl', 'mother', 'mom', 'mum', 'wife', 'sister',
'daughter', 'aunt', 'grandmother', 'gran', 'lady', 'miss', 'mrs',
'ms', 'queen', 'princess',
})
_MALE_NAMES = frozenset({
# English first names
'john', 'david', 'michael', 'james', 'robert', 'william', 'richard',
'thomas', 'charles', 'christopher', 'daniel', 'matthew', 'andrew',
'peter', 'mark', 'paul', 'steven', 'kevin', 'brian', 'george',
'edward', 'joseph', 'sam', 'samuel', 'ben', 'benjamin', 'tom', 'tim',
'bob', 'jack', 'henry', 'oliver', 'noah', 'liam', 'elias', 'elijah',
'alex', 'alexander', 'ethan', 'lucas', 'mason', 'logan', 'caleb',
'ryan', 'nathan', 'jacob', 'frank', 'harry', 'simon', 'steve',
# Arabic / regional first names
'ahmed', 'ahmad', 'mohammed', 'mohamed', 'omar', 'ali', 'khaled',
'tarek', 'yousef', 'youssef', 'hassan', 'amir', 'rami', 'sami',
'hamza', 'ibrahim', 'mahmoud', 'karim', 'adam', 'bilal',
# Generic role / kinship terms
'male', 'man', 'boy', 'father', 'dad', 'husband', 'brother', 'son',
'uncle', 'grandfather', 'grandpa', 'sir', 'mr', 'king', 'prince',
})
# Role nouns that frequently appear as speaker labels in listening
# scripts but carry no inherent gender — we route these through the
# alternation path instead of forcing a default voice on them.
_NEUTRAL_ROLES = frozenset({
'narrator', 'speaker', 'voice', 'person', 'student', 'teacher',
'host', 'presenter', 'interviewer', 'guest', 'reporter', 'announcer',
'caller', 'customer', 'staff', 'clerk', 'agent', 'driver', 'doctor',
'patient', 'manager', 'employee', 'colleague', 'friend',
})
def _lookup_gender(name: str) -> str | None:
"""Return ``'male'`` / ``'female'`` from the static name lookup, or
``None`` if the first token isn't recognised.
"""
first = (name or '').strip().lower().split()[0] if name else ''
if first in _FEMALE_NAMES:
return 'female'
if first in _MALE_NAMES:
return 'male'
return None
def parse_dialogue(script: str) -> list[dict]:
"""Split ``script`` into per-speaker segments.
Returns a list of ``{'speaker': str | None, 'gender': str | None,
'text': str}``:
* Multi-speaker dialogue → one entry per turn, with the speaker's
name (preserved as written) and a heuristic gender label.
* Monologue (no ``Name:`` prefix) → a single segment with
``speaker=None`` and ``gender=None`` so callers can short-circuit.
* Empty / whitespace-only script → ``[]``.
The returned ``text`` never includes the speaker label — that's the
whole point: the TTS engine must not say "Host colon ..." aloud.
Gender assignment is two-pass so that unknown / role-only speakers
(Host, Interviewer, etc.) end up *contrasting* with the named
speakers in the same dialogue. Without this a "Host + Sarah"
interview would assign female to both (Sarah by lookup, Host by
default), defeating the whole point of multi-voice rendering.
"""
if not script or not script.strip():
return []
matches = list(_SPEAKER_PREFIX.finditer(script))
if not matches:
return [{'speaker': None, 'gender': None, 'text': script.strip()}]
# Build the list of (name, text) turns first so we can do a global
# gender assignment over the unique speaker set.
turns: list[tuple[str, str]] = []
head = script[: matches[0].start()].strip()
for i, m in enumerate(matches):
name = m.group('name').strip()
end = matches[i + 1].start() if i + 1 < len(matches) else len(script)
text = script[m.end():end].strip()
if text:
turns.append((name, text))
if not turns:
# Edge case: matched some "Name:" labels but every body turned
# out empty. Return the whole pre-match head as a single
# narration, falling back to the script if even that's empty.
return [{'speaker': None, 'gender': None,
'text': head or script.strip()}]
# Pass 1: collect unique speakers in order of first appearance, and
# apply the static name → gender lookup. ``None`` here means
# "we don't know yet — fill in pass 2".
unique_order: list[str] = []
speaker_gender: dict[str, str | None] = {}
for name, _ in turns:
key = name.lower()
if key not in speaker_gender:
unique_order.append(key)
speaker_gender[key] = _lookup_gender(name)
# Pass 2: resolve unknowns. The contract we want is "contrast" —
# any 2-speaker dialogue should produce one male and one female
# voice. So for each unknown speaker we count how many male vs
# female slots are *already* assigned (by lookup or by an earlier
# iteration of this pass) and pick the under-represented gender.
# Pure parity-by-index would re-introduce the Host/Sarah collision.
for key in unique_order:
if speaker_gender[key] is not None:
continue
male_count = sum(1 for v in speaker_gender.values() if v == 'male')
female_count = sum(1 for v in speaker_gender.values() if v == 'female')
if male_count < female_count:
speaker_gender[key] = 'male'
elif female_count < male_count:
speaker_gender[key] = 'female'
else:
# Truly tied (or first unknown in a no-lookup dialogue) —
# alternate based on how many unknowns we've already filled
# so two unknowns in a row get different voices.
assigned_unknowns = sum(
1 for k in unique_order
if speaker_gender[k] is not None and _lookup_gender(k) is None
)
speaker_gender[key] = (
'male' if assigned_unknowns % 2 == 0 else 'female'
)
# Pass 3: emit the segments, including any unattributed pre-match
# narration ("It was a sunny day. Anna: Hi!"). The narration takes
# whichever gender is *under-represented* among the speakers, so it
# contrasts with the dialogue voices instead of duplicating one.
segments: list[dict] = []
if head:
female = sum(1 for v in speaker_gender.values() if v == 'female')
male = sum(1 for v in speaker_gender.values() if v == 'male')
narrator_gender = 'female' if female <= male else 'male'
segments.append(
{'speaker': None, 'gender': narrator_gender, 'text': head},
)
for name, text in turns:
segments.append({
'speaker': name,
'gender': speaker_gender[name.lower()],
'text': text,
})
return segments
def strip_speaker_labels(script: str) -> str:
"""Return ``script`` with all ``Name:`` prefixes removed.
Used when the active TTS provider can't do multi-voice (gTTS only
speaks one voice per call) so we still get a clean recording —
just one narrator reading both turns instead of an awkward
"Host colon ... Sarah colon ...".
"""
if not script:
return ''
return _SPEAKER_PREFIX.sub('', script).strip()
def is_dialogue(segments: Iterable[dict]) -> bool:
"""True if ``segments`` represents a multi-speaker dialogue.
The single-speaker fast-path skips concatenation and just calls the
underlying TTS once with the full text, so this gate has to be
accurate. We require *two distinct named speakers* — a single
speaker who happens to talk in multiple turns (rare but possible
after an intro paragraph) is still a monologue from a TTS
perspective.
"""
seen = set()
for seg in segments:
sp = seg.get('speaker')
if sp:
seen.add(sp.lower())
if len(seen) >= 2:
return True
return False

View File

@@ -0,0 +1,373 @@
"""Mine exercises from indexed reference books and persist them as
``interactive_workbook`` materials on the plan.
Pipeline
--------
1. Walk all chunks belonging to the plan's indexed sources
(``encoach.embedding`` rows with ``content_type='course_plan_source'``
and ``entity_id=plan.id``).
2. Group chunks into N-chunk batches and ask the LLM to identify
exercises in the text — gap-fills, multiple-choice items, matching
pairs, word-reorder, transformation, short-answer.
3. Validate / clean each exercise with cheap heuristics. Drop anything
without a usable answer key. De-dupe against previously-extracted
stems (same plan).
4. For each accepted batch, attach the workbook to the most relevant
week — picked by keyword overlap between the exercise stems and
each week's ``unit + focus``. Falls back to the first week.
5. Persist as one ``encoach.course.plan.material`` per batch with
``material_type='interactive_workbook'`` and an
``extracted_from`` provenance record so the UI can show "Extracted
from <book>".
"""
from __future__ import annotations
import json
import logging
import re
_logger = logging.getLogger(__name__)
# Per LLM call: how many ~2K-char chunks to feed in one go. 6 keeps the
# prompt under ~12K chars, well within a 16K context budget while still
# letting the model see enough surrounding context to identify rubrics.
CHUNKS_PER_BATCH = 6
# Hard cap on total batches per run — protects against runaway cost on a
# very large book. Operators can lift this via ``max_batches`` kwarg.
DEFAULT_MAX_BATCHES = 8
ALLOWED_TYPES = {
'gap_fill', 'multiple_choice', 'match_pairs',
'reorder_words', 'transformation', 'short_answer',
}
_EXTRACT_HINT = """
You are reading raw text extracted from a printed English exercise book.
Identify any drills, controlled-practice exercises, or workbook tasks in
the passage and extract them as machine-checkable JSON.
Return JSON with EXACTLY this shape:
{
"exercises": [
{"id":"e1", "type":"gap_fill", "stem":"I ___ (be) a student.", "answer":"am", "alt":["am"], "hint":"present simple of 'be'"},
{"id":"e2", "type":"multiple_choice", "stem":"...", "options":["A","B","C","D"], "answer":"B", "rationale":"..."},
{"id":"e3", "type":"match_pairs", "left":["dog","cat"], "right":["barks","meows"], "answer":[[0,0],[1,1]]},
{"id":"e4", "type":"reorder_words", "tokens":["I","am","a","student"], "answer":"I am a student"},
{"id":"e5", "type":"transformation", "from":"He plays football.", "instruction":"Make it negative.", "answer":"He doesn't play football.", "alt":["He does not play football."]},
{"id":"e6", "type":"short_answer", "stem":"What's your name?", "answer":"My name is …", "accepted":["My name is *","I'm *","I am *"]}
],
"topic_keywords": ["routine","present simple", "..."],
"page_hint": "if the source mentions 'p. 12' or 'Unit 1', echo it here"
}
Strict rules
------------
- Skip narrative prose, scope-and-sequence pages, answer keys.
- Skip exercises whose answer cannot be inferred from the source.
- Use ONLY the six types listed above; never invent a new type.
- Stems must be self-contained (no "look at exercise 3").
- Answers must be a string, a boolean, or a list of [int,int] pairs as
shown — never a sentence like "answer key on p. 99".
- Output MUST be valid JSON. If the passage contains no usable
exercises, return ``{"exercises": [], "topic_keywords": [], "page_hint": ""}``.
"""
def _normalize_stem(stem: str) -> str:
"""Lowercase + collapse whitespace — used for de-dup keys."""
return re.sub(r'\s+', ' ', (stem or '').strip().lower())
def _is_clean_exercise(ex: dict) -> bool:
"""Cheap structural validation. Drops anything we can't grade."""
t = (ex.get('type') or '').lower()
if t not in ALLOWED_TYPES:
return False
if not (ex.get('stem') or ex.get('from') or ex.get('left') or ex.get('tokens')):
return False
ans = ex.get('answer')
if ans is None or ans == '' or ans == []:
return False
if t == 'multiple_choice':
opts = ex.get('options') or []
if not isinstance(opts, list) or len(opts) < 2:
return False
if isinstance(ans, str) and ans not in opts:
# Some authors use "B" as the answer; that's fine.
if not (len(ans) == 1 and ans.isalpha()):
return False
if t == 'match_pairs':
if not (isinstance(ex.get('left'), list)
and isinstance(ex.get('right'), list)
and isinstance(ans, list)):
return False
if not all(isinstance(p, list) and len(p) == 2 for p in ans):
return False
if t == 'reorder_words':
toks = ex.get('tokens')
if not (isinstance(toks, list) and len(toks) >= 2):
return False
return True
class ExerciseExtractor:
"""Walk the plan's indexed corpus and persist interactive workbooks."""
CONTENT_TYPE = 'course_plan_source'
def __init__(self, env, *, language: str = 'en'):
self.env = env
self.language = language
try:
from odoo.addons.encoach_ai.services.openai_service import (
OpenAIService,
)
except ImportError:
OpenAIService = None
self._OpenAIService = OpenAIService
self._ai = None # lazy
# ------------------------------------------------------------------
def _service(self):
if self._ai is not None:
return self._ai
if self._OpenAIService is None:
raise RuntimeError('encoach_ai.OpenAIService not available')
self._ai = self._OpenAIService(self.env, language=self.language)
return self._ai
# ------------------------------------------------------------------
def _load_corpus(self, plan) -> list[dict]:
"""Return all indexed chunks for the plan, oldest-first."""
Embedding = self.env['encoach.embedding'].sudo()
rows = Embedding.search([
('content_type', '=', self.CONTENT_TYPE),
('entity_id', '=', plan.id),
], order='content_id asc, chunk_index asc')
chunks = []
for r in rows:
metadata = {}
try:
metadata = json.loads(r.metadata_json or '{}')
except (TypeError, ValueError):
pass
chunks.append({
'source_id': int(metadata.get('source_id') or r.content_id),
'title': metadata.get('title') or '',
'chunk_index': r.chunk_index,
'text': r.content_text or '',
})
return chunks
# ------------------------------------------------------------------
def _existing_stems(self, plan) -> set[str]:
"""Already-stored normalized stems → avoid duplicate workbooks."""
Material = self.env['encoach.course.plan.material'].sudo()
rows = Material.search([
('plan_id', '=', plan.id),
('material_type', '=', 'interactive_workbook'),
])
seen: set[str] = set()
for m in rows:
try:
body = json.loads(m.body_json or '{}')
except (TypeError, ValueError):
continue
for ex in body.get('exercises') or []:
key = _normalize_stem(ex.get('stem') or ex.get('from') or '')
if key:
seen.add(key)
return seen
# ------------------------------------------------------------------
@staticmethod
def _pick_week(plan, topic_keywords: list[str], stems: list[str]):
"""Return the week most relevant to a batch's topic keywords.
Falls back to week 1 when the plan has no overlap signal — that's
better than dropping the workbook entirely.
"""
weeks = list(plan.week_ids.sorted('week_number'))
if not weeks:
return None
haystack = ' '.join(topic_keywords + stems).lower()
if not haystack.strip():
return weeks[0]
def score(week):
text = f'{week.unit or ""} {week.focus or ""}'.lower()
terms = [t for t in re.split(r'\W+', text) if len(t) > 3]
return sum(1 for t in terms if t in haystack)
ranked = sorted(weeks, key=score, reverse=True)
# If nothing scored at all, prefer week 1 for stability.
return ranked[0] if score(ranked[0]) > 0 else weeks[0]
# ------------------------------------------------------------------
def _llm_extract_batch(self, chunks: list[dict]) -> dict:
"""Run one extraction LLM call against a batch of chunks."""
ai = self._service()
joined = '\n\n---\n\n'.join(
f'[chunk {c["chunk_index"]} | source #{c["source_id"]}]\n{c["text"]}'
for c in chunks
)
messages = [
{
'role': 'system',
'content': (
'You extract original printed exercises from English '
'workbook scans. You return STRICT JSON. You never '
'invent exercises that are not on the page.'
),
},
{
'role': 'user',
'content': (
f'Extract the exercises from the passage below.\n\n'
f'{_EXTRACT_HINT}\n\nPassage:\n{joined}'
),
},
]
try:
return ai.chat_json(
messages,
temperature=0.1,
max_tokens=4000,
action='course_plan.extract_exercises',
) or {}
except Exception as exc:
_logger.warning('Exercise extraction LLM call failed: %s', exc)
return {}
# ------------------------------------------------------------------
def run(self, plan, *, max_batches: int = DEFAULT_MAX_BATCHES) -> dict:
"""Extract exercises for ``plan`` and persist workbook materials.
:returns: ``{materials_created, exercises_total, batches_run, skipped}``.
"""
chunks = self._load_corpus(plan)
if not chunks:
return {
'materials_created': 0,
'exercises_total': 0,
'batches_run': 0,
'skipped': 0,
'reason': 'no indexed chunks for this plan',
}
seen_stems = self._existing_stems(plan)
Material = self.env['encoach.course.plan.material'].sudo()
materials_created = 0
exercises_total = 0
batches_run = 0
skipped_dup = 0
# Group chunks per source so a single workbook stays cohesive.
by_source: dict[int, list[dict]] = {}
for ch in chunks:
by_source.setdefault(ch['source_id'], []).append(ch)
for source_id, source_chunks in by_source.items():
for i in range(0, len(source_chunks), CHUNKS_PER_BATCH):
if batches_run >= max_batches:
break
batch = source_chunks[i:i + CHUNKS_PER_BATCH]
batches_run += 1
payload = self._llm_extract_batch(batch)
if not payload or 'error' in payload:
continue
raw_exercises = payload.get('exercises') or []
clean: list[dict] = []
for ex in raw_exercises:
if not isinstance(ex, dict):
continue
if not _is_clean_exercise(ex):
continue
key = _normalize_stem(
ex.get('stem') or ex.get('from') or '',
)
if not key or key in seen_stems:
skipped_dup += 1
continue
seen_stems.add(key)
clean.append(ex)
if not clean:
continue
topic_keywords = [
str(t).strip() for t in (payload.get('topic_keywords') or [])
if str(t).strip()
]
page_hint = (payload.get('page_hint') or '').strip()
stems = [ex.get('stem') or ex.get('from') or '' for ex in clean]
week = self._pick_week(plan, topic_keywords, stems)
if week is None:
continue
title = self._pick_title(batch, topic_keywords, week)
summary = (
f'Extracted from {batch[0].get("title") or "uploaded book"}'
+ (f' ({page_hint})' if page_hint else '')
+ f'{len(clean)} interactive exercise(s).'
)
body = {
'lesson_plan': {
'warmup': '',
'presentation': (
'Originally printed exercises mined from the '
'reference book. Use them as in-class drills '
'or homework.'
),
'controlled_practice': '',
'freer_practice': '',
'exit_ticket': '',
},
'exercises': clean,
}
provenance = {
'source_id': int(source_id),
'source_title': batch[0].get('title') or '',
'page_hint': page_hint,
'topic_keywords': topic_keywords,
'chunk_indices': [c['chunk_index'] for c in batch],
}
Material.create({
'plan_id': plan.id,
'week_id': week.id,
'skill': 'integrated',
'material_type': 'interactive_workbook',
'title': title,
'summary': summary,
'body_json': json.dumps(body, ensure_ascii=False),
'body_text': '',
'extracted_from_json': json.dumps(
provenance, ensure_ascii=False,
),
})
materials_created += 1
exercises_total += len(clean)
if batches_run >= max_batches:
break
return {
'materials_created': materials_created,
'exercises_total': exercises_total,
'batches_run': batches_run,
'skipped': skipped_dup,
}
# ------------------------------------------------------------------
@staticmethod
def _pick_title(batch, topic_keywords, week) -> str:
"""Compose a friendly material title for the extracted workbook."""
topic = ''
if topic_keywords:
topic = topic_keywords[0]
if not topic and week and (week.unit or week.focus):
topic = (week.unit or week.focus or '').strip()
if not topic:
topic = 'Practice'
return f'Workbook — {topic.title()}'

View File

@@ -0,0 +1,806 @@
"""Multimedia generation service for course-plan materials.
Three modalities — each persists an ``encoach.course.plan.media`` row
with the bytes attached as an ``ir.attachment`` so the existing
``/web/content/<id>`` URL serving works without extra plumbing.
PROVIDER FALLBACK CHAIN (Phase 24.1, Apr 2026)
==============================================
Every modality now tries providers in order: the explicitly-requested or
admin-configured paid provider first, then a sequence of *free* fallbacks.
A request that hits a billing/quota error (HTTP 402/429, OpenAI
``insufficient_quota``, AWS Polly ``ThrottlingException``, ElevenLabs
character-limit, etc.) is silently retried against the next provider in
the chain — the request never fails just because the admin's API key has
run out of credit.
* Image: ``openai (DALL-E 3) → pillow (offline placeholder) → unsplash``.
* Audio: ``polly | elevenlabs → gtts → silent-stub``.
* Video: ``ffmpeg (image+audio) → static (text-only image card)``.
The active provider per capability is read fresh from
``ir.config_parameter`` on every call (see
:mod:`encoach_ai.services.provider_router`) so toggling a provider in
the admin UI takes effect on the very next request without restarting.
"""
from __future__ import annotations
import base64
import logging
import os
import shutil
import subprocess
import sys
import tempfile
import time
from typing import Optional
_logger = logging.getLogger(__name__)
def _resolve_bin(name: str) -> str | None:
"""Locate a binary, preferring the running Python's sibling ``bin/``.
Odoo is launched with the conda env's interpreter, so its sibling
``bin/`` reliably hosts ``ffmpeg`` even when ``$PATH`` in the
daemon's environment doesn't include the conda prefix. We use this
same pattern in ``encoach_ai_course.services.source_indexer`` for
tesseract / pdftoppm — keeping it duplicated here rather than
importing across services to avoid a circular dep on a single
helper.
"""
sibling = os.path.join(os.path.dirname(sys.executable), name)
if os.path.isfile(sibling) and os.access(sibling, os.X_OK):
return sibling
return shutil.which(name)
from odoo.addons.encoach_ai.services import provider_router
from odoo.addons.encoach_ai.services.provider_router import (
classify_provider_error,
should_fallback,
)
from . import dialogue_parser
# Providers that can do per-segment multi-voice TTS. gTTS / silent only
# expose a single voice per request, so for those we degrade gracefully
# to label-stripped single-voice narration.
_MULTI_VOICE_PROVIDERS = frozenset({'polly', 'elevenlabs'})
# --- Helpers ----------------------------------------------------------------
def _attach_bytes(env, *, name, mime_type, data: bytes,
res_model: str | None = None,
res_id: int | None = None,
public: bool = True):
"""Persist ``data`` as an ``ir.attachment`` and return the record."""
Attachment = env['ir.attachment'].sudo()
return Attachment.create({
'name': name,
'type': 'binary',
'datas': base64.b64encode(data),
'mimetype': mime_type or 'application/octet-stream',
'res_model': res_model or False,
'res_id': res_id or 0,
'public': bool(public),
})
def _get_param(env, key, default):
return env['ir.config_parameter'].sudo().get_param(key, default)
def _enforce_image_budget(env, plan, planned_images: int = 1) -> None:
"""Raise if generating ``planned_images`` would exceed the per-plan cap.
The budget only applies to *paid* image providers (DALL-E). Free
fallbacks (Pillow, Unsplash) are unmetered.
"""
cap = int(_get_param(env, 'encoach_ai_course.image_budget_per_plan', '60'))
if cap <= 0:
return
Media = env['encoach.course.plan.media'].sudo()
used = Media.search_count([
('plan_id', '=', plan.id),
('kind', '=', 'image'),
('provider', '=', 'openai_image'),
('status', 'in', ('ready', 'generating')),
])
if used + planned_images > cap:
raise RuntimeError(
f'Paid image budget exceeded for this plan: {used} used, cap is '
f'{cap}. Raise encoach_ai_course.image_budget_per_plan or delete '
f'old DALL-E images. Free Pillow/Unsplash fallbacks remain available.'
)
def _build_audio_script(material) -> str:
"""Choose the right text from a material's body for narration.
NEVER include the structural field labels ("Context:", "Script:",
"Transcript:") — only the actual narratable content. The previous
implementation already did this for ``listening_script`` (only the
``script`` field was returned), but we re-document the contract
here because the dialogue-aware path below depends on it: the
speaker labels we strip out are ONLY the in-text "Name:" turn
markers, never section headers.
"""
body = material._loads(material.body_json, {}) or {}
if material.material_type == 'listening_script':
return (body.get('script') or '').strip()
if material.material_type == 'speaking_prompt':
if isinstance(body.get('model_answer'), str) and body['model_answer'].strip():
return body['model_answer'].strip()
prompts = body.get('prompts') or []
if prompts:
return ' '.join(str(p) for p in prompts).strip()
if material.material_type == 'reading_text':
return (body.get('text') or '').strip()
return (material.body_text or '').strip()
def _build_image_prompt(material, *, plan) -> str:
"""Construct a DALL-E prompt from the material content + plan CEFR."""
body = material._loads(material.body_json, {}) or {}
cefr = (plan.cefr_level or '').upper() or 'A2'
style_hint = (
'flat illustration, soft pastel palette, friendly textbook style, '
'clean white background, high contrast, no text, no watermarks'
)
if material.material_type == 'reading_text':
snippet = (body.get('text') or '')[:300].strip()
return (
f'Editorial illustration for an English language reading lesson '
f'(CEFR {cefr}) titled "{material.title}". Subject: {snippet} '
f'Style: {style_hint}.'
)
if material.material_type == 'listening_script':
snippet = (body.get('script') or '')[:300].strip()
return (
f'Illustration showing the scene of an English listening '
f'lesson dialogue (CEFR {cefr}) titled "{material.title}". '
f'Scene: {snippet} Style: {style_hint}.'
)
if material.material_type == 'vocabulary_list':
words = body.get('words') or []
if words:
term = words[0].get('term') or material.title
return (
f'Single concrete object representing the English word '
f'"{term}" for a CEFR {cefr} vocabulary flashcard. {style_hint}.'
)
return (
f'Illustration for an English language teaching material (CEFR '
f'{cefr}) titled "{material.title}". {style_hint}.'
)
def _image_subtitle(material, *, plan):
"""Short subtitle string used by the offline placeholder card."""
cefr = (plan.cefr_level or '').upper() or ''
parts = [f'CEFR {cefr}']
if material.week_number:
parts.append(f'Week {material.week_number}')
if material.material_type:
parts.append(material.material_type.replace('_', ' ').title())
return ' · '.join(parts)
# --- Public service ---------------------------------------------------------
class MediaService:
"""Generate audio / image / video assets for a course-plan material.
All three public methods (:meth:`synthesize_audio`,
:meth:`generate_image`, :meth:`compose_video`) walk a provider chain
and degrade gracefully — they never raise to the controller as long
as at least one free fallback is wired up. The persisted
``encoach.course.plan.media`` row records *which* provider actually
succeeded, plus any errors hit along the way (concatenated into
``error`` for diagnostics).
"""
def __init__(self, env):
self.env = env
# -- Audio -----------------------------------------------------------
def synthesize_audio(self, material, *, voice: Optional[str] = None,
language: str = 'en-GB',
gender: str = 'female',
provider: str = 'auto') -> 'models.Model':
"""Generate a narration MP3 for ``material`` and persist it.
The ``provider`` argument behaves like the admin setting:
* ``'auto'`` — try paid then free fallbacks
* a specific name (``polly``, ``elevenlabs``, ``gtts``,
``silent``) — pin that provider; the chain still falls back
to the free providers if it errors out
Even if every provider fails, the row is marked ``failed`` with
a helpful error rather than raising.
"""
Media = self.env['encoach.course.plan.media'].sudo()
media = Media.create({
'plan_id': material.plan_id.id,
'week_id': material.week_id.id if material.week_id else False,
'material_id': material.id,
'kind': 'audio',
'provider': provider,
'title': f'{material.title} — narration',
'language': language,
'voice': voice or '',
'status': 'generating',
})
text = _build_audio_script(material)
if not text:
media.write({'status': 'failed', 'error': 'No script text to narrate'})
return media
media.write({'source_text': text[:3000]})
# Parse "Host: ... Sarah: ..." style dialogues into per-speaker
# segments. For monologues this returns a single segment with
# speaker=None and we fall through to the legacy fast-path. For
# real dialogues we'll synthesize each turn with a gender-matched
# voice and concatenate them with ffmpeg.
segments = dialogue_parser.parse_dialogue(text)
is_dialogue = dialogue_parser.is_dialogue(segments)
chain = provider_router.resolve_chain(
self.env, 'audio', requested=provider if provider != 'auto' else None,
)
errors = []
for prov in chain:
try:
if is_dialogue and prov in _MULTI_VOICE_PROVIDERS:
# The good path: per-speaker, gender-matched voices,
# stitched into a single MP3 by ffmpeg. The user asked
# for "if conversation between two persons, person if
# man voice must be man and if woman voice is for
# woman" — this is where that contract lives.
result = self._synthesize_dialogue(
prov, segments=segments, language=language,
)
else:
# Single-voice path. Even here we must NOT speak the
# speaker labels aloud, so for dialogue scripts hitting
# a free fallback we feed the label-stripped form.
spoken = (
dialogue_parser.strip_speaker_labels(text)
if is_dialogue else text
)
result = self._call_audio_provider(
prov, text=spoken, voice=voice,
language=language, gender=gender,
)
content_type = result.get('content_type', 'audio/mpeg')
ext = 'wav' if content_type == 'audio/wav' else 'mp3'
attach = _attach_bytes(
self.env,
name=f'plan-{material.plan_id.id}-week-{material.week_number}'
f'-{material.material_type}-{material.id}.{ext}',
mime_type=content_type,
data=result['audio'],
res_model='encoach.course.plan.media',
res_id=media.id,
)
media.write({
'attachment_id': attach.id,
'mime_type': content_type,
'size_bytes': len(result['audio']),
'voice': result.get('voice') or voice or '',
'provider': prov,
'status': 'ready',
'error': '\n'.join(errors)[:500] if errors else False,
})
return media
except Exception as exc:
kind = classify_provider_error(exc)
msg = f'[{prov}/{kind}] {str(exc)[:200]}'
errors.append(msg)
_logger.warning(
'TTS provider %s failed (%s) for material %s — trying next',
prov, kind, material.id,
)
if not should_fallback(exc) and prov != chain[-1]:
# ``other`` errors (not quota/auth/network) suggest a
# genuine input problem, not provider exhaustion. Keep
# falling back anyway because the user just wants audio
# produced — but log loudly so we notice in production.
_logger.exception(
'Unclassified TTS error from %s — continuing chain', prov,
)
media.write({
'status': 'failed',
'error': ('All audio providers failed: '
+ ' | '.join(errors))[:500],
})
return media
def _call_audio_provider(self, provider, *, text, voice, language, gender):
if provider == 'polly':
from odoo.addons.encoach_ai.services.polly_service import PollyService
return PollyService(self.env).synthesize(
text, voice=voice, language=language, gender=gender,
)
if provider == 'elevenlabs':
from odoo.addons.encoach_ai.services.elevenlabs_service import (
ElevenLabsService,
)
res = ElevenLabsService(self.env).synthesize(
text, voice_id=voice or None,
)
return {
'audio': res.get('audio') or res.get('audio_bytes') or b'',
'content_type': res.get('content_type', 'audio/mpeg'),
'voice': res.get('voice') or voice or 'elevenlabs',
'characters': len(text),
}
if provider == 'gtts':
from odoo.addons.encoach_ai.services.free_tts import (
synthesize_with_gtts,
)
return synthesize_with_gtts(text, language=language)
if provider == 'silent':
from odoo.addons.encoach_ai.services.free_tts import synthesize_silent
# Pick a duration roughly proportional to the script so the
# silent stub still gives the video composer enough length.
seconds = max(1, min(30, len(text) // 12))
return synthesize_silent(duration_seconds=seconds)
raise RuntimeError(f'Unknown audio provider: {provider!r}')
# -- Multi-voice dialogue --------------------------------------------
def _synthesize_dialogue(self, provider, *, segments, language):
"""Render a dialogue script with per-speaker, gendered voices.
Each turn is synthesized independently — Polly/ElevenLabs both
let us pick a voice per call — and the resulting MP3 / WAV blobs
are concatenated via ffmpeg's concat filter into a single audio
track. We re-encode through libmp3lame so the output is uniform
regardless of which provider was used (bitrate, sample-rate
differences would otherwise corrupt the join).
If ffmpeg is not available we still return *some* audio — a
single-voice rendering of the label-stripped text — rather than
failing the whole request. Listening practice without a real
dialogue is preferable to no audio at all.
"""
if not segments:
raise RuntimeError('No dialogue segments to synthesize')
ffmpeg_bin = _resolve_bin('ffmpeg')
if not ffmpeg_bin:
# Graceful degradation: produce a single-voice rendition of
# the stripped script. Surface a soft warning but do not
# raise — the caller's fallback chain would just route us to
# gtts/silent, which is identical behaviour but slower.
_logger.warning(
'ffmpeg missing — falling back to single-voice dialogue; '
'install ffmpeg for true per-speaker rendering.'
)
joined = ' '.join(seg['text'] for seg in segments)
return self._call_audio_provider(
provider, text=joined, voice=None,
language=language, gender='female',
)
clip_paths: list[str] = []
first_voice: Optional[str] = None
# Polly bills per character; cap each turn at ~3000 chars (5x the
# ~600-char average so a stray giant turn from the LLM doesn't
# blow the budget but a typical 3-minute conversation passes
# untouched).
MAX_CHARS_PER_TURN = 3000
with tempfile.TemporaryDirectory(prefix='encoach_dialogue_') as tmp:
for idx, seg in enumerate(segments):
seg_text = (seg.get('text') or '').strip()
if not seg_text:
continue
seg_text = seg_text[:MAX_CHARS_PER_TURN]
seg_gender = seg.get('gender') or 'female'
try:
# Pin the per-segment voice via gender. Letting
# ``voice=None`` flow through means each provider
# picks its default gendered voice (Amy/Brian for
# Polly en-GB, Rachel/Arnold for ElevenLabs).
res = self._call_audio_provider(
provider, text=seg_text, voice=None,
language=language, gender=seg_gender,
)
except Exception as exc:
raise RuntimeError(
f'segment {idx} ({seg.get("speaker") or "narrator"}) '
f'failed: {exc}'
) from exc
if not first_voice:
first_voice = res.get('voice') or seg_gender
ext = 'wav' if res.get(
'content_type') == 'audio/wav' else 'mp3'
clip_path = os.path.join(tmp, f'seg_{idx:03d}.{ext}')
with open(clip_path, 'wb') as fh:
fh.write(res['audio'])
clip_paths.append(clip_path)
if not clip_paths:
raise RuntimeError('All dialogue segments were empty')
if len(clip_paths) == 1:
# Edge case: 2 speakers but only one had non-empty text.
# Skip the concat dance and return the single clip raw.
with open(clip_paths[0], 'rb') as fh:
return {
'audio': fh.read(),
'content_type': 'audio/mpeg' if clip_paths[0].endswith('.mp3') else 'audio/wav',
'voice': first_voice or 'dialogue',
'characters': sum(len(s.get('text') or '') for s in segments),
}
# Build the ffmpeg concat-filter call. We re-encode to a
# uniform 22050 Hz mono MP3 so bitrate/sample-rate mismatches
# between providers (or even between Polly's neural and
# standard engines) don't corrupt the join.
out_path = os.path.join(tmp, 'dialogue.mp3')
cmd = [ffmpeg_bin, '-y']
for p in clip_paths:
cmd += ['-i', p]
n = len(clip_paths)
filter_inputs = ''.join(f'[{i}:a]' for i in range(n))
cmd += [
'-filter_complex',
f'{filter_inputs}concat=n={n}:v=0:a=1[outa]',
'-map', '[outa]',
'-ac', '1',
'-ar', '22050',
'-c:a', 'libmp3lame',
'-b:a', '96k',
out_path,
]
t0 = time.time()
proc = subprocess.run(
cmd, capture_output=True, check=False, timeout=180,
)
elapsed = time.time() - t0
if proc.returncode != 0:
err = (proc.stderr or b'').decode(
'utf-8', errors='replace')[-500:]
raise RuntimeError(
f'ffmpeg concat failed in {elapsed:.1f}s: {err}',
)
with open(out_path, 'rb') as fh:
audio_bytes = fh.read()
return {
'audio': audio_bytes,
'content_type': 'audio/mpeg',
'voice': f'dialogue-{n}-turns',
'characters': sum(len(s.get('text') or '') for s in segments),
}
# -- Image -----------------------------------------------------------
def generate_image(self, material, *,
custom_prompt: Optional[str] = None,
size: str = '1024x1024',
style: str = 'natural',
quality: str = 'standard',
provider: str = 'auto') -> 'models.Model':
"""Generate an illustration for ``material`` with provider fallback."""
Media = self.env['encoach.course.plan.media'].sudo()
plan = material.plan_id
prompt = (custom_prompt or _build_image_prompt(material, plan=plan)).strip()
media = Media.create({
'plan_id': plan.id,
'week_id': material.week_id.id if material.week_id else False,
'material_id': material.id,
'kind': 'image',
'provider': provider,
'title': f'{material.title} — illustration',
'source_text': prompt[:3000],
'style': style,
'status': 'generating',
})
chain = provider_router.resolve_chain(
self.env, 'image', requested=provider if provider != 'auto' else None,
)
errors = []
for prov in chain:
try:
# Only the paid OpenAI provider is metered against the budget.
if prov in ('openai', 'openai_image'):
_enforce_image_budget(self.env, plan, planned_images=1)
result = self._call_image_provider(
prov, prompt=prompt, size=size, style=style,
quality=quality, material=material, plan=plan,
)
provider_label = 'openai_image' if prov in (
'openai', 'openai_image') else prov
attach = _attach_bytes(
self.env,
name=f'plan-{plan.id}-week-{material.week_number}'
f'-{material.material_type}-{material.id}.png',
mime_type=result.get('mime_type', 'image/png'),
data=result['image'],
res_model='encoach.course.plan.media',
res_id=media.id,
)
try:
w, h = (int(s) for s in size.split('x'))
except Exception:
w, h = 0, 0
media.write({
'attachment_id': attach.id,
'mime_type': result.get('mime_type', 'image/png'),
'size_bytes': len(result['image']),
'width': w,
'height': h,
'provider': provider_label,
'status': 'ready',
'error': '\n'.join(errors)[:500] if errors else False,
'cost_cents': (4 if quality == 'standard' else 8)
if prov in ('openai', 'openai_image') else 0,
})
return media
except Exception as exc:
kind = classify_provider_error(exc)
msg = f'[{prov}/{kind}] {str(exc)[:200]}'
errors.append(msg)
_logger.warning(
'Image provider %s failed (%s) for material %s — trying next',
prov, kind, material.id,
)
media.write({
'status': 'failed',
'error': ('All image providers failed: '
+ ' | '.join(errors))[:500],
})
return media
def _call_image_provider(self, provider, *, prompt, size, style, quality,
material, plan):
if provider in ('openai', 'openai_image'):
from odoo.addons.encoach_ai.services.openai_service import (
OpenAIService,
)
res = OpenAIService(self.env).generate_image(
prompt, size=size, style=style, quality=quality,
)
return {
'image': res['image'],
'mime_type': 'image/png',
}
if provider == 'pillow':
from odoo.addons.encoach_ai.services.free_image import (
render_placeholder,
)
png = render_placeholder(
material.title or 'Course material',
subtitle=_image_subtitle(material, plan=plan),
size=size,
seed=material.id,
)
return {'image': png, 'mime_type': 'image/png'}
if provider == 'unsplash':
return self._fetch_unsplash(prompt, size=size)
if provider == 'mock':
from odoo.addons.encoach_ai.services.free_image import (
render_placeholder,
)
png = render_placeholder(
'Mock provider',
subtitle=material.title or '',
size=size,
seed=material.id,
)
return {'image': png, 'mime_type': 'image/png'}
raise RuntimeError(f'Unknown image provider: {provider!r}')
def _fetch_unsplash(self, prompt, *, size='1024x1024'):
"""Free Unsplash Source endpoint — no API key required.
Falls back to the offline Pillow placeholder if the network call
fails so this provider, like all the others, is non-blocking.
"""
try:
import requests
except ImportError as exc: # pragma: no cover
raise RuntimeError('requests not installed') from exc
# The "source" endpoint returns a redirect to a JPEG that matches
# the keywords. We deliberately use only the first 5 keywords to
# keep the URL short.
words = ' '.join((prompt or '').split()[:5]).strip() or 'education'
try:
w, h = size.lower().split('x')
except Exception:
w, h = '1024', '1024'
url = f'https://source.unsplash.com/{w}x{h}/?{words}'
resp = requests.get(url, timeout=15, allow_redirects=True)
if resp.status_code != 200 or not resp.content:
raise RuntimeError(
f'Unsplash returned HTTP {resp.status_code}'
)
return {'image': resp.content, 'mime_type': 'image/jpeg'}
# -- Video -----------------------------------------------------------
def compose_video(self, material, *, audio_media=None,
image_media=None,
provider: str = 'auto') -> 'models.Model':
"""Compose a slide-style MP4 (image + audio) for ``material``.
Strategy:
1. Try ``ffmpeg`` (real slideshow video) if ffmpeg is on PATH.
2. Fall back to ``static`` — a 5-second MP4 generated purely
from the placeholder image without external audio.
Like the audio/image methods, this never raises to the caller;
it always returns a media row whose ``status`` reflects success
or failure.
"""
Media = self.env['encoach.course.plan.media'].sudo()
plan = material.plan_id
media = Media.create({
'plan_id': plan.id,
'week_id': material.week_id.id if material.week_id else False,
'material_id': material.id,
'kind': 'video',
'provider': provider,
'title': f'{material.title} — slideshow',
'status': 'generating',
})
chain = provider_router.resolve_chain(
self.env, 'video', requested=provider if provider != 'auto' else None,
)
errors = []
for prov in chain:
try:
if prov == 'ffmpeg':
return self._compose_video_ffmpeg(
media, material, audio_media, image_media,
)
if prov == 'static':
return self._compose_video_static(media, material)
raise RuntimeError(f'Unknown video provider: {prov!r}')
except Exception as exc:
kind = classify_provider_error(exc)
msg = f'[{prov}/{kind}] {str(exc)[:200]}'
errors.append(msg)
_logger.warning(
'Video provider %s failed (%s) for material %s — trying next',
prov, kind, material.id,
)
media.write({
'status': 'failed',
'error': ('All video providers failed: '
+ ' | '.join(errors))[:500],
})
return media
# ── Concrete video providers ────────────────────────────────────────
def _compose_video_ffmpeg(self, media, material, audio_media, image_media):
"""Real slideshow MP4 via ffmpeg. Raises if ffmpeg not available."""
ffmpeg_bin = _resolve_bin('ffmpeg')
if ffmpeg_bin is None:
raise RuntimeError('ffmpeg not found on PATH')
plan = material.plan_id
audio = audio_media or material.media_ids.filtered(
lambda m: m.kind == 'audio' and m.status == 'ready'
)[:1]
if not audio:
audio = self.synthesize_audio(material)
if audio.status != 'ready':
raise RuntimeError(
f'Audio prerequisite not ready: {audio.error or "unknown"}'
)
image = image_media or material.media_ids.filtered(
lambda m: m.kind == 'image' and m.status == 'ready'
)[:1]
if not image:
image = self.generate_image(material)
if image.status != 'ready':
raise RuntimeError(
f'Image prerequisite not ready: {image.error or "unknown"}'
)
audio_attach = audio.attachment_id
image_attach = image.attachment_id
if not audio_attach or not image_attach:
raise RuntimeError('Missing audio/image attachments')
audio_bytes = base64.b64decode(audio_attach.datas)
image_bytes = base64.b64decode(image_attach.datas)
with tempfile.TemporaryDirectory(prefix='encoach_video_') as tmp:
a_path = os.path.join(tmp, 'audio.mp3')
i_path = os.path.join(tmp, 'image.png')
v_path = os.path.join(tmp, 'out.mp4')
with open(a_path, 'wb') as f:
f.write(audio_bytes)
with open(i_path, 'wb') as f:
f.write(image_bytes)
cmd = [
ffmpeg_bin, '-y',
'-loop', '1', '-i', i_path,
'-i', a_path,
'-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '192k',
'-shortest',
'-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,'
'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=white',
v_path,
]
t0 = time.time()
proc = subprocess.run(
cmd, capture_output=True, check=False, timeout=180,
)
elapsed = time.time() - t0
if proc.returncode != 0:
err = (proc.stderr or b'').decode('utf-8', errors='replace')[-500:]
raise RuntimeError(f'ffmpeg failed: {err}')
with open(v_path, 'rb') as f:
video_bytes = f.read()
attach = _attach_bytes(
self.env,
name=f'plan-{plan.id}-week-{material.week_number}'
f'-{material.material_type}-{material.id}.mp4',
mime_type='video/mp4',
data=video_bytes,
res_model='encoach.course.plan.media',
res_id=media.id,
)
media.write({
'attachment_id': attach.id,
'mime_type': 'video/mp4',
'size_bytes': len(video_bytes),
'duration_seconds': float(audio.duration_seconds or elapsed or 0),
'width': 1280,
'height': 720,
'provider': 'ffmpeg',
'status': 'ready',
'error': False,
})
return media
def _compose_video_static(self, media, material):
"""Last-resort: a tiny MP4-shaped image-only stub.
We don't pretend to render a true video without ffmpeg — instead
we attach the placeholder PNG with an MP4 mime so the LMS can
still display *something*. The media row is marked ``ready`` but
flagged as ``static`` so admins can re-generate later.
"""
from odoo.addons.encoach_ai.services.free_image import (
render_placeholder,
)
plan = material.plan_id
png = render_placeholder(
material.title or 'Course material',
subtitle=_image_subtitle(material, plan=plan) + ' · static',
size='1280x720',
seed=material.id,
)
attach = _attach_bytes(
self.env,
name=f'plan-{plan.id}-week-{material.week_number}'
f'-{material.material_type}-{material.id}-static.png',
mime_type='image/png',
data=png,
res_model='encoach.course.plan.media',
res_id=media.id,
)
media.write({
'attachment_id': attach.id,
'mime_type': 'image/png',
'size_bytes': len(png),
'duration_seconds': 0.0,
'width': 1280,
'height': 720,
'provider': 'static',
'status': 'ready',
'error': 'ffmpeg not available — served as static placeholder image',
})
return media

View File

@@ -0,0 +1,281 @@
"""Build RAG context for course-plan generation.
Wraps :class:`encoach_vector.services.embedding_service.EmbeddingService` to
return the top-k chunks from a plan's indexed reference sources, scoped to
the canonical ``content_type='course_plan_source'`` and trimmed to a
character budget so the LLM prompt stays within context limits.
Indexer convention
------------------
:class:`SourceIndexer` stores each ``encoach.course.plan.source`` chunk
with metadata ``{plan_id, source_id, kind, title, entity_id=plan_id}``
(see :mod:`source_indexer`). This builder uses that convention to scope
retrieval to a single plan: it filters embeddings on ``entity_id=plan.id``
and ``content_type='course_plan_source'``.
Usage
-----
.. code-block:: python
builder = RAGContextBuilder(env)
ctx = builder.for_week(plan, week, skill='reading', k=6)
if ctx['passages']:
prompt += "\\n\\nReference passages from your uploaded book(s):\\n"
prompt += ctx['as_prompt_block']
"""
from __future__ import annotations
import logging
_logger = logging.getLogger(__name__)
# Soft char budget for the assembled passage block. ~3500 chars is roughly
# 800-1000 tokens, leaving room for the rest of the prompt (~2-3 K tokens)
# under a 16 K context window.
DEFAULT_CHAR_BUDGET = 3500
DEFAULT_K = 6
class RAGContextBuilder:
"""Produce RAG passages for a (plan, week, skill) tuple."""
CONTENT_TYPE = 'course_plan_source'
def __init__(self, env, *, char_budget: int = DEFAULT_CHAR_BUDGET):
self.env = env
self.char_budget = int(char_budget)
self._svc = None # lazily created on first call
# ------------------------------------------------------------------
def _service(self):
"""Lazy-load the embedding service so import never fails fatally."""
if self._svc is not None:
return self._svc
try:
from odoo.addons.encoach_vector.services.embedding_service import (
EmbeddingService,
)
except ImportError:
_logger.warning(
'encoach_vector not installed; RAG context disabled.'
)
return None
self._svc = EmbeddingService(self.env)
return self._svc
# ------------------------------------------------------------------
def has_indexed_sources(self, plan) -> bool:
"""Return True iff the plan has at least one indexed (status='indexed') source."""
try:
count = self.env['encoach.course.plan.source'].sudo().search_count([
('plan_id', '=', plan.id),
('status', '=', 'indexed'),
])
except Exception:
return False
return bool(count)
# ------------------------------------------------------------------
def _build_query(self, plan, week, skill: str) -> str:
"""Synthesize a focused retrieval query for (week, skill).
The query is intentionally dense: skill, week unit + focus, and the
descriptions of the outcomes targeted by this week's items. We feed
the descriptions (not just codes) so semantic search can match book
passages that talk about, say, "personal introductions" even when
no outcome code appears in the source text.
"""
outcomes = plan._loads(plan.outcomes_json, {})
items = week._loads(week.items_json, []) if week else []
codes_for_skill: list[str] = []
for it in items:
if (it.get('skill') or '').lower() == (skill or '').lower():
codes_for_skill.extend(it.get('outcome_codes') or [])
outcome_descs: list[str] = []
skill_outcomes = outcomes.get((skill or '').lower()) or []
for o in skill_outcomes:
if o.get('code') in set(codes_for_skill):
outcome_descs.append((o.get('description') or '').strip())
parts = [
(skill or '').strip(),
(week.unit or '').strip() if week else '',
(week.focus or '').strip() if week else '',
' '.join(d for d in outcome_descs if d),
]
query = ''.join(p for p in parts if p)
return query or (plan.name or 'general english')
# ------------------------------------------------------------------
def _trim_to_budget(self, results: list[dict]) -> tuple[list[dict], int]:
"""Cut chunks until total chars <= self.char_budget.
Returns the truncated list and the actual char count used.
"""
out: list[dict] = []
used = 0
for r in results:
text = (r.get('text') or '').strip()
if not text:
continue
# Per-chunk soft cap so one giant page can't eat the whole budget.
if len(text) > 1200:
text = text[:1200].rsplit(' ', 1)[0] + ''
if used + len(text) > self.char_budget:
# If we have nothing yet, take a hard slice so the prompt is
# never empty when the budget is tiny.
if not out:
text = text[: max(self.char_budget // 2, 200)]
else:
break
out.append({**r, 'text': text})
used += len(text)
return out, used
# ------------------------------------------------------------------
def for_week(self, plan, week, skill: str, *, k: int = DEFAULT_K) -> dict:
"""Return RAG context for a (plan, week, skill).
:returns: ``{passages, sources, char_budget_used, query, as_prompt_block}``.
Empty dict-shape when the plan has no indexed sources.
"""
empty = {
'passages': [],
'sources': [],
'char_budget_used': 0,
'query': '',
'as_prompt_block': '',
}
svc = self._service()
if svc is None:
return empty
if not self.has_indexed_sources(plan):
return empty
query = self._build_query(plan, week, skill)
try:
raw_results = svc.search(
query,
content_type=self.CONTENT_TYPE,
limit=int(k),
entity_id=plan.id, # SourceIndexer stores plan_id under entity_id
)
except Exception:
_logger.exception('RAG search failed for plan=%s skill=%s', plan.id, skill)
return empty
# Decorate with source titles for citation.
Source = self.env['encoach.course.plan.source'].sudo()
passages: list[dict] = []
for r in raw_results or []:
md = r.get('metadata') or {}
source_id = int(md.get('source_id') or 0) or None
title = md.get('title') or ''
if source_id and not title:
rec = Source.browse(source_id)
if rec.exists():
title = rec.name or rec.file_name or rec.url or f'Source #{source_id}'
passages.append({
'text': r.get('text') or '',
'similarity': float(r.get('score') or r.get('similarity') or 0.0),
'source_id': source_id,
'source_title': title,
'chunk_index': md.get('chunk_index'),
})
passages, used = self._trim_to_budget(passages)
sources = self._collect_sources(passages)
block = self._render_prompt_block(passages)
return {
'passages': passages,
'sources': sources,
'char_budget_used': used,
'query': query,
'as_prompt_block': block,
}
# ------------------------------------------------------------------
def for_plan(self, plan, *, k: int = 12, query: str | None = None) -> dict:
"""Plan-wide retrieval — used by ExerciseExtractor and the workbook
cross-week pass. Falls back to ``plan.name`` when no query is given.
"""
empty = {
'passages': [],
'sources': [],
'char_budget_used': 0,
'query': '',
'as_prompt_block': '',
}
svc = self._service()
if svc is None or not self.has_indexed_sources(plan):
return empty
q = (query or plan.name or 'exercises').strip()
try:
raw_results = svc.search(
q,
content_type=self.CONTENT_TYPE,
limit=int(k),
entity_id=plan.id,
)
except Exception:
_logger.exception('RAG plan-wide search failed for plan=%s', plan.id)
return empty
Source = self.env['encoach.course.plan.source'].sudo()
passages: list[dict] = []
for r in raw_results or []:
md = r.get('metadata') or {}
source_id = int(md.get('source_id') or 0) or None
title = md.get('title') or ''
if source_id and not title:
rec = Source.browse(source_id)
if rec.exists():
title = rec.name or rec.file_name or rec.url or f'Source #{source_id}'
passages.append({
'text': r.get('text') or '',
'similarity': float(r.get('score') or r.get('similarity') or 0.0),
'source_id': source_id,
'source_title': title,
'chunk_index': md.get('chunk_index'),
})
passages, used = self._trim_to_budget(passages)
sources = self._collect_sources(passages)
block = self._render_prompt_block(passages)
return {
'passages': passages,
'sources': sources,
'char_budget_used': used,
'query': q,
'as_prompt_block': block,
}
# ------------------------------------------------------------------
@staticmethod
def _collect_sources(passages: list[dict]) -> list[dict]:
"""Group passages by source_id → ``[{source_id, title, chunks_used}]``."""
agg: dict[int, dict] = {}
for p in passages:
sid = p.get('source_id') or 0
entry = agg.setdefault(sid, {
'source_id': sid,
'title': p.get('source_title') or '',
'chunks_used': 0,
})
entry['chunks_used'] += 1
if not entry['title']:
entry['title'] = p.get('source_title') or ''
return [v for v in agg.values() if v['source_id']]
# ------------------------------------------------------------------
@staticmethod
def _render_prompt_block(passages: list[dict]) -> str:
"""Format passages as a citation-ready prompt block."""
if not passages:
return ''
lines: list[str] = []
for i, p in enumerate(passages, start=1):
title = p.get('source_title') or 'Reference'
lines.append(f'[{i}] ({title})\n{p.get("text", "")}\n')
return '\n'.join(lines).strip()

View File

@@ -0,0 +1,498 @@
"""Extract text from a course-plan source and embed it into pgvector.
Supported inputs:
* ``kind='file'`` — PDF (preferred via ``pypdf`` then ``PyPDF2``), DOCX
(via ``python-docx``), plain text, or any text MIME we can decode.
* ``kind='url'`` — fetched with ``requests``; HTML is reduced to text
via a tiny BeautifulSoup4 path when available, otherwise raw text.
* ``kind='text'`` — used as-is.
Indexed chunks are stored under ``content_type='course_plan_source'``
with ``entity_id=plan_id`` so the existing ``resources.search`` tool
can scope retrieval to a single plan. We never raise — failures are
recorded on the source row and surfaced to the UI through the status /
error fields. That way one bad PDF doesn't block the rest.
"""
from __future__ import annotations
import base64
import io
import logging
import os
import sys
from datetime import datetime
_logger = logging.getLogger(__name__)
# A doc with fewer than this many extractable characters is treated as
# image-only and routed to OCR. Most legitimate PDFs cross 200 chars in
# their first few pages; below that we're almost certainly looking at a
# scan whose pypdf/pdfminer output is junk metadata only.
_OCR_TRIGGER_CHARS = 200
# Cap how much we OCR in a single indexing pass so a 500-page scanned
# tome doesn't tie up the request thread for 20 minutes. Editable via
# env var so an operator can dial it up for big workbooks.
_OCR_MAX_PAGES = int(os.environ.get('ENCOACH_OCR_MAX_PAGES', '300'))
_OCR_DPI = int(os.environ.get('ENCOACH_OCR_DPI', '200'))
_OCR_LANGS = os.environ.get('ENCOACH_OCR_LANGS', 'eng')
def _resolve_bin(name: str) -> str | None:
"""Locate a binary by checking the running Python's bin/ first.
Odoo is launched with the conda env's interpreter, so its sibling
``bin/`` reliably hosts ``tesseract`` / ``pdftoppm`` even when
``$PATH`` in the daemon's environment is sparse. Fall back to
``shutil.which`` for systems where they're installed globally.
"""
sibling = os.path.join(os.path.dirname(sys.executable), name)
if os.path.isfile(sibling) and os.access(sibling, os.X_OK):
return sibling
import shutil
return shutil.which(name)
def _ocr_pdf(payload: bytes, *, max_pages: int = _OCR_MAX_PAGES) -> str:
"""OCR a scanned/image-only PDF using tesseract + poppler.
Streams page-by-page so peak memory stays bounded even on a 100+
page workbook: rasterizing all pages eagerly into PIL.Image objects
blows past Odoo's ~2 GB ``limit_memory_soft`` and forces a worker
reload mid-request. We instead call ``pdftoppm`` for one page at a
time, OCR the image, drop it, and move on.
Raises a ``RuntimeError`` with an actionable message when the OCR
stack isn't available, so the UI can surface "install OCR" rather
than the cryptic "Extracted no text from source".
"""
try:
from pdf2image import convert_from_bytes, pdfinfo_from_bytes # type: ignore
import pytesseract # type: ignore
except ImportError as exc:
raise RuntimeError(
'This PDF appears to be scanned/image-only and the OCR stack '
'is not installed. Install the deps: '
'`micromamba install -n odoo19 -c conda-forge tesseract poppler` '
'and `pip install pytesseract pdf2image`. '
f'(missing: {exc.name})',
) from exc
tesseract_bin = _resolve_bin('tesseract')
poppler_bin = _resolve_bin('pdftoppm')
if not tesseract_bin or not poppler_bin:
raise RuntimeError(
'OCR is required for this PDF (no embedded text layer) but '
f'tesseract={tesseract_bin or "missing"} / '
f'pdftoppm={poppler_bin or "missing"} could not be located. '
'Install with `micromamba install -n odoo19 -c conda-forge '
'tesseract poppler`.',
)
pytesseract.pytesseract.tesseract_cmd = tesseract_bin
poppler_path = os.path.dirname(poppler_bin)
try:
info = pdfinfo_from_bytes(payload, poppler_path=poppler_path)
total_pages = int(info.get('Pages') or 0)
except Exception as exc:
raise RuntimeError(f'Could not read PDF page count: {exc}') from exc
page_count = min(total_pages, max_pages)
if page_count <= 0:
return ''
_logger.info(
'OCR streaming %s page(s) at %sdpi (lang=%s)',
page_count, _OCR_DPI, _OCR_LANGS,
)
pages: list[str] = []
for page_num in range(1, page_count + 1):
try:
images = convert_from_bytes(
payload,
dpi=_OCR_DPI,
first_page=page_num,
last_page=page_num,
poppler_path=poppler_path,
fmt='png',
thread_count=1,
)
except Exception as exc:
_logger.warning('OCR rasterize page %s failed: %s', page_num, exc)
continue
if not images:
continue
img = images[0]
try:
txt = pytesseract.image_to_string(img, lang=_OCR_LANGS) or ''
except Exception as exc:
_logger.warning('OCR page %s failed: %s', page_num, exc)
txt = ''
finally:
try:
img.close()
except Exception:
pass
del img, images
if txt.strip():
pages.append(txt)
return '\n\n'.join(pages).strip()
def _sniff_format(payload: bytes) -> str:
"""Detect the real file format from magic bytes.
User-supplied `type` / `mime_type` / filename are unreliable: people
pick `type=pdf` in the resource library and then upload a `.docx`,
or rename a `.doc` to `.pdf`. We therefore decide by content first,
so that even when `res.type='pdf'` is wrong we route a DOCX into
`_extract_docx` instead of feeding it to `pypdf` which then dies
with "EOF marker not found" — exactly the failure that surfaced in
the UI as "indexing failed" without an actionable message.
Returns one of: ``pdf``, ``docx``, ``doc``, ``html``, ``text``, ``unknown``.
"""
if not payload or len(payload) < 4:
return 'unknown'
if payload[:5] == b'%PDF-':
return 'pdf'
if payload[:4] == b'PK\x03\x04':
head = payload[:8192]
if b'word/document.xml' in head or b'word/' in head:
return 'docx'
return 'zip'
if payload[:8] == b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1':
return 'doc'
sniff = payload[:512].lstrip().lower()
if sniff.startswith(b'<!doctype') or sniff.startswith(b'<html') or b'<html' in sniff:
return 'html'
try:
sample = payload[:1024].decode('utf-8')
except UnicodeDecodeError:
return 'unknown'
if all((ch.isprintable() or ch in '\r\n\t') for ch in sample):
return 'text'
return 'unknown'
def _extract_pdf(payload: bytes) -> str:
"""Best-effort PDF text extraction with OCR fallback.
1. Try ``pypdf`` (and ``PyPDF2`` as a legacy fallback) to read the
embedded text layer — fast, deterministic, lossless.
2. If that yields virtually nothing (< ``_OCR_TRIGGER_CHARS``), the
PDF is almost certainly a scan; rasterize the first
``_OCR_MAX_PAGES`` pages and run them through tesseract.
Both raise on encrypted PDFs we can't decrypt — we swallow that and
let the caller record the error.
"""
if payload[:5] != b'%PDF-':
raise RuntimeError(
'File is not a valid PDF (missing %PDF- header). '
f'Detected format: {_sniff_format(payload)!r}.'
)
try:
from pypdf import PdfReader # type: ignore
except ImportError:
try:
from PyPDF2 import PdfReader # type: ignore
except ImportError as exc:
raise RuntimeError(
'PDF parser not installed (pip install pypdf)',
) from exc
reader = PdfReader(io.BytesIO(payload))
pages = []
for page in reader.pages:
try:
pages.append(page.extract_text() or '')
except Exception:
pages.append('')
text = '\n\n'.join(p for p in pages if p).strip()
if len(text) >= _OCR_TRIGGER_CHARS:
return text
# Looks image-only — try OCR. If OCR is unavailable, propagate a
# clear error rather than silently returning the empty/near-empty
# text-layer output.
_logger.info(
'PDF text layer empty (%s chars over %s pages); attempting OCR',
len(text), len(reader.pages),
)
ocr_text = _ocr_pdf(payload)
if ocr_text:
return ocr_text
return text
def _extract_docx(payload: bytes) -> str:
try:
import docx # type: ignore
except ImportError as exc:
raise RuntimeError(
'DOCX parser not installed (pip install python-docx)',
) from exc
document = docx.Document(io.BytesIO(payload))
return '\n'.join(p.text for p in document.paragraphs if p.text).strip()
def _extract_html(html: str) -> str:
"""Strip tags. Uses BeautifulSoup if available, else a regex fallback."""
try:
from bs4 import BeautifulSoup # type: ignore
soup = BeautifulSoup(html, 'html.parser')
for tag in soup(['script', 'style', 'noscript']):
tag.decompose()
text = soup.get_text(separator='\n')
except ImportError:
import re
text = re.sub(r'<[^>]+>', ' ', html)
lines = [line.strip() for line in text.splitlines()]
return '\n'.join(line for line in lines if line)
def _fetch_url(url: str) -> tuple[str, str]:
"""Fetch ``url`` and return ``(content_type, text)``."""
try:
import requests # type: ignore
except ImportError as exc:
raise RuntimeError(
'requests not installed (pip install requests)',
) from exc
resp = requests.get(url, timeout=30, headers={
'User-Agent': 'EnCoach-CoursePlan-Indexer/1.0',
})
resp.raise_for_status()
ctype = (resp.headers.get('Content-Type') or '').split(';')[0].strip().lower()
if ctype == 'application/pdf':
return ctype, _extract_pdf(resp.content)
if 'html' in ctype:
return ctype, _extract_html(resp.text)
if ctype.startswith('text/') or not ctype:
return ctype or 'text/plain', resp.text
raise RuntimeError(f'Unsupported content-type for URL: {ctype}')
class SourceIndexer:
"""Extract text from a source row and (re-)index it to pgvector."""
CONTENT_TYPE = 'course_plan_source'
def __init__(self, env):
self.env = env
def _extract(self, source) -> str:
"""Return raw extracted text — or raise if extraction fails."""
if source.kind == 'text':
return (source.inline_text or '').strip()
if source.kind == 'resource':
# Dereference the library resource at extraction time. This
# keeps the binary in one place (``encoach.resource``) so an
# admin update — re-uploading a corrected PDF, fixing a URL,
# changing the linked file — propagates to every plan that
# grounds on it on the next reindex.
res = source.resource_id
if not res or not res.exists():
raise ValueError(
'Linked library resource is missing or was deleted.',
)
rtype = (res.type or '').lower()
file_name = (res.name or '') + (
f'.{rtype}' if rtype in ('pdf', 'docx') and not (res.name or '').lower().endswith(('.pdf', '.docx')) else ''
)
# Persist the resolved metadata on the source row so the UI
# can render a meaningful "indexed N chunks from X.pdf" line
# without having to rejoin the resource table on every read.
updates = {}
if not source.name:
updates['name'] = res.name or f'Resource #{res.id}'
if not source.file_name:
updates['file_name'] = file_name
if updates:
source.write(updates)
if res.file:
payload = base64.b64decode(res.file)
if not payload:
raise ValueError('Library resource has an empty file.')
# Route by ACTUAL file content (magic bytes), not by the
# user-set `type`. Otherwise an admin who uploaded a DOCX
# but classified it as `type='pdf'` causes pypdf to die
# with "EOF marker not found" — exactly the failure we
# were debugging here.
fmt = _sniff_format(payload)
if fmt == 'pdf':
return _extract_pdf(payload)
if fmt == 'docx':
return _extract_docx(payload)
if fmt == 'html':
return _extract_html(payload.decode('utf-8', errors='replace'))
if fmt == 'text':
return payload.decode('utf-8', errors='replace').strip()
if fmt == 'doc':
raise RuntimeError(
'Legacy .doc (Word 97-2003) is not supported for RAG '
'indexing. Re-save the file as .docx or PDF and '
're-upload.'
)
# Last-resort: best-effort try the parsers anyway, in case
# someone gave us a weird-but-still-text-bearing payload.
for fn in (_extract_pdf, _extract_docx):
try:
text = fn(payload)
if text:
return text
except Exception:
continue
try:
decoded = payload.decode('utf-8', errors='replace').strip()
except Exception as exc:
raise RuntimeError(
f'Cannot decode library resource binary: {exc}',
) from exc
if decoded:
return decoded
raise RuntimeError(
f'Unsupported file format for RAG indexing '
f'(detected={fmt!r}, declared type={rtype!r}). '
'Supported: PDF, DOCX, HTML, plain text.'
)
if res.url:
_, text = _fetch_url(res.url)
return text or ''
raise ValueError(
'Library resource has neither a file nor a URL to index.',
)
if source.kind == 'url':
url = (source.url or '').strip()
if not url:
raise ValueError('URL is empty')
mime, text = _fetch_url(url)
if not source.mime_type:
source.mime_type = mime
return text or ''
if source.kind == 'file':
payload = source.get_decoded_file()
if not payload:
raise ValueError('No file payload to index')
# Detect actual content first so a mis-typed mime / extension
# (e.g. .pdf renamed onto a .docx) still indexes correctly.
fmt = _sniff_format(payload)
mime = (source.mime_type or '').lower()
name = (source.file_name or '').lower()
if fmt == 'pdf':
return _extract_pdf(payload)
if fmt == 'docx':
return _extract_docx(payload)
if fmt == 'html':
return _extract_html(payload.decode('utf-8', errors='replace'))
if fmt == 'text':
return payload.decode('utf-8', errors='replace').strip()
if fmt == 'doc':
raise RuntimeError(
'Legacy .doc (Word 97-2003) is not supported for RAG '
'indexing. Re-save the file as .docx or PDF and '
're-upload.'
)
# Fall back to declared mime/extension for ambiguous content.
if mime.startswith('text/') or mime in (
'application/json', 'application/xml', 'application/csv',
) or name.endswith((
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
'.log', '.rst',
)):
try:
return payload.decode('utf-8', errors='replace').strip()
except Exception as exc:
raise RuntimeError(f'Cannot decode file: {exc}') from exc
raise ValueError(
f'Unsupported file type for RAG indexing: '
f'detected={fmt!r} mime={mime!r} name={source.file_name!r}. '
f'Supported: PDF, DOCX, HTML, plain text (txt/md/csv/json/xml).'
)
raise ValueError(f'Unknown source kind: {source.kind!r}')
def index(self, source) -> dict:
"""Run extraction + embedding for one source row."""
from odoo.addons.encoach_vector.services.embedding_service import (
EmbeddingService,
)
source.write({'status': 'indexing', 'error': False})
try:
text = self._extract(source)
except Exception as exc:
source.write({
'status': 'failed',
'error': str(exc)[:500],
'chunks_count': 0,
'extracted_chars': 0,
})
_logger.warning('Extract failed for source %s: %s', source.id, exc)
return {'status': 'failed', 'error': str(exc)}
if not text or not text.strip():
kind = (source.kind or '').lower()
if kind in ('file', 'resource') and (
(source.file_name or '').lower().endswith('.pdf')
or (source.mime_type or '').lower() == 'application/pdf'
):
err = (
'No text could be extracted from this PDF. It looks '
'image-only (a scan) and OCR returned nothing. Try a '
'higher-quality scan, a publisher PDF with an '
'embedded text layer, or raise ENCOACH_OCR_DPI.'
)
else:
err = 'Extracted no text from source'
source.write({
'status': 'failed',
'error': err,
'chunks_count': 0,
'extracted_chars': 0,
})
return {'status': 'failed', 'error': err}
try:
svc = EmbeddingService(self.env)
metadata = {
'plan_id': source.plan_id.id,
'source_id': source.id,
'kind': source.kind,
'title': source.name or source.file_name or source.url or '',
'entity_id': source.plan_id.id,
}
chunks = svc.upsert(
self.CONTENT_TYPE, source.id, text, metadata,
)
except Exception as exc:
source.write({
'status': 'failed',
'error': str(exc)[:500],
})
_logger.exception('Embedding failed for source %s', source.id)
return {'status': 'failed', 'error': str(exc)}
source.write({
'status': 'indexed',
'error': False,
'chunks_count': len(chunks),
'extracted_chars': len(text),
'indexed_at': datetime.utcnow(),
})
return {
'status': 'indexed',
'chunks_count': len(chunks),
'extracted_chars': len(text),
}

View File

@@ -94,12 +94,39 @@ def _get_jwt_secret():
return secret
def validate_token():
"""Decode JWT Bearer token and return the corresponding ``res.users`` record or None."""
def _extract_bearer_token(allow_query_param: bool = False) -> str | None:
"""Pull a JWT off the current request.
By default we only honour the ``Authorization: Bearer …`` header. Some
endpoints (notably media streams that get embedded in ``<img>`` /
``<audio>`` / ``<video>`` tags, where the browser cannot attach custom
headers) opt in to ``allow_query_param=True`` so callers can send the
token via ``?token=<jwt>`` or ``?access_token=<jwt>``. We deliberately
keep this off by default so a leaked URL never gives access to JSON
APIs — only to the specific raw-media route that opts in.
"""
auth_header = request.httprequest.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
if auth_header.startswith("Bearer "):
return auth_header[7:].strip() or None
if allow_query_param:
try:
args = request.httprequest.args
except Exception:
args = {}
token = (args.get("token") or args.get("access_token") or "").strip()
if token:
return token
return None
def validate_token(allow_query_param: bool = False):
"""Decode JWT Bearer token and return the corresponding ``res.users`` record or None.
See :func:`_extract_bearer_token` for the ``allow_query_param`` flag.
"""
token = _extract_bearer_token(allow_query_param=allow_query_param)
if not token:
return None
token = auth_header[7:]
secret = _get_jwt_secret()
if not secret:
_logger.error("System parameter 'encoach.jwt_secret' is not configured")

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({
@@ -270,8 +304,23 @@ class ApprovalWorkflowController(http.Controller):
if not req_rec.exists():
return _error_response('Request not found', 404)
# Authorization — only the user assigned to the current stage
# (or a system admin) may approve. Without this check any
# authenticated user could ride a valid JWT and approve any
# request, bypassing the entire approval workflow.
current_user = request.env.user
stage = req_rec.current_stage_id
assigned = stage.approver_id if stage else None
is_admin = (
current_user.has_group('base.group_system')
or getattr(current_user, 'user_type', None) == 'admin'
)
if assigned and assigned.id != current_user.id and not is_admin:
return _error_response(
'You are not the assigned approver for this stage', 403,
)
with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'approved',
@@ -296,6 +345,16 @@ class ApprovalWorkflowController(http.Controller):
})
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})
@@ -318,8 +377,20 @@ class ApprovalWorkflowController(http.Controller):
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
if not req_rec.exists():
return _error_response('Request not found', 404)
# Same authorization gate as approve — the rejection action
# is just as sensitive as the approval action.
current_user = request.env.user
stage = req_rec.current_stage_id
assigned = stage.approver_id if stage else None
is_admin = (
current_user.has_group('base.group_system')
or getattr(current_user, 'user_type', None) == 'admin'
)
if assigned and assigned.id != current_user.id and not is_admin:
return _error_response(
'You are not the assigned approver for this stage', 403,
)
with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage:
stage.write({
'status': 'rejected',
@@ -327,6 +398,14 @@ class ApprovalWorkflowController(http.Controller):
'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:
@@ -339,18 +418,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

@@ -11,6 +11,18 @@ _logger = logging.getLogger(__name__)
ENTITY_MODEL = 'encoach.entity'
def _ensure_admin_user():
user = request.env.user.sudo()
is_admin = bool(
user.has_group('base.group_system')
or user.has_group('base.group_erp_manager')
or getattr(user, 'user_type', '') == 'admin'
)
if not is_admin:
raise PermissionError('Admin access required')
return user
def _entity_to_dict(entity):
d = entity.to_api_dict() if hasattr(entity, 'to_api_dict') else {
'id': entity.id,
@@ -35,6 +47,16 @@ def _role_to_dict(role):
}
def _user_to_dict(user):
return {
'id': user.id,
'name': user.name or '',
'login': user.login or '',
'email': user.email or '',
'active': bool(user.active),
}
class EntityController(http.Controller):
@http.route('/api/entities', type='http', auth='public',
@@ -59,6 +81,62 @@ class EntityController(http.Controller):
_logger.exception('list entities failed')
return _error_response(str(e), 500)
@http.route('/api/entities/<int:entity_id>/users', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required
def list_entity_users(self, entity_id, **kw):
try:
_ensure_admin_user()
entity = request.env[ENTITY_MODEL].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
items = [_user_to_dict(u) for u in entity.user_ids.sorted('name')]
return _json_response({
'items': items,
'data': items,
'total': len(items),
'entity_id': entity.id,
})
except Exception as e:
code = 403 if isinstance(e, PermissionError) else 500
return _error_response(str(e), code)
@http.route('/api/entities/<int:entity_id>/users', type='http', auth='public',
methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_entity_users(self, entity_id, **kw):
try:
_ensure_admin_user()
body = _get_json_body() or {}
raw_ids = body.get('user_ids') or []
if not isinstance(raw_ids, list):
return _error_response('user_ids must be a list', 400)
try:
user_ids = [int(uid) for uid in raw_ids if uid is not None]
except (TypeError, ValueError):
return _error_response('user_ids must contain integers', 400)
entity = request.env[ENTITY_MODEL].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
users = request.env['res.users'].sudo().browse(user_ids).exists()
if len(users) != len(user_ids):
return _error_response('Some users do not exist', 400)
entity.write({'user_ids': [(6, 0, users.ids)]})
updated = [_user_to_dict(u) for u in entity.user_ids.sorted('name')]
return _json_response({
'success': True,
'entity_id': entity.id,
'user_ids': entity.user_ids.ids,
'items': updated,
'total': len(updated),
})
except Exception as e:
code = 403 if isinstance(e, PermissionError) else 500
return _error_response(str(e), code)
@http.route('/api/entities/<int:entity_id>', type='http', auth='public',
methods=['GET'], csrf=False)
@jwt_required

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

@@ -40,6 +40,8 @@ class EncoachExamCustom(models.Model):
status = fields.Selection([
('draft', 'Draft'),
('pending_review', 'Pending Review'),
('pending_approval', 'Pending Approval'),
('rejected', 'Rejected'),
('published', 'Published'),
('archived', 'Archived'),
], default='draft', required=True)

View File

@@ -23,6 +23,9 @@
'encoach_ai',
'encoach_taxonomy',
'encoach_resources',
'encoach_scoring',
'encoach_exam_template',
'encoach_ai_course',
],
'data': [
'security/ir.model.access.csv',

View File

@@ -26,3 +26,11 @@ from . import paymob
from . import platform_settings
from . import training
from . import reports
from . import branches
from . import teacher_insights
from . import exam_security
from . import live_sessions
from . import personalized_plan
from . import handwritten
from . import reports_export
from . import turnitin

View File

@@ -224,7 +224,15 @@ class AcademicController(http.Controller):
'term_end_date': str(e),
'academic_year_id': year.id,
})
if 'parent_id' in Term._fields:
# Wire the quarter -> parent semester via the actual
# field name on op.academic.term, which is
# ``parent_term`` (not ``parent_id``). The previous
# check matched ``parent_id`` and silently no-op'd
# for everyone — the relationship was never written.
if 'parent_term' in Term._fields:
q1.write({'parent_term': parent.id})
q2.write({'parent_term': parent.id})
elif 'parent_id' in Term._fields:
q1.write({'parent_id': parent.id})
q2.write({'parent_id': parent.id})
quarters += [q1, q2]

View File

@@ -0,0 +1,174 @@
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
)
_logger = logging.getLogger(__name__)
def _entity_scope():
"""Same convention as ``encoach_lms_api.controllers.lms_core._entity_scope``."""
user = request.env.user.sudo()
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
if ids:
return ids, False
is_super = bool(
user.id == 1 or user.has_group('base.group_system')
)
return ids, is_super
def _ensure_entity_access(entity_id):
if not entity_id:
raise PermissionError('entity_id is required')
ids, is_super = _entity_scope()
if is_super:
return int(entity_id)
if not ids:
raise PermissionError('User is not linked to any entity')
if int(entity_id) not in ids:
raise PermissionError('Entity access denied')
return int(entity_id)
def _default_entity_id_from_scope():
ids, is_super = _entity_scope()
if ids:
return ids[0]
if is_super:
return False
raise PermissionError('User is not linked to any entity')
def _scoped_entity_domain(base_domain=None):
domain = list(base_domain or [])
ids, is_super = _entity_scope()
if is_super:
return domain
if not ids:
return domain + [('id', '=', 0)]
return domain + [('entity_id', 'in', ids)]
def _ser_branch(rec):
return {
'id': rec.id,
'name': rec.name or '',
'code': rec.code or '',
'active': bool(rec.active),
'notes': rec.notes or '',
'entity_id': rec.entity_id.id if rec.entity_id else None,
'entity_name': rec.entity_id.name if rec.entity_id else '',
'course_count': rec.course_count or 0,
'batch_count': rec.batch_count or 0,
'student_count': rec.student_count or 0,
'teacher_count': rec.teacher_count or 0,
}
class LmsBranchController(http.Controller):
@http.route('/api/branches', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_branches(self, **kw):
try:
Branch = request.env['encoach.lms.branch'].sudo()
domain = _scoped_entity_domain([])
if kw.get('search'):
q = kw['search']
domain += ['|', ('name', 'ilike', q), ('code', 'ilike', q)]
if kw.get('active') in ('true', 'false', '1', '0'):
domain.append(('active', '=', kw.get('active') in ('true', '1')))
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
offset, limit, page = _paginate(kw)
total = Branch.search_count(domain)
recs = Branch.search(domain, offset=offset, limit=limit, order='name asc, id asc')
items = [_ser_branch(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as exc:
_logger.exception('list_branches failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/branches/<int:branch_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_branch(self, branch_id, **kw):
try:
rec = request.env['encoach.lms.branch'].sudo().browse(branch_id)
if not rec.exists():
return _error_response('Not found', 404)
_ensure_entity_access(rec.entity_id.id)
return _json_response({'data': _ser_branch(rec)})
except Exception as exc:
_logger.exception('get_branch failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/branches', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_branch(self, **kw):
try:
body = _get_json_body()
name = (body.get('name') or '').strip()
if not name:
return _error_response('name is required', 400)
requested_entity = body.get('entity_id')
if requested_entity:
entity_id = _ensure_entity_access(int(requested_entity))
else:
entity_id = _default_entity_id_from_scope()
vals = {
'name': name,
'entity_id': entity_id,
'code': (body.get('code') or '').strip() or False,
'notes': (body.get('notes') or '').strip(),
}
if 'active' in body:
vals['active'] = bool(body.get('active'))
rec = request.env['encoach.lms.branch'].sudo().create(vals)
return _json_response({'data': _ser_branch(rec)})
except Exception as exc:
_logger.exception('create_branch failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/branches/<int:branch_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_branch(self, branch_id, **kw):
try:
rec = request.env['encoach.lms.branch'].sudo().browse(branch_id)
if not rec.exists():
return _error_response('Not found', 404)
_ensure_entity_access(rec.entity_id.id)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = (body.get('name') or '').strip()
if 'code' in body:
vals['code'] = (body.get('code') or '').strip() or False
if 'notes' in body:
vals['notes'] = (body.get('notes') or '').strip()
if 'active' in body:
vals['active'] = bool(body.get('active'))
if 'entity_id' in body and body.get('entity_id'):
vals['entity_id'] = _ensure_entity_access(int(body.get('entity_id')))
if vals:
rec.write(vals)
return _json_response({'data': _ser_branch(rec)})
except Exception as exc:
_logger.exception('update_branch failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/branches/<int:branch_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_branch(self, branch_id, **kw):
try:
rec = request.env['encoach.lms.branch'].sudo().browse(branch_id)
if rec.exists():
_ensure_entity_access(rec.entity_id.id)
rec.unlink()
return _json_response({'success': True})
except Exception as exc:
_logger.exception('delete_branch failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)

View File

@@ -1,4 +1,30 @@
"""HTTP controller for classrooms (homerooms).
Routes:
- GET /api/classrooms list (entity-scoped)
- GET /api/classrooms/<id> fetch with full nested data
- POST /api/classrooms create
- PATCH /api/classrooms/<id> partial update
- DELETE /api/classrooms/<id> delete
- GET /api/classrooms/<id>/students list roster
- POST /api/classrooms/<id>/students set/add students (body: {student_ids:[..], mode:'set'|'add'})
- DELETE /api/classrooms/<id>/students remove students (body: {student_ids:[..]})
- GET /api/classrooms/<id>/teachers list homeroom teachers
- POST /api/classrooms/<id>/teachers set/add teachers (body: {teacher_ids:[..], mode:'set'|'add'})
- GET /api/classrooms/<id>/courses list courses
- POST /api/classrooms/<id>/assign-course cascade course → batch + enroll students
- DELETE /api/classrooms/<id>/courses/<cid> detach course (does NOT delete batches/enrollments)
- GET /api/classrooms/<id>/batches list classroom batches
The legacy ``/api/groups`` aliases on op.classroom remain for backward
compatibility but new clients should target ``/api/classrooms``.
"""
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
@@ -8,89 +34,592 @@ from odoo.addons.encoach_api.controllers.base import (
_logger = logging.getLogger(__name__)
def _ser_classroom(r):
# ----------------------------------------------------------------------
# Entity scoping helpers (same shape used in branches.py / lms_core.py)
# ----------------------------------------------------------------------
def _entity_scope():
"""Same convention as ``encoach_lms_api.controllers.lms_core._entity_scope``."""
user = request.env.user.sudo()
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
if ids:
return ids, False
is_super = bool(
user.id == 1 or user.has_group('base.group_system')
)
return ids, is_super
def _ensure_entity_access(entity_id):
if not entity_id:
raise PermissionError('entity_id is required')
ids, is_super = _entity_scope()
if is_super:
return int(entity_id)
if not ids:
raise PermissionError('User is not linked to any entity')
if int(entity_id) not in ids:
raise PermissionError('Entity access denied')
return int(entity_id)
def _default_entity_id_from_scope():
ids, is_super = _entity_scope()
if ids:
return ids[0]
if is_super:
return False
raise PermissionError('User is not linked to any entity')
def _scoped_entity_domain(base_domain=None):
domain = list(base_domain or [])
ids, is_super = _entity_scope()
if is_super:
return domain
if not ids:
return domain + [('id', '=', 0)]
return domain + [('entity_id', 'in', ids)]
def _ensure_branch_access(branch_id, *, entity_id=None):
"""Validate that the branch exists, belongs to ``entity_id`` (when set)
and is accessible by the current user."""
if not branch_id:
return None
Branch = request.env['encoach.lms.branch'].sudo()
branch = Branch.browse(int(branch_id))
if not branch.exists():
raise ValueError('Branch not found')
_ensure_entity_access(branch.entity_id.id)
if entity_id and branch.entity_id.id != int(entity_id):
raise PermissionError('Branch does not belong to the requested entity.')
return branch.id
def _filter_ids_in_entity(model, ids, entity_id, *, label='record'):
"""Return ids that exist *and* belong to ``entity_id``.
When ``entity_id`` is falsy (e.g. legacy classroom without an entity), we
accept any record that has no ``entity_id`` set yet. This keeps the API
safe by default while still allowing administrators to operate on
pre-LMS-isolation data without surprises.
"""
if not ids:
return []
Model = request.env[model].sudo()
recs = Model.browse([int(x) for x in ids])
out = []
for rec in recs:
if not rec.exists():
continue
rec_entity = rec.entity_id.id if rec.entity_id else None
if entity_id and rec_entity and rec_entity != int(entity_id):
raise PermissionError(
f"{label} {rec.id} belongs to a different entity"
)
out.append(rec.id)
return out
# ----------------------------------------------------------------------
# Serializers
# ----------------------------------------------------------------------
def _ser_student_short(s):
return {
'id': r.id,
'name': r.name or '',
'code': getattr(r, 'code', '') or '',
'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0,
'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '',
'capacity': getattr(r, 'capacity', 0) or 0,
'id': s.id,
'name': s.name or '',
'email': s.email or '',
'gr_no': getattr(s, 'gr_no', '') or '',
}
def _ser_teacher_short(f):
return {
'id': f.id,
'name': f.name or '',
'email': getattr(f, 'email', '') or '',
}
def _ser_course_short(c):
return {
'id': c.id,
'name': c.name or '',
'code': c.code or '',
}
def _ser_section_short(s):
return {
'id': s.id,
'name': s.name or '',
'code': s.code or '',
'sequence': s.sequence or 0,
'course_id': s.course_id.id if s.course_id else None,
'course_name': s.course_id.name if s.course_id else '',
}
def _ser_batch_short(b):
return {
'id': b.id,
'name': b.name or '',
'code': b.code or '',
'course_id': b.course_id.id if b.course_id else None,
'course_name': b.course_id.name if b.course_id else '',
'course_section_id': (
b.course_section_id.id
if hasattr(b, 'course_section_id') and b.course_section_id
else None
),
'course_section_name': (
b.course_section_id.name
if hasattr(b, 'course_section_id') and b.course_section_id
else ''
),
'course_section_code': (
b.course_section_id.code
if hasattr(b, 'course_section_id') and b.course_section_id
else ''
),
'term_key': getattr(b, 'term_key', '') or '',
'start_date': b.start_date and str(b.start_date) or '',
'end_date': b.end_date and str(b.end_date) or '',
'student_count': getattr(b, 'student_count', 0) or 0,
'teacher_ids': b.teacher_ids.ids if hasattr(b, 'teacher_ids') else [],
}
def _ser_classroom(r, *, full=False):
data = {
'id': r.id,
'name': r.name or '',
'code': r.code or '',
'capacity': r.capacity or 0,
'active': bool(r.active),
'notes': getattr(r, 'notes', '') or '',
'entity_id': r.entity_id.id if r.entity_id else None,
'entity_name': r.entity_id.name if r.entity_id else '',
'branch_id': r.branch_id.id if r.branch_id else None,
'branch_name': r.branch_id.name if r.branch_id else '',
'student_count': r.student_count or 0,
'teacher_count': r.teacher_count or 0,
'course_count': r.course_count or 0,
'batch_count': r.batch_count or 0,
'student_ids': r.student_ids.ids,
'teacher_ids': r.teacher_ids.ids,
'course_ids': r.course_ids.ids,
}
if full:
data['students'] = [_ser_student_short(s) for s in r.student_ids]
data['teachers'] = [_ser_teacher_short(t) for t in r.teacher_ids]
data['courses'] = [_ser_course_short(c) for c in r.course_ids]
data['sections'] = [
_ser_section_short(s)
for s in request.env['encoach.course.section'].sudo().search([
('course_id', 'in', r.course_ids.ids)
], order='course_id, sequence, id')
]
data['batches'] = [_ser_batch_short(b) for b in r.batch_ids]
return data
def _classroom_or_404(cid):
rec = request.env['op.classroom'].sudo().browse(int(cid))
if not rec.exists():
return None
return rec
def _scoped_classroom_domain(base_domain=None):
"""Same as ``_scoped_entity_domain`` but tolerates legacy classrooms
without ``entity_id`` for backward-compatibility (so existing rooms
keep showing up to admins)."""
domain = list(base_domain or [])
ids, is_super = _entity_scope()
if is_super:
return domain
if not ids:
return domain + [('id', '=', 0)]
return domain + ['|', ('entity_id', 'in', ids), ('entity_id', '=', False)]
# ----------------------------------------------------------------------
# Controller
# ----------------------------------------------------------------------
class ClassroomsController(http.Controller):
# ---- CRUD --------------------------------------------------------
@http.route('/api/classrooms', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_classrooms(self, **kw):
try:
Classroom = request.env['op.classroom'].sudo()
domain = _scoped_classroom_domain([])
if kw.get('search'):
q = kw['search']
domain += ['|', ('name', 'ilike', q), ('code', 'ilike', q)]
if kw.get('active') in ('true', 'false', '1', '0'):
domain.append(('active', '=', kw.get('active') in ('true', '1')))
if kw.get('branch_id'):
domain.append(('branch_id', '=', int(kw['branch_id'])))
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
offset, limit, page = _paginate(kw)
total = Classroom.search_count(domain)
recs = Classroom.search(domain, offset=offset, limit=limit, order='name asc, id asc')
items = [_ser_classroom(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as exc:
_logger.exception('list_classrooms failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_classroom(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('get_classroom failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_classroom(self, **kw):
try:
body = _get_json_body()
name = (body.get('name') or '').strip()
if not name:
return _error_response('name is required', 400)
entity_id = (
_ensure_entity_access(int(body['entity_id']))
if body.get('entity_id') else _default_entity_id_from_scope()
)
branch_id = _ensure_branch_access(body.get('branch_id'), entity_id=entity_id) if body.get('branch_id') else None
code = (body.get('code') or '').strip() or name.upper().replace(' ', '_')[:16]
vals = {
'name': name,
'code': code,
'capacity': int(body.get('capacity') or 30),
'notes': (body.get('notes') or '').strip(),
'entity_id': entity_id,
'branch_id': branch_id or False,
}
if 'active' in body:
vals['active'] = bool(body.get('active'))
if body.get('student_ids'):
vals['student_ids'] = [(6, 0, [int(x) for x in body['student_ids']])]
if body.get('teacher_ids'):
vals['teacher_ids'] = [(6, 0, [int(x) for x in body['teacher_ids']])]
rec = request.env['op.classroom'].sudo().create(vals)
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('create_classroom failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_classroom(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = (body.get('name') or '').strip()
if 'code' in body:
vals['code'] = (body.get('code') or '').strip() or rec.code
if 'capacity' in body:
vals['capacity'] = int(body.get('capacity') or rec.capacity)
if 'notes' in body:
vals['notes'] = (body.get('notes') or '').strip()
if 'active' in body:
vals['active'] = bool(body.get('active'))
target_entity = rec.entity_id.id
if 'entity_id' in body and body.get('entity_id'):
target_entity = _ensure_entity_access(int(body['entity_id']))
vals['entity_id'] = target_entity
if 'branch_id' in body:
vals['branch_id'] = (
_ensure_branch_access(int(body['branch_id']), entity_id=target_entity)
if body.get('branch_id') else False
)
if vals:
rec.write(vals)
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('update_classroom failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_classroom(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if rec:
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
rec.unlink()
return _json_response({'success': True})
except Exception as exc:
_logger.exception('delete_classroom failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ---- Roster (students) ------------------------------------------
@http.route('/api/classrooms/<int:cid>/students', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_classroom_students(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
items = [_ser_student_short(s) for s in rec.student_ids]
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as exc:
_logger.exception('list_classroom_students failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>/students', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def set_classroom_students(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
body = _get_json_body()
raw_ids = [int(x) for x in (body.get('student_ids') or [])]
ids = _filter_ids_in_entity(
'op.student', raw_ids,
rec.entity_id.id if rec.entity_id else None,
label='Student',
)
mode = (body.get('mode') or 'set').lower()
if mode == 'set':
rec.write({'student_ids': [(6, 0, ids)]})
else:
rec.write({'student_ids': [(4, x) for x in ids]})
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('set_classroom_students failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>/students', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def remove_classroom_students(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
body = _get_json_body()
ids = [int(x) for x in (body.get('student_ids') or [])]
rec.write({'student_ids': [(3, x) for x in ids]})
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('remove_classroom_students failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ---- Homeroom teachers ------------------------------------------
@http.route('/api/classrooms/<int:cid>/teachers', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_classroom_teachers(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
items = [_ser_teacher_short(t) for t in rec.teacher_ids]
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as exc:
_logger.exception('list_classroom_teachers failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>/teachers', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def set_classroom_teachers(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
body = _get_json_body()
raw_ids = [int(x) for x in (body.get('teacher_ids') or [])]
ids = _filter_ids_in_entity(
'op.faculty', raw_ids,
rec.entity_id.id if rec.entity_id else None,
label='Teacher',
)
mode = (body.get('mode') or 'set').lower()
if mode == 'set':
rec.write({'teacher_ids': [(6, 0, ids)]})
else:
rec.write({'teacher_ids': [(4, x) for x in ids]})
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('set_classroom_teachers failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ---- Courses + cascade ------------------------------------------
@http.route('/api/classrooms/<int:cid>/courses', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_classroom_courses(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
items = [_ser_course_short(c) for c in rec.course_ids]
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as exc:
_logger.exception('list_classroom_courses failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>/sections', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_classroom_sections(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
domain = [('course_id', 'in', rec.course_ids.ids)]
if kw.get('course_id'):
domain.append(('course_id', '=', int(kw['course_id'])))
if kw.get('active') in ('true', 'false', '1', '0'):
domain.append(('active', '=', kw.get('active') in ('true', '1')))
items = [
_ser_section_short(s)
for s in request.env['encoach.course.section'].sudo().search(
domain, order='course_id, sequence, id'
)
]
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as exc:
_logger.exception('list_classroom_sections failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>/assign-course', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def assign_classroom_course(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
body = _get_json_body()
course_id = int(body.get('course_id') or 0)
if not course_id:
return _error_response('course_id is required', 400)
classroom_entity_id = rec.entity_id.id if rec.entity_id else None
_filter_ids_in_entity('op.course', [course_id], classroom_entity_id, label='Course')
section_id = int(body.get('section_id') or 0) or None
if section_id:
_filter_ids_in_entity(
'encoach.course.section', [section_id],
classroom_entity_id, label='Course section',
)
teacher_ids = body.get('teacher_ids')
if teacher_ids is not None:
teacher_ids = _filter_ids_in_entity(
'op.faculty', [int(x) for x in teacher_ids],
classroom_entity_id, label='Teacher',
)
term_name = body.get('term_name') or None
term_key = (body.get('term_key') or '').strip() or None
start_date = body.get('start_date') or None
end_date = body.get('end_date') or None
batch = rec.assign_course(
course_id,
term_name=term_name,
start_date=start_date,
end_date=end_date,
teacher_ids=teacher_ids,
section_id=section_id,
term_key=term_key,
)
return _json_response({
'data': _ser_classroom(rec, full=True),
'batch': _ser_batch_short(batch),
})
except Exception as exc:
_logger.exception('assign_classroom_course failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/classrooms/<int:cid>/assign-section', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def assign_classroom_section(self, cid, **kw):
"""Alias to make the classroom×section workflow explicit for clients."""
return self.assign_classroom_course(cid, **kw)
@http.route('/api/classrooms/<int:cid>/courses/<int:course_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def detach_classroom_course(self, cid, course_id, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
rec.write({'course_ids': [(3, int(course_id))]})
return _json_response({'data': _ser_classroom(rec, full=True)})
except Exception as exc:
_logger.exception('detach_classroom_course failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ---- Batches -----------------------------------------------------
@http.route('/api/classrooms/<int:cid>/batches', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_classroom_batches(self, cid, **kw):
try:
rec = _classroom_or_404(cid)
if not rec:
return _error_response('Not found', 404)
if rec.entity_id:
_ensure_entity_access(rec.entity_id.id)
items = [_ser_batch_short(b) for b in rec.batch_ids]
return _json_response({'items': items, 'data': items, 'total': len(items)})
except Exception as exc:
_logger.exception('list_classroom_batches failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ---- Legacy /api/groups aliases (back-compat) -------------------
@http.route('/api/groups', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_groups(self, **kw):
try:
M = request.env['op.classroom'].sudo()
offset, limit, page = _paginate(kw)
total = M.search_count([])
recs = M.search([], offset=offset, limit=limit, order='id desc')
items = [_ser_classroom(r) for r in recs]
return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit})
except Exception as e:
return _error_response(str(e), 500)
return self.list_classrooms(**kw)
@http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_group(self, gid, **kw):
try:
rec = request.env['op.classroom'].sudo().browse(gid)
if not rec.exists():
return _error_response('Not found', 404)
return _json_response(_ser_classroom(rec))
except Exception as e:
return _error_response(str(e), 500)
return self.get_classroom(gid, **kw)
@http.route('/api/groups', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_group(self, **kw):
try:
body = _get_json_body()
vals = {'name': body.get('name', '')}
if body.get('code'):
vals['code'] = body['code']
if body.get('capacity'):
vals['capacity'] = int(body['capacity'])
rec = request.env['op.classroom'].sudo().create(vals)
return _json_response(_ser_classroom(rec))
except Exception as e:
return _error_response(str(e), 500)
return self.create_classroom(**kw)
@http.route('/api/groups/<int:gid>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def delete_group(self, gid, **kw):
try:
rec = request.env['op.classroom'].sudo().browse(gid)
if rec.exists():
rec.unlink()
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/groups/transfer', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def transfer(self, **kw):
try:
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/groups/<int:gid>/members', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def add_members(self, gid, **kw):
try:
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/groups/<int:gid>/members/remove', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def remove_members(self, gid, **kw):
try:
return _json_response({'success': True})
except Exception as e:
return _error_response(str(e), 500)
return self.delete_classroom(gid, **kw)

View File

@@ -18,6 +18,8 @@ def _ser_board(b):
'batch_name': b.batch_id.name if b.batch_id else None,
'chapter_id': b.chapter_id.id if b.chapter_id else None,
'chapter_name': b.chapter_id.name if b.chapter_id else None,
'plan_id': b.plan_id.id if b.plan_id else None,
'plan_name': b.plan_id.name if b.plan_id else None,
'is_enabled': b.is_enabled,
'allow_student_posts': b.allow_student_posts,
'post_count': b.post_count or 0,
@@ -97,6 +99,8 @@ class CommunicationController(http.Controller):
domain.append(('course_id', '=', int(kw['course_id'])))
if kw.get('batch_id'):
domain.append(('batch_id', '=', int(kw['batch_id'])))
if kw.get('plan_id'):
domain.append(('plan_id', '=', int(kw['plan_id'])))
recs = M.search(domain, limit=200, order='id desc')
return _json_response([_ser_board(r) for r in recs])
except Exception as e:
@@ -112,11 +116,55 @@ class CommunicationController(http.Controller):
vals['course_id'] = int(body['course_id'])
if body.get('batch_id'):
vals['batch_id'] = int(body['batch_id'])
if body.get('plan_id'):
vals['plan_id'] = int(body['plan_id'])
rec = request.env['encoach.discussion.board'].sudo().create(vals)
return _json_response(_ser_board(rec))
except Exception as e:
return _error_response(str(e), 500)
# Phase 1: course/plan-scoped board lookup-or-create.
# Returns the board attached to a given course or plan; creates
# one transparently the first time someone opens the discussion
# tab inside a course detail page.
@http.route('/api/discussion-boards/for-course/<int:course_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def board_for_course(self, course_id, **kw):
try:
M = request.env['encoach.discussion.board'].sudo()
board = M.search([('course_id', '=', course_id), ('plan_id', '=', False)], limit=1)
if not board:
Course = request.env['op.course'].sudo().browse(course_id)
if not Course.exists():
return _error_response('Course not found', 404)
board = M.create({
'name': f'Discussion — {Course.name or "Course"}',
'course_id': course_id,
})
return _json_response(_ser_board(board))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/discussion-boards/for-plan/<int:plan_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def board_for_plan(self, plan_id, **kw):
try:
M = request.env['encoach.discussion.board'].sudo()
board = M.search([('plan_id', '=', plan_id)], limit=1)
if not board:
Plan = request.env['encoach.course.plan'].sudo().browse(plan_id)
if not Plan.exists():
return _error_response('Plan not found', 404)
board = M.create({
'name': f'Discussion — {Plan.name or "Course Plan"}',
'plan_id': plan_id,
})
return _json_response(_ser_board(board))
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/discussion-boards/<int:bid>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_board(self, bid, **kw):

View File

@@ -0,0 +1,282 @@
"""Online-test SOFT security controller.
* `POST /api/exam/security/event` — student client posts a
suspicious event during an attempt.
* `POST /api/exam/single-attempt-check` — student client checks
whether a fresh attempt is allowed for an exam.
* `GET /api/teacher/exam/<assignment_id>/security/events` —
teacher console reads the per-assignment log.
* `GET /api/teacher/exam/attempt/<attempt_id>/security/events` —
same, scoped to a specific attempt.
* `GET /api/teacher/exam/<assignment_id>/live` — list of in-progress
attempts + latest event per attempt.
"""
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
VALID_EVENT_TYPES = {
'tab_blur', 'tab_focus', 'window_blur', 'window_focus',
'paste', 'copy', 'cut', 'context_menu', 'fullscreen_exit',
'shortcut', 'multi_attempt_block', 'other',
}
def _parse_body():
try:
body = request.httprequest.get_data(as_text=True)
return json.loads(body) if body else {}
except (TypeError, ValueError):
return {}
def _ser_event(ev):
payload = None
if ev.payload:
try:
payload = json.loads(ev.payload)
except (TypeError, ValueError):
payload = ev.payload
return {
'id': ev.id,
'attempt_id': ev.attempt_id.id if ev.attempt_id else None,
'student_id': ev.student_id.id,
'student_name': ev.student_id.name or ev.student_id.login or '',
'exam_id': ev.exam_id.id if ev.exam_id else None,
'event_type': ev.event_type,
'severity': ev.severity,
'occurred_at': ev.occurred_at.isoformat() if ev.occurred_at else None,
'payload': payload,
}
class ExamSecurityController(http.Controller):
@http.route('/api/exam/security/event',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def post_event(self, **_kw):
try:
body = _parse_body()
event_type = (body.get('event_type') or '').strip().lower()
if event_type not in VALID_EVENT_TYPES:
return _error_response('Unknown event_type', 400)
attempt_id = body.get('attempt_id')
attempt = None
if attempt_id:
attempt = request.env['encoach.student.attempt'].sudo() \
.browse(int(attempt_id))
if not attempt.exists():
attempt = None
user = request.env.user
if attempt and attempt.student_id and attempt.student_id.id != user.id:
return _error_response(
'Cannot log events for another user\'s attempt', 403,
)
severity = body.get('severity') or 'warning'
if severity not in ('info', 'warning', 'alert'):
severity = 'warning'
payload_obj = body.get('payload')
payload_str = None
if payload_obj is not None:
try:
payload_str = json.dumps(payload_obj)[:4000]
except (TypeError, ValueError):
payload_str = None
Ev = request.env['encoach.exam.security.event'].sudo()
ev = Ev.create({
'attempt_id': attempt.id if attempt else False,
'student_id': user.id,
'exam_id': (attempt.exam_id.id if attempt and attempt.exam_id
else (int(body['exam_id']) if body.get('exam_id') else False)),
'event_type': event_type,
'severity': severity,
'payload': payload_str,
})
auto_locked = False
if attempt and attempt.exam_id:
exam = attempt.exam_id
level = getattr(exam, 'security_level', 'soft') or 'soft'
threshold = getattr(exam, 'suspicious_event_threshold', 5) or 5
if level == 'strict' and severity == 'alert':
alerts = Ev.search_count([
('attempt_id', '=', attempt.id),
('severity', '=', 'alert'),
])
if alerts >= threshold and attempt.status == 'in_progress':
attempt.write({
'status': 'completed',
'completed_at': ev.occurred_at,
})
auto_locked = True
return _json_response({
'event_id': ev.id,
'auto_locked': auto_locked,
})
except Exception as e:
_logger.exception('exam.security.event failed')
return _error_response(str(e), 500)
@http.route('/api/exam/single-attempt-check',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def single_attempt_check(self, **kw):
try:
exam_id_raw = kw.get('exam_id')
if not exam_id_raw:
return _error_response('exam_id required', 400)
exam = request.env['encoach.exam.custom'].sudo() \
.browse(int(exam_id_raw))
if not exam.exists():
return _error_response('Exam not found', 404)
user = request.env.user
single = bool(getattr(exam, 'single_attempt', True))
existing = request.env['encoach.student.attempt'].sudo().search([
('exam_id', '=', exam.id),
('student_id', '=', user.id),
('status', 'in', (
'completed', 'scoring', 'scored', 'released',
'pending_approval',
)),
], limit=1)
allowed = not (single and existing)
if not allowed:
request.env['encoach.exam.security.event'].sudo().create({
'student_id': user.id,
'exam_id': exam.id,
'event_type': 'multi_attempt_block',
'severity': 'alert',
})
return _json_response({
'allowed': allowed,
'security_level': getattr(exam, 'security_level', 'soft'),
'single_attempt': single,
'existing_attempt_id': existing.id if existing else None,
})
except Exception as e:
_logger.exception('single_attempt_check failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/exam/<int:assignment_id>/security/events',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def assignment_events(self, assignment_id, **kw):
try:
Assign = request.env['encoach.exam.assignment'].sudo()
assign = Assign.browse(assignment_id)
if not assign.exists():
return _error_response('Assignment not found', 404)
domain = [('exam_id', '=', assign.exam_id.id)] if assign.exam_id else []
if assign.student_id:
domain.append(('student_id', '=', assign.student_id.id))
elif assign.batch_id:
Batch = request.env['op.batch'].sudo().browse(assign.batch_id.id)
course_regs = request.env['op.student.course'].sudo().search([
('batch_id', '=', Batch.id),
])
user_ids = [r.student_id.user_id.id for r in course_regs
if r.student_id and r.student_id.user_id]
if user_ids:
domain.append(('student_id', 'in', user_ids))
Ev = request.env['encoach.exam.security.event'].sudo()
events = Ev.search(domain, limit=500)
return _json_response({
'items': [_ser_event(e) for e in events],
'total': len(events),
})
except Exception as e:
_logger.exception('assignment_events failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/exam/attempt/<int:attempt_id>/security/events',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def attempt_events(self, attempt_id, **kw):
try:
Att = request.env['encoach.student.attempt'].sudo()
attempt = Att.browse(attempt_id)
if not attempt.exists():
return _error_response('Attempt not found', 404)
Ev = request.env['encoach.exam.security.event'].sudo()
events = Ev.search([('attempt_id', '=', attempt.id)], limit=500)
return _json_response({
'attempt_id': attempt.id,
'student_id': attempt.student_id.id if attempt.student_id else None,
'student_name': (attempt.student_id.name
or attempt.student_id.login or ''),
'exam_id': attempt.exam_id.id if attempt.exam_id else None,
'status': attempt.status,
'items': [_ser_event(e) for e in events],
'total': len(events),
})
except Exception as e:
_logger.exception('attempt_events failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/exam/<int:assignment_id>/live',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def live_test_console(self, assignment_id, **kw):
try:
Assign = request.env['encoach.exam.assignment'].sudo()
assign = Assign.browse(assignment_id)
if not assign.exists() or not assign.exam_id:
return _error_response('Assignment not found', 404)
Att = request.env['encoach.student.attempt'].sudo()
attempts = Att.search([
('exam_id', '=', assign.exam_id.id),
('status', '=', 'in_progress'),
])
Ev = request.env['encoach.exam.security.event'].sudo()
items = []
for att in attempts:
last = Ev.search([
('attempt_id', '=', att.id),
], limit=1, order='occurred_at desc')
alerts = Ev.search_count([
('attempt_id', '=', att.id),
('severity', '=', 'alert'),
])
items.append({
'attempt_id': att.id,
'student_id': att.student_id.id if att.student_id else None,
'student_name': (att.student_id.name
or att.student_id.login or ''),
'started_at': (att.started_at.isoformat()
if att.started_at else None),
'alert_count': alerts,
'last_event': _ser_event(last) if last else None,
})
return _json_response({
'assignment_id': assign.id,
'exam_id': assign.exam_id.id,
'in_progress': items,
'total': len(items),
})
except Exception as e:
_logger.exception('live_test_console failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,246 @@
"""Handwritten solution upload + AI grading controller.
* `POST /api/student/handwritten/<material_id>` — upload pages.
* `GET /api/student/handwritten/<material_id>` — fetch my latest.
* `GET /api/teacher/handwritten/<material_id>` — list submissions.
* `POST /api/teacher/handwritten/<submission_id>/review` — teacher overrides AI.
Image uploads use multipart/form-data so we can stream the raw
JPEG/PNG bytes straight into ir.attachment without round-tripping
through base64.
"""
import base64
import json
import logging
from odoo import http, fields as oo_fields
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _ser_submission(s):
return {
'id': s.id,
'student_id': s.student_id.id if s.student_id else None,
'student_name': s.student_id.name or s.student_id.login or '',
'material_id': s.material_id.id if s.material_id else None,
'plan_id': s.plan_id.id if s.plan_id else None,
'submitted_at': s.submitted_at.isoformat() if s.submitted_at else None,
'status': s.status,
'student_note': s.student_note or '',
'image_count': len(s.image_attachment_ids),
'image_urls': [
f'/web/content/{a.id}?download=true'
for a in s.image_attachment_ids
],
'ai_score': s.ai_score,
'ai_feedback': s.ai_feedback or '',
'ai_extracted_text': s.ai_extracted_text or '',
'teacher_score': s.teacher_score,
'teacher_feedback': s.teacher_feedback or '',
'reviewed_by': s.reviewed_by.name if s.reviewed_by else '',
'reviewed_at': s.reviewed_at.isoformat() if s.reviewed_at else None,
}
def _grade_with_ai(submission):
"""Call OCR + LLM to grade the handwritten pages.
Falls back gracefully when the AI service is unavailable so the
submission still records (status='failed') instead of 500-ing.
"""
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
except ImportError:
submission.write({
'status': 'failed',
'ai_feedback': 'AI grading service not installed.',
})
return
extracted_chunks = []
try:
try:
import pytesseract
from PIL import Image
import io
for att in submission.image_attachment_ids:
if not att.datas:
continue
try:
raw = base64.b64decode(att.datas)
img = Image.open(io.BytesIO(raw))
text = pytesseract.image_to_string(img) or ''
if text.strip():
extracted_chunks.append(text.strip())
except Exception:
_logger.warning('OCR failed for attachment %s', att.id,
exc_info=True)
except ImportError:
extracted_chunks.append(
'[OCR not installed — relying on student note only.]'
)
extracted = '\n\n'.join(extracted_chunks).strip() \
or (submission.student_note or '')
ai = OpenAIService(request.env, language='en')
prompt_user = (
"You are a math/science assignment grader. Grade the "
"following handwritten solution from 0 to 100 and return "
"valid JSON: {\"score\": float, \"feedback\": str}.\n\n"
f"Student note:\n{submission.student_note or '(none)'}\n\n"
f"OCR-extracted handwriting:\n{extracted or '(empty)'}\n"
)
try:
data = ai.chat_json(
system="You are a strict but fair math/science grader.",
user=prompt_user,
action='handwritten.grade',
)
except Exception as e:
submission.write({
'status': 'failed',
'ai_extracted_text': extracted,
'ai_feedback': f'AI grading error: {e}',
})
return
score = float(data.get('score') or 0)
feedback = data.get('feedback') or ''
submission.write({
'status': 'graded',
'ai_score': score,
'ai_feedback': feedback,
'ai_extracted_text': extracted,
})
except Exception as e:
_logger.exception('handwritten grading failed')
submission.write({
'status': 'failed',
'ai_feedback': f'Grading pipeline crashed: {e}',
})
class HandwrittenController(http.Controller):
@http.route('/api/student/handwritten/<int:material_id>',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def upload(self, material_id, **kw):
try:
user = request.env.user
Material = request.env['encoach.course.plan.material'].sudo()
material = Material.browse(material_id)
if not material.exists():
return _error_response('Material not found', 404)
files = request.httprequest.files.getlist('pages') \
or request.httprequest.files.getlist('file') \
or []
if not files:
return _error_response('At least one image required', 400)
student_note = (request.params.get('note') or '').strip()
Att = request.env['ir.attachment'].sudo()
att_ids = []
for f in files[:10]:
raw = f.read()
if not raw:
continue
rec = Att.create({
'name': f.filename or 'handwritten.jpg',
'type': 'binary',
'datas': base64.b64encode(raw),
'mimetype': f.mimetype or 'image/jpeg',
'res_model': 'encoach.handwritten.submission',
'res_id': 0,
})
att_ids.append(rec.id)
Sub = request.env['encoach.handwritten.submission'].sudo()
sub = Sub.create({
'student_id': user.id,
'material_id': material.id,
'student_note': student_note,
'image_attachment_ids': [(6, 0, att_ids)],
'status': 'submitted',
})
for aid in att_ids:
request.env['ir.attachment'].sudo().browse(aid).res_id = sub.id
sub.status = 'grading'
try:
_grade_with_ai(sub)
except Exception:
_logger.exception('async grading inline failed')
return _json_response(_ser_submission(sub), 201)
except Exception as e:
_logger.exception('handwritten upload failed')
return _error_response(str(e), 500)
@http.route('/api/student/handwritten/<int:material_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def my_submission(self, material_id, **kw):
try:
user = request.env.user
Sub = request.env['encoach.handwritten.submission'].sudo()
sub = Sub.search([
('material_id', '=', material_id),
('student_id', '=', user.id),
], limit=1, order='submitted_at desc')
return _json_response({
'submission': _ser_submission(sub) if sub else None,
})
except Exception as e:
_logger.exception('my_handwritten_submission failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/handwritten/<int:material_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_submissions(self, material_id, **kw):
try:
Sub = request.env['encoach.handwritten.submission'].sudo()
subs = Sub.search([('material_id', '=', material_id)])
return _json_response({
'items': [_ser_submission(s) for s in subs],
'total': len(subs),
})
except Exception as e:
_logger.exception('list_handwritten failed')
return _error_response(str(e), 500)
@http.route('/api/teacher/handwritten/<int:submission_id>/review',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def review_submission(self, submission_id, **_kw):
try:
Sub = request.env['encoach.handwritten.submission'].sudo()
sub = Sub.browse(submission_id)
if not sub.exists():
return _error_response('Submission not found', 404)
try:
raw = request.httprequest.get_data(as_text=True)
body = json.loads(raw) if raw else {}
except (TypeError, ValueError):
body = {}
score = body.get('score')
feedback = body.get('feedback') or ''
sub.write({
'teacher_score': float(score) if score is not None else False,
'teacher_feedback': feedback,
'reviewed_by': request.env.user.id,
'reviewed_at': oo_fields.Datetime.now(),
'status': 'reviewed',
})
return _json_response(_ser_submission(sub))
except Exception as e:
_logger.exception('review_handwritten failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,705 @@
"""Live (Jitsi-backed) classroom sessions controller.
Endpoints (Phase 3 — 2026-04-30):
* `GET /api/live-sessions` — list (filters)
* `POST /api/live-sessions` — create
* `GET /api/live-sessions/<id>` — read
* `PATCH /api/live-sessions/<id>` — update
* `DELETE /api/live-sessions/<id>` — cancel
* `POST /api/live-sessions/<id>/notify` — email enrolled students
* `POST /api/live-sessions/<id>/join` — student/teacher posts on entering Jitsi
* `POST /api/live-sessions/<id>/leave` — student/teacher posts on leaving Jitsi
* `POST /api/live-sessions/<id>/end` — host ends session
* `POST /api/live-sessions/<id>/recording` — host stores recording URL
* `POST /api/live-sessions/<id>/remove-participant` — host kicks user (with reason)
* `GET /api/live-sessions/<id>/attendance` — host attendance roll
"""
import base64
import json
import logging
import secrets
from datetime import datetime, timedelta
from odoo import http, fields as oo_fields
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _parse_body():
try:
body = request.httprequest.get_data(as_text=True)
return json.loads(body) if body else {}
except (TypeError, ValueError):
return {}
def _parse_dt(raw):
"""Coerce an ISO-8601 / Odoo / 'YYYY-MM-DDTHH:MM' datetime string
into the format Odoo persists ('%Y-%m-%d %H:%M:%S')."""
if not raw:
return raw
if isinstance(raw, datetime):
return raw.strftime('%Y-%m-%d %H:%M:%S')
s = str(raw).strip()
# Strip a trailing Z and turn 'T' into ' '.
if s.endswith('Z'):
s = s[:-1]
s = s.replace('T', ' ')
if '.' in s:
s = s.split('.', 1)[0]
if '+' in s[10:]:
s = s.rsplit('+', 1)[0]
if len(s) == 16:
s = s + ':00'
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M'):
try:
return datetime.strptime(s, fmt).strftime('%Y-%m-%d %H:%M:%S')
except ValueError:
continue
return s
def _ser_session(s, env=None):
return {
'id': s.id,
'title': s.title or '',
'description': s.description or '',
'course_id': s.course_id.id if s.course_id else None,
'course_name': s.course_id.name if s.course_id else '',
'batch_id': s.batch_id.id if s.batch_id else None,
'batch_name': s.batch_id.name if s.batch_id else '',
'plan_id': s.plan_id.id if s.plan_id else None,
'plan_name': s.plan_id.name if s.plan_id else '',
'host_id': s.host_id.id if s.host_id else None,
'host_name': s.host_id.name if s.host_id else '',
'scheduled_at': s.scheduled_at.isoformat() if s.scheduled_at else None,
'duration_min': s.duration_min,
'actual_started_at': (s.actual_started_at.isoformat()
if s.actual_started_at else None),
'actual_ended_at': (s.actual_ended_at.isoformat()
if s.actual_ended_at else None),
'status': s.status,
'room_name': s.room_name,
'waiting_room': s.waiting_room,
'recording_enabled': s.recording_enabled,
'recording_url': s.recording_url or '',
'auto_attendance_minutes': s.auto_attendance_minutes,
'late_entry_minutes': s.late_entry_minutes,
'notification_sent': s.notification_sent,
'participant_count': len(s.participant_ids),
}
def _ser_participant(p):
return {
'id': p.id,
'session_id': p.session_id.id,
'user_id': p.user_id.id,
'user_name': p.user_id.name or p.user_id.login or '',
'joined_at': p.joined_at.isoformat() if p.joined_at else None,
'left_at': p.left_at.isoformat() if p.left_at else None,
'duration_seconds': p.duration_seconds,
'attendance_status': p.attendance_status,
'removed_reason': p.removed_reason or '',
'is_host': p.is_host,
}
def _enrolled_user_ids(session):
"""Return res.users IDs that should be notified / can join."""
user_ids = set()
if session.batch_id:
regs = request.env['op.student.course'].sudo().search([
('batch_id', '=', session.batch_id.id),
])
for r in regs:
if r.student_id and r.student_id.user_id:
user_ids.add(r.student_id.user_id.id)
elif session.course_id:
regs = request.env['op.student.course'].sudo().search([
('course_id', '=', session.course_id.id),
])
for r in regs:
if r.student_id and r.student_id.user_id:
user_ids.add(r.student_id.user_id.id)
if session.plan_id:
Assign = request.env['encoach.course.plan.assignment'].sudo() \
if 'encoach.course.plan.assignment' in request.env else None
if Assign is not None:
try:
ass = Assign.search([('plan_id', '=', session.plan_id.id)])
for a in ass:
if a.user_id:
user_ids.add(a.user_id.id)
except Exception:
pass
if session.host_id:
user_ids.discard(session.host_id.id)
return list(user_ids)
def _build_ics(session, recipient_email=''):
"""Return raw iCalendar bytes for the session.
Adding an .ics attachment lets every modern mail client (Gmail,
Outlook, Apple Mail, mobile clients) drop the session straight
into the recipient's calendar with one click — addressing the
"Advance scheduling + calendar sync" requirement without us
having to OAuth into anyone's Google/Apple calendar.
"""
def _fmt(dt):
if not dt:
return ''
if isinstance(dt, str):
try:
dt = datetime.strptime(dt, '%Y-%m-%d %H:%M:%S')
except ValueError:
try:
dt = datetime.fromisoformat(dt)
except ValueError:
return ''
return dt.strftime('%Y%m%dT%H%M%SZ')
def _esc(s):
return (s or '').replace('\\', '\\\\').replace(',', '\\,') \
.replace(';', '\\;').replace('\n', '\\n')
start = session.scheduled_at
if isinstance(start, str):
try:
start = datetime.strptime(start, '%Y-%m-%d %H:%M:%S')
except ValueError:
start = datetime.utcnow()
end = start + timedelta(minutes=session.duration_min or 60)
uid = f'live-{session.id}-{secrets.token_hex(4)}@encoach'
description = (
f"Live session hosted by {session.host_id.name}.\\n"
f"Course: {session.course_id.name or session.plan_id.name or ''}\\n"
f"Open from your dashboard when it starts."
)
base_url = (request.env['ir.config_parameter'].sudo()
.get_param('web.base.url') or '').rstrip('/')
location = f'{base_url}/live/{session.id}' if base_url else f'/live/{session.id}'
lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//EnCoach//Live Sessions//EN',
'CALSCALE:GREGORIAN',
'METHOD:REQUEST',
'BEGIN:VEVENT',
f'UID:{uid}',
f'DTSTAMP:{_fmt(datetime.utcnow())}',
f'DTSTART:{_fmt(start)}',
f'DTEND:{_fmt(end)}',
f'SUMMARY:{_esc(session.title)}',
f'DESCRIPTION:{description}',
f'LOCATION:{_esc(location)}',
f'ORGANIZER;CN={_esc(session.host_id.name)}:mailto:{session.host_id.email or "noreply@encoach.test"}',
]
if recipient_email:
lines.append(
f'ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:'
f'mailto:{recipient_email}'
)
lines.extend([
'STATUS:CONFIRMED',
'BEGIN:VALARM',
'TRIGGER:-PT15M',
'ACTION:DISPLAY',
f'DESCRIPTION:Reminder: {_esc(session.title)}',
'END:VALARM',
'END:VEVENT',
'END:VCALENDAR',
])
return '\r\n'.join(lines).encode('utf-8')
def _send_session_email(session):
"""Send an HTML invite + .ics attachment to every enrolled student."""
user_ids = _enrolled_user_ids(session)
if not user_ids:
return 0
Mail = request.env['mail.mail'].sudo()
Users = request.env['res.users'].sudo()
Att = request.env['ir.attachment'].sudo()
sent = 0
base_url = (request.env['ir.config_parameter'].sudo()
.get_param('web.base.url') or '').rstrip('/')
join_url = f'{base_url}/live/{session.id}' if base_url else f'/live/{session.id}'
for user in Users.browse(user_ids):
if not user.email:
continue
body = (
f"<p>Hi {user.name},</p>"
f"<p>You have a new live session: <b>{session.title}</b>.</p>"
f"<p>When: {session.scheduled_at} ({session.duration_min} min)<br/>"
f"Course: {session.course_id.name or session.plan_id.name or ''}<br/>"
f"Host: {session.host_id.name}</p>"
f'<p><a href="{join_url}" '
f'style="display:inline-block;background:#1f2937;color:#fff;'
f'padding:10px 16px;border-radius:8px;text-decoration:none">'
f'Join from dashboard</a></p>'
f"<p style=\"color:#666;font-size:12px\">"
f"The attached .ics will add this session to your calendar.</p>"
)
try:
ics_bytes = _build_ics(session, recipient_email=user.email)
att = Att.create({
'name': f'session-{session.id}.ics',
'type': 'binary',
'datas': base64.b64encode(ics_bytes),
'mimetype': 'text/calendar; method=REQUEST; charset=utf-8',
'res_model': 'encoach.live.session',
'res_id': session.id,
})
mail = Mail.create({
'subject': f'[EnCoach] {session.title}',
'body_html': body,
'email_to': user.email,
'email_from': (request.env['ir.config_parameter'].sudo()
.get_param('mail.from') or 'noreply@encoach.test'),
'attachment_ids': [(4, att.id)],
})
mail.send()
sent += 1
except Exception:
_logger.warning('failed to send session invite to %s',
user.email, exc_info=True)
return sent
def _user_can_host(user, session):
if not user or not user.id:
return False
if user.id == session.host_id.id:
return True
if user.has_group('base.group_system'):
return True
user_type = getattr(user, 'user_type', '') or ''
return user_type in ('admin', 'corporate', 'mastercorporate', 'developer')
class LiveSessionController(http.Controller):
# ── List + create ──────────────────────────────────────────────
@http.route('/api/live-sessions',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def list_sessions(self, **kw):
try:
Sess = request.env['encoach.live.session'].sudo()
domain = []
for f in ('course_id', 'batch_id', 'plan_id', 'host_id'):
v = kw.get(f)
if v:
try:
domain.append((f, '=', int(v)))
except (TypeError, ValueError):
pass
status = kw.get('status')
if status:
domain.append(('status', '=', status))
mine = (kw.get('mine') or '').lower() in ('1', 'true', 'yes')
if mine:
user = request.env.user
regs = request.env['op.student.course'].sudo().search([
('student_id.user_id', '=', user.id),
])
course_ids = list({r.course_id.id for r in regs if r.course_id})
batch_ids = list({r.batch_id.id for r in regs if r.batch_id})
or_clauses = []
if course_ids:
or_clauses.append(('course_id', 'in', course_ids))
if batch_ids:
or_clauses.append(('batch_id', 'in', batch_ids))
or_clauses.append(('host_id', '=', user.id))
if len(or_clauses) > 1:
domain.extend(['|'] * (len(or_clauses) - 1))
domain.extend(or_clauses)
sessions = Sess.search(domain, order='scheduled_at desc', limit=200)
return _json_response({
'items': [_ser_session(s) for s in sessions],
'total': len(sessions),
})
except Exception as e:
_logger.exception('list_sessions failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def create_session(self, **_kw):
try:
body = _parse_body()
vals = {
'title': body.get('title') or 'Live session',
'description': body.get('description') or '',
'scheduled_at': _parse_dt(body.get('scheduled_at')),
'duration_min': int(body.get('duration_min') or 60),
'host_id': request.env.user.id,
'waiting_room': bool(body.get('waiting_room', True)),
'recording_enabled': bool(body.get('recording_enabled', False)),
'auto_attendance_minutes': int(body.get('auto_attendance_minutes') or 10),
'late_entry_minutes': int(body.get('late_entry_minutes') or 15),
}
for f in ('course_id', 'batch_id', 'plan_id', 'entity_id'):
if body.get(f):
try:
vals[f] = int(body[f])
except (TypeError, ValueError):
pass
if not vals.get('scheduled_at'):
return _error_response('scheduled_at required', 400)
sess = request.env['encoach.live.session'].sudo().create(vals)
if body.get('notify', True):
try:
sent = _send_session_email(sess)
sess.notification_sent = bool(sent)
except Exception:
_logger.warning('email batch failed', exc_info=True)
return _json_response(_ser_session(sess), 201)
except Exception as e:
_logger.exception('create_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def read_session(self, session_id, **kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
payload = _ser_session(sess)
payload['participants'] = [_ser_participant(p)
for p in sess.participant_ids]
payload['enrolled_user_ids'] = _enrolled_user_ids(sess)
return _json_response(payload)
except Exception as e:
_logger.exception('read_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>',
type='http', auth='public', methods=['PATCH'], csrf=False)
@jwt_required
def patch_session(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
body = _parse_body()
vals = {}
for f in ('title', 'description', 'duration_min',
'waiting_room', 'recording_enabled',
'auto_attendance_minutes', 'late_entry_minutes',
'status', 'recording_url'):
if f in body:
vals[f] = body[f]
if 'scheduled_at' in body:
vals['scheduled_at'] = _parse_dt(body['scheduled_at'])
sess.write(vals)
return _json_response(_ser_session(sess))
except Exception as e:
_logger.exception('patch_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>',
type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required
def cancel_session(self, session_id, **kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
sess.status = 'cancelled'
return _json_response({'ok': True})
except Exception as e:
_logger.exception('cancel_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/ics',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def session_ics(self, session_id, **kw):
"""Download a single .ics file the user can drop into their calendar."""
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
email = request.env.user.email or ''
ics = _build_ics(sess, recipient_email=email)
return request.make_response(ics, headers=[
('Content-Type', 'text/calendar; method=REQUEST; charset=utf-8'),
('Content-Disposition',
f'attachment; filename="session-{sess.id}.ics"'),
])
except Exception as e:
_logger.exception('session_ics failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/notify',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def notify_session(self, session_id, **kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
sent = _send_session_email(sess)
sess.notification_sent = True
return _json_response({'sent': sent})
except Exception as e:
_logger.exception('notify_session failed')
return _error_response(str(e), 500)
# ── Lifecycle: join / leave / end ──────────────────────────────
@http.route('/api/live-sessions/<int:session_id>/join',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def join_session(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if sess.status == 'cancelled':
return _error_response('Session was cancelled', 400)
user = request.env.user
now = oo_fields.Datetime.now()
is_host = user.id == sess.host_id.id
if not is_host and sess.late_entry_minutes and sess.actual_started_at:
cutoff = sess.actual_started_at + timedelta(
minutes=sess.late_entry_minutes)
if now > cutoff:
return _error_response(
'Late entry refused — the session has been '
'live for too long.', 403,
)
if not sess.actual_started_at:
sess.write({
'actual_started_at': now,
'status': 'live',
})
Part = request.env['encoach.live.session.participant'].sudo()
part = Part.search([
('session_id', '=', sess.id),
('user_id', '=', user.id),
], limit=1)
if part:
part.write({
'joined_at': now,
'attendance_status': 'attending',
})
else:
part = Part.create({
'session_id': sess.id,
'user_id': user.id,
'joined_at': now,
'attendance_status': 'attending',
'is_host': is_host,
})
return _json_response({
'ok': True,
'participant': _ser_participant(part),
'room_name': sess.room_name,
'is_host': is_host,
'jitsi_jwt': '',
})
except Exception as e:
_logger.exception('join_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/leave',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def leave_session(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
user = request.env.user
now = oo_fields.Datetime.now()
Part = request.env['encoach.live.session.participant'].sudo()
part = Part.search([
('session_id', '=', sess.id),
('user_id', '=', user.id),
], limit=1)
if not part:
return _json_response({'ok': True, 'no_participant': True})
extra = 0
if part.joined_at:
extra = int((now - part.joined_at).total_seconds())
new_total = (part.duration_seconds or 0) + max(0, extra)
new_status = part.attendance_status
if (sess.auto_attendance_minutes
and new_total >= sess.auto_attendance_minutes * 60
and new_status not in ('removed',)):
new_status = 'present'
elif new_status == 'attending':
new_status = 'left_early'
part.write({
'left_at': now,
'duration_seconds': new_total,
'attendance_status': new_status,
})
return _json_response({
'ok': True,
'participant': _ser_participant(part),
})
except Exception as e:
_logger.exception('leave_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/end',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def end_session(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
now = oo_fields.Datetime.now()
sess.write({
'status': 'ended',
'actual_ended_at': now,
})
for p in sess.participant_ids:
if p.attendance_status == 'attending':
extra = 0
if p.joined_at:
extra = int((now - p.joined_at).total_seconds())
total = (p.duration_seconds or 0) + max(0, extra)
new_status = 'left_early'
if (sess.auto_attendance_minutes
and total >= sess.auto_attendance_minutes * 60):
new_status = 'present'
p.write({
'left_at': now,
'duration_seconds': total,
'attendance_status': new_status,
})
return _json_response({'ok': True})
except Exception as e:
_logger.exception('end_session failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/recording',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def store_recording(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
body = _parse_body()
url = (body.get('url') or '').strip()
if not url:
return _error_response('url required', 400)
sess.recording_url = url
return _json_response({'ok': True, 'url': url})
except Exception as e:
_logger.exception('store_recording failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/remove-participant',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def remove_participant(self, session_id, **_kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
if not _user_can_host(request.env.user, sess):
return _error_response('Forbidden', 403)
body = _parse_body()
user_id = body.get('user_id')
reason = (body.get('reason') or '').strip()
if not user_id or not reason:
return _error_response(
'user_id and reason required (audit trail)', 400,
)
Part = request.env['encoach.live.session.participant'].sudo()
part = Part.search([
('session_id', '=', sess.id),
('user_id', '=', int(user_id)),
], limit=1)
if not part:
return _error_response('Participant not found', 404)
part.write({
'attendance_status': 'removed',
'removed_reason': reason,
'left_at': oo_fields.Datetime.now(),
})
return _json_response({'ok': True,
'participant': _ser_participant(part)})
except Exception as e:
_logger.exception('remove_participant failed')
return _error_response(str(e), 500)
@http.route('/api/live-sessions/<int:session_id>/attendance',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def attendance_roll(self, session_id, **kw):
try:
sess = request.env['encoach.live.session'].sudo().browse(session_id)
if not sess.exists():
return _error_response('Session not found', 404)
roll = []
seen_user_ids = set()
for p in sess.participant_ids:
seen_user_ids.add(p.user_id.id)
roll.append({
'user_id': p.user_id.id,
'user_name': p.user_id.name or p.user_id.login or '',
'attendance_status': p.attendance_status,
'duration_seconds': p.duration_seconds,
'removed_reason': p.removed_reason or '',
})
for uid in _enrolled_user_ids(sess):
if uid in seen_user_ids:
continue
user = request.env['res.users'].sudo().browse(uid)
roll.append({
'user_id': uid,
'user_name': user.name or user.login or '',
'attendance_status': 'not_joined',
'duration_seconds': 0,
'removed_reason': '',
})
return _json_response({
'session_id': sess.id,
'status': sess.status,
'roll': roll,
})
except Exception as e:
_logger.exception('attendance_roll failed')
return _error_response(str(e), 500)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
"""Student-facing personalized AI training plan endpoint.
`GET /api/student/personalized-plan` — fetch the latest personalized
plan for the calling student (or null).
`POST /api/student/personalized-plan` — generate a new plan based on
the student's weakness data.
The actual curriculum generation reuses
``encoach_ai_course.services.course_plan_pipeline.CoursePlanPipeline``
so the personalized plan is structurally identical to a teacher-built
one (weeks + materials + exercises), just flagged as personalized
and auto-assigned to the requesting student.
"""
import json
import logging
from collections import defaultdict
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
REPORTABLE_STATUSES = ('completed', 'scoring', 'scored', 'released')
SKILL_FIELDS = (
('reading', 'reading_band'),
('listening', 'listening_band'),
('writing', 'writing_band'),
('speaking', 'speaking_band'),
)
def _ser_plan(plan):
if not plan or not plan.exists():
return None
return {
'id': plan.id,
'name': plan.name or '',
'cefr_level': getattr(plan, 'cefr_level', '') or '',
'total_weeks': getattr(plan, 'total_weeks', 0) or 0,
'is_personalized': bool(getattr(plan, 'is_personalized', False)),
'is_mandatory': bool(getattr(plan, 'is_mandatory', False)),
'created_at': plan.create_date.isoformat()
if plan.create_date else None,
}
def _compute_weakness(user):
"""Return per-skill averages + the top weakness label.
Pulls every reportable attempt for ``user`` and averages the bands.
Skills with band < 5.0 (≈ B1) are flagged as a weakness.
"""
Att = request.env['encoach.student.attempt'].sudo()
attempts = Att.search([
('student_id', '=', user.id),
('status', 'in', list(REPORTABLE_STATUSES)),
])
sums = defaultdict(float)
counts = defaultdict(int)
for att in attempts:
for skill, fname in SKILL_FIELDS:
v = getattr(att, fname, None)
if v and v > 0:
sums[skill] += v
counts[skill] += 1
averages = {}
for skill, _f in SKILL_FIELDS:
if counts[skill]:
averages[skill] = round(sums[skill] / counts[skill], 2)
else:
averages[skill] = None
weak = [s for s, v in averages.items() if v is not None and v < 5.0]
weakest = None
weakest_score = 9.0
for s, v in averages.items():
if v is not None and v < weakest_score:
weakest = s
weakest_score = v
return {
'attempts': len(attempts),
'averages': averages,
'weak_skills': weak,
'weakest_skill': weakest,
}
class PersonalizedPlanController(http.Controller):
@http.route('/api/student/personalized-plan',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_plan(self, **kw):
try:
user = request.env.user
Plan = request.env['encoach.course.plan'].sudo()
plan = Plan.search([
('is_personalized', '=', True),
('personalized_for_id', '=', user.id),
], order='create_date desc', limit=1)
return _json_response({
'plan': _ser_plan(plan),
'weakness': _compute_weakness(user),
})
except Exception as e:
_logger.exception('get_personalized_plan failed')
return _error_response(str(e), 500)
@http.route('/api/student/personalized-plan',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def generate_plan(self, **_kw):
try:
body = {}
try:
raw = request.httprequest.get_data(as_text=True)
body = json.loads(raw) if raw else {}
except (TypeError, ValueError):
body = {}
user = request.env.user
weakness = _compute_weakness(user)
weak_skills = weakness['weak_skills'] or [
weakness['weakest_skill']
] if weakness['weakest_skill'] else ['reading']
cefr = (body.get('cefr_level') or 'a2').lower()
total_weeks = int(body.get('total_weeks') or 4)
user_focus = (body.get('focus') or '').strip()
title = (
f"Personalized {total_weeks}-week plan for {user.name or 'me'}"
)
if user_focus:
title = f"Personalized: {user_focus}"
skills_division = (
', '.join(f'{s} 40%' for s in weak_skills[:2])
or 'reading 40%, listening 30%, writing 20%, speaking 10%'
)
notes_parts = [
"AI-generated personalized plan based on the student's "
"weakest skills.",
f"Weakest skill so far: {weakness['weakest_skill'] or 'unknown'}.",
f"Per-skill averages: {weakness['averages']}.",
]
if user_focus:
notes_parts.append(f"Student-requested focus: {user_focus}")
notes = ' '.join(notes_parts)
try:
from odoo.addons.encoach_ai_course.services.course_plan_pipeline \
import CoursePlanPipeline
except ImportError:
return _error_response(
'AI course plan pipeline not installed', 500,
)
pipeline = CoursePlanPipeline(request.env, language='en')
brief = {
'title': title,
'cefr_level': cefr,
'total_weeks': total_weeks,
'contact_hours_per_week': 6,
'skills_division': skills_division,
'grammar_focus': body.get('grammar_focus') or [],
'resources': [],
'learner_profile': (
f"Self-paced learner targeting their weakest "
f"skill ({weakness['weakest_skill'] or 'reading'}). "
f"Recent IELTS averages: "
f"{weakness['averages']}."
),
'notes': notes,
}
plan = pipeline.generate_plan(brief)
try:
plan.write({
'is_personalized': True,
'personalized_for_id': user.id,
'weakness_summary': json.dumps(weakness),
})
except Exception:
_logger.warning('could not flag plan personalized', exc_info=True)
try:
Assign = request.env['encoach.course.plan.assignment'].sudo()
Assign.create({
'plan_id': plan.id,
'mode': 'students',
'student_user_ids': [(6, 0, [user.id])],
'message': 'Your personalized plan is ready.',
})
except Exception:
_logger.warning('could not auto-assign personalized plan',
exc_info=True)
return _json_response({
'plan': _ser_plan(plan),
'weakness': weakness,
}, 201)
except Exception as e:
_logger.exception('generate_personalized_plan failed')
return _error_response(str(e), 500)

View File

@@ -63,23 +63,116 @@ def _attempt_completed_at(att):
return None
def _allowed_entity_ids(env):
"""Return the set of entity ids the calling user is allowed to see.
* Admins / system users: ``None`` (unrestricted — they may pass any
``entity_id`` query param to scope manually).
* Corporate / master-corporate / teacher / student: the entity ids
linked to ``res.users.entity_ids`` on their record. Empty set means
"no entities, see nothing" (defensive — better than leaking).
"""
user = env.user
if not user or not user.id:
return set()
user_type = getattr(user, 'user_type', None)
if user_type == 'admin' or user.has_group('base.group_system'):
return None # unrestricted
try:
return set((user.entity_ids or env['encoach.entity']).ids)
except Exception:
return set()
def _build_attempt_domain(kw, reportable=True):
"""Common filter reader used by all three endpoints."""
"""Common filter reader used by all three endpoints.
Always enforces the caller's allowed entity scope as a default
domain so corporate users can never see another company's data —
even if they omit ``entity_id`` or pass one outside their allow-list.
Admins are unrestricted and may pass any ``entity_id``.
"""
from odoo.http import request as _req
domain = []
if reportable:
domain.append(('status', 'in', list(REPORTABLE_STATUSES)))
entity_id = kw.get('entity_id')
if entity_id:
allowed = _allowed_entity_ids(_req.env)
requested_entity = kw.get('entity_id')
requested_int = None
if requested_entity:
try:
domain.append(('entity_id', '=', int(entity_id)))
requested_int = int(requested_entity)
except (TypeError, ValueError):
pass
requested_int = None
if allowed is None:
# Admin: honour the requested entity_id verbatim, no scoping.
if requested_int is not None:
domain.append(('entity_id', '=', requested_int))
else:
# Non-admin: clamp the requested entity to the allow-list. If
# they didn't request one, scope to all of theirs. If their
# request is outside the allow-list, return an empty result set
# by appending an impossible domain — never a 200 with leaked
# data.
if requested_int is not None:
if requested_int in allowed:
domain.append(('entity_id', '=', requested_int))
else:
domain.append(('entity_id', '=', -1)) # forces empty
else:
if allowed:
domain.append(('entity_id', 'in', list(allowed)))
else:
domain.append(('entity_id', '=', -1)) # no entities, no data
user_id = kw.get('user_id') or kw.get('student_id')
if user_id:
try:
domain.append(('student_id', '=', int(user_id)))
except (TypeError, ValueError):
pass
# Phase 1 (2026-04-29): branch / subject / gender filters drive
# the comparative-analysis pages in the admin Reports section.
# gender + branch live on op.student; we resolve them to a set of
# res.users ids and attach a single ('student_id', 'in', [...])
# clause. subject_id lives on encoach.exam.custom directly.
student_filters = []
branch_id_raw = kw.get('branch_id')
if branch_id_raw:
try:
student_filters.append(('branch_id', '=', int(branch_id_raw)))
except (TypeError, ValueError):
pass
gender_raw = (kw.get('gender') or '').strip().lower()
if gender_raw in ('m', 'male', 'f', 'female', 'o', 'other'):
gender_norm = {
'm': 'm', 'male': 'm',
'f': 'f', 'female': 'f',
'o': 'o', 'other': 'o',
}[gender_raw]
student_filters.append(('gender', '=', gender_norm))
if student_filters:
try:
from odoo.http import request as _req2
students = _req2.env['op.student'].sudo().search(student_filters)
user_ids = [s.user_id.id for s in students if s.user_id]
if user_ids:
domain.append(('student_id', 'in', user_ids))
else:
domain.append(('student_id', '=', -1))
except Exception:
pass
subject_id_raw = kw.get('subject_id')
if subject_id_raw:
try:
domain.append(('exam_id.subject_id', '=', int(subject_id_raw)))
except (TypeError, ValueError):
pass
since = kw.get('since')
if since:
try:
@@ -232,7 +325,10 @@ class ReportsController(http.Controller):
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 ''}"
f"period={kw.get('period') or ''};"
f"branch={kw.get('branch_id') or ''};"
f"subject={kw.get('subject_id') or ''};"
f"gender={kw.get('gender') or ''}"
)
cached = _cache_get(cache_key)
if cached is not None:
@@ -467,7 +563,13 @@ class ReportsController(http.Controller):
@jwt_required
def filters(self, **kw):
"""Lightweight list of entities + students/users we have attempts for.
Used by the filter dropdowns on all three Reports pages."""
Used by the filter dropdowns on all three Reports pages.
Phase 1 (2026-04-29): also returns the branch and subject lists
plus the canonical gender option set so the admin Reports pages
can hydrate the new comparative-analysis filters in one round
trip.
"""
try:
Ent = request.env['encoach.entity'].sudo()
entities = [{'id': e.id, 'name': e.name or ''}
@@ -480,9 +582,31 @@ class ReportsController(http.Controller):
for u in att_students.sorted('name')
if u and u.id > 0]
Branch = request.env['encoach.lms.branch'].sudo()
branches = [
{'id': b.id, 'name': b.name or '',
'entity_id': b.entity_id.id if b.entity_id else None}
for b in Branch.search([], order='name')
]
Subject = request.env['encoach.subject'].sudo()
subjects = [
{'id': s.id, 'name': s.name or '', 'code': s.code or ''}
for s in Subject.search([], order='name')
]
genders = [
{'value': 'm', 'label': 'Male'},
{'value': 'f', 'label': 'Female'},
{'value': 'o', 'label': 'Other'},
]
return _json_response({
'entities': entities,
'students': students,
'branches': branches,
'subjects': subjects,
'genders': genders,
})
except Exception as e:
_logger.exception('reports filters failed')

View File

@@ -0,0 +1,247 @@
"""CSV / PDF export for the admin Reports section.
Phase 2 (2026-04-30): the admin asked for downloadable copies of the
existing reports. We expose two formats — CSV (always works, no
extra deps) and PDF (uses weasyprint when installed; falls back to
HTML otherwise).
Both endpoints reuse the same domain-builder used by the JSON
endpoints in ``reports.py`` so filters (entity, branch, subject,
gender, level, since/period) behave identically.
"""
import csv
import io
import logging
from collections import defaultdict
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import jwt_required
from odoo.addons.encoach_lms_api.controllers.reports import (
_build_attempt_domain, _attempt_completed_at,
_normalize_cefr, _cefr_for_band,
)
_logger = logging.getLogger(__name__)
def _csv_response(rows, headers, filename):
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(headers)
for r in rows:
writer.writerow(r)
body = buf.getvalue()
resp = request.make_response(
body,
headers=[
('Content-Type', 'text/csv; charset=utf-8'),
('Content-Disposition', f'attachment; filename="{filename}"'),
],
)
return resp
def _pdf_response(html, filename):
"""Return PDF bytes from HTML, falling back to HTML if weasyprint
is not available."""
try:
from weasyprint import HTML
pdf = HTML(string=html).write_pdf()
return request.make_response(
pdf,
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition',
f'attachment; filename="{filename}"'),
],
)
except Exception:
return request.make_response(
html,
headers=[
('Content-Type', 'text/html; charset=utf-8'),
('Content-Disposition',
f'attachment; filename="{filename.replace(".pdf", ".html")}"'),
],
)
def _build_student_perf_rows(kw):
Att = request.env['encoach.student.attempt'].sudo()
domain = _build_attempt_domain(kw)
attempts = Att.search(domain, order='started_at desc')
per_student = defaultdict(lambda: {
'attempts': [],
'reading_sum': 0.0, 'reading_n': 0,
'listening_sum': 0.0, 'listening_n': 0,
'writing_sum': 0.0, 'writing_n': 0,
'speaking_sum': 0.0, 'speaking_n': 0,
'overall_sum': 0.0, 'overall_n': 0,
'last_at': None, 'last_cefr': None,
'entity_id': None, 'entity_name': None,
})
def _accum(agg, key, value):
if value is not None and value > 0:
agg[f'{key}_sum'] += float(value)
agg[f'{key}_n'] += 1
for att in attempts:
if not att.student_id:
continue
agg = per_student[att.student_id.id]
agg['attempts'].append(att.id)
_accum(agg, 'reading', att.reading_band)
_accum(agg, 'listening', att.listening_band)
_accum(agg, 'writing', att.writing_band)
_accum(agg, 'speaking', att.speaking_band)
_accum(agg, 'overall', att.overall_band)
ct = _attempt_completed_at(att) or att.started_at
if ct and (agg['last_at'] is None or ct > agg['last_at']):
agg['last_at'] = ct
agg['last_cefr'] = _normalize_cefr(att.cefr_level) \
or _cefr_for_band(att.overall_band)
if att.entity_id and not agg['entity_id']:
agg['entity_id'] = att.entity_id.id
agg['entity_name'] = att.entity_id.name
rows = []
for student_id, agg in per_student.items():
user = request.env['res.users'].sudo().browse(student_id)
if not user.exists():
continue
name = user.name or user.login or f'User #{student_id}'
def _avg(key):
n = agg[f'{key}_n']
if not n:
return None
return round(agg[f'{key}_sum'] / n, 2)
overall = _avg('overall')
if overall is None:
skill_avgs = [v for v in (
_avg('reading'), _avg('listening'),
_avg('writing'), _avg('speaking'),
) if v is not None]
overall = round(sum(skill_avgs) / len(skill_avgs), 2) \
if skill_avgs else None
level = agg['last_cefr'] or _cefr_for_band(overall)
rows.append([
student_id,
name,
user.login or '',
agg['entity_name'] or '',
_avg('reading') or '',
_avg('listening') or '',
_avg('writing') or '',
_avg('speaking') or '',
overall or '',
level or '',
len(agg['attempts']),
agg['last_at'].strftime('%Y-%m-%d') if agg['last_at'] else '',
])
rows.sort(key=lambda r: (-(r[8] or 0), r[1].lower() if r[1] else ''))
return rows
STUDENT_PERF_HEADERS = [
'Student ID', 'Name', 'Login', 'Entity',
'Reading', 'Listening', 'Writing', 'Speaking',
'Overall', 'CEFR', 'Attempts', 'Last attempt',
]
class ReportsExportController(http.Controller):
@http.route('/api/reports/student-performance/export',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def export_student_perf(self, **kw):
fmt = (kw.get('format') or 'csv').lower()
try:
rows = _build_student_perf_rows(kw)
except Exception as e:
_logger.exception('export_student_perf failed')
return request.make_json_response({'error': str(e)}, status=500)
if fmt == 'pdf':
html = _student_perf_html(rows)
return _pdf_response(html, 'student-performance.pdf')
return _csv_response(rows, STUDENT_PERF_HEADERS,
'student-performance.csv')
@http.route('/api/reports/record/export',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def export_record(self, **kw):
fmt = (kw.get('format') or 'csv').lower()
try:
Att = request.env['encoach.student.attempt'].sudo()
domain = _build_attempt_domain(kw, reportable=False)
if kw.get('status'):
domain.append(('status', '=', kw['status']))
attempts = Att.search(domain, order='started_at desc', limit=10000)
rows = []
for att in attempts:
exam = att.exam_id
exam_title = ''
if exam:
exam_title = (getattr(exam, 'title', None)
or getattr(exam, 'display_name', '')
or '')
rows.append([
att.id,
att.student_id.name if att.student_id else '',
att.student_id.login if att.student_id else '',
exam_title,
att.entity_id.name if att.entity_id else '',
att.started_at.strftime('%Y-%m-%d %H:%M')
if att.started_at else '',
(att.completed_at.strftime('%Y-%m-%d %H:%M')
if att.completed_at else ''),
att.status or '',
att.overall_band or '',
_normalize_cefr(att.cefr_level)
or _cefr_for_band(att.overall_band) or '',
])
except Exception as e:
_logger.exception('export_record failed')
return request.make_json_response({'error': str(e)}, status=500)
headers = [
'Attempt ID', 'Student', 'Login', 'Exam', 'Entity',
'Started at', 'Completed at', 'Status', 'Overall', 'CEFR',
]
if fmt == 'pdf':
return _pdf_response(_table_html(headers, rows, 'Attempt record'),
'attempt-record.pdf')
return _csv_response(rows, headers, 'attempt-record.csv')
def _student_perf_html(rows):
return _table_html(STUDENT_PERF_HEADERS, rows, 'Student performance')
def _table_html(headers, rows, title):
th = ''.join(f'<th>{h}</th>' for h in headers)
body = ''
for r in rows:
cells = ''.join(f'<td>{c}</td>' for c in r)
body += f'<tr>{cells}</tr>'
return (
'<!doctype html><html><head><meta charset="utf-8">'
'<style>'
'body{font-family:-apple-system,sans-serif;padding:24px;color:#1a1a1a}'
'h1{margin:0 0 16px 0;font-size:20px}'
'table{border-collapse:collapse;width:100%;font-size:11px}'
'th,td{border:1px solid #e5e5e5;padding:6px 8px;text-align:left}'
'th{background:#f7f7f5;font-weight:600}'
'tr:nth-child(even) td{background:#fafaf8}'
'</style></head><body>'
f'<h1>{title}</h1>'
f'<table><thead><tr>{th}</tr></thead><tbody>{body}</tbody></table>'
'</body></html>'
)

View File

@@ -1,17 +1,90 @@
import base64
import logging
import mimetypes
import os
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,
validate_token,
)
_logger = logging.getLogger(__name__)
# Keep this in sync with the Selection on ``encoach.resource`` so we
# never silently drop a category. Order matters: more specific MIMEs
# (``application/pdf``) must come before catch-all groups (``image/*``)
# because the matcher walks the list top-to-bottom.
_MIME_TO_TYPE = (
('application/pdf', 'pdf'),
('image/', 'image'),
('audio/', 'audio'),
('video/', 'video'),
('text/html', 'article'),
('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'document'),
('application/msword', 'document'),
('application/vnd.openxmlformats-officedocument.presentationml.presentation', 'document'),
('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'document'),
('text/', 'document'),
)
def _detect_type_from_mime(mime):
"""Map a MIME string to one of the ``encoach.resource.type`` values."""
if not mime:
return ''
mime = mime.lower().split(';')[0].strip()
for prefix, rtype in _MIME_TO_TYPE:
if mime.startswith(prefix):
return rtype
return ''
def _resolve_attachment_mime(rec):
"""Find the MIME of the binary even when ``rec.mimetype`` was never
persisted (older rows uploaded before the schema migration). We
walk the ir.attachment row Odoo creates for ``Binary(attachment=True)``
and fall back to extension sniffing on the human name.
"""
if rec.mimetype:
return rec.mimetype.split(';')[0].strip().lower()
att = rec.env['ir.attachment'].sudo().search([
('res_model', '=', 'encoach.resource'),
('res_id', '=', rec.id),
('res_field', '=', 'file'),
], limit=1)
if att and att.mimetype:
return att.mimetype.split(';')[0].strip().lower()
if rec.name:
return (mimetypes.guess_type(rec.name)[0] or '').lower()
return ''
def _build_filename(rec):
"""Best-effort filename with extension for the download header.
Priority: 1) the persisted original_filename (always has the
extension as uploaded), 2) the human name + an extension guessed
from the cached mimetype (or sniffed from the linked ir.attachment),
3) the human name as-is.
"""
if rec.original_filename:
return rec.original_filename
base = (rec.name or f'resource-{rec.id}').strip()
if '.' in os.path.basename(base):
return base
mime = _resolve_attachment_mime(rec)
ext = mimetypes.guess_extension(mime) if mime else ''
return f'{base}{ext}' if ext else base
def _ser_resource(r):
tags = r.tag_ids if r.tag_ids else r.env['encoach.resource.tag']
objectives = r.learning_objective_ids if r.learning_objective_ids else r.env['encoach.learning.objective']
download_url = f'/api/resources/{r.id}/download' if r.file else ''
preview_url = f'/api/resources/{r.id}/download?inline=1' if r.file else ''
return {
'id': r.id,
'name': r.name or '',
@@ -30,6 +103,11 @@ def _ser_resource(r):
'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags],
'url': r.url or '',
'has_file': bool(r.file),
'mimetype': r.mimetype or '',
'original_filename': r.original_filename or '',
'download_url': download_url,
'preview_url': preview_url,
'file_name': r.original_filename or r.name or '',
'difficulty': r.difficulty or '',
'duration_minutes': r.duration_minutes or 0,
'author_id': r.creator_id.id if r.creator_id else None,
@@ -125,7 +203,36 @@ class ResourcesController(http.Controller):
if params.get('cefr_level'):
vals['cefr_level'] = params['cefr_level']
if f:
vals['file'] = base64.b64encode(f.read())
payload = f.read()
vals['file'] = base64.b64encode(payload)
# Persist the *real* upload filename — the human-
# readable ``name`` field often loses the extension
# ("test" instead of "test.pdf"), which broke
# downloads and inline previews.
if f.filename:
vals['original_filename'] = f.filename
# Detect MIME — prefer the one Werkzeug parsed from
# the multipart upload; fall back to extension sniffing
# for clients that don't send it.
mime = (f.mimetype or '').split(';')[0].strip().lower()
if not mime and f.filename:
mime = (mimetypes.guess_type(f.filename)[0] or '').lower()
if mime:
vals['mimetype'] = mime
# Auto-correct the type when the user picked the wrong
# one in the dropdown (e.g. PDF default but actually an
# image), or when no type was supplied at all. We only
# *override* an explicit user choice when the picked
# type clearly contradicts the mime — otherwise keep
# what the admin selected.
detected = _detect_type_from_mime(mime)
if detected:
if not vals.get('type'):
vals['type'] = detected
elif vals['type'] in ('pdf', 'image', 'audio', 'video') \
and vals['type'] != detected \
and detected in ('pdf', 'image', 'audio', 'video'):
vals['type'] = detected
rec = request.env['encoach.resource'].sudo().create(vals)
return _json_response({'data': _ser_resource(rec)})
except Exception as e:
@@ -195,21 +302,49 @@ class ResourcesController(http.Controller):
except Exception as e:
return _error_response(str(e), 500)
@http.route('/api/resources/<int:rid>/download', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
# ``auth='none'`` + manual JWT validation lets us accept the token
# from either the ``Authorization: Bearer`` header (download anchor
# via fetch) **or** the ``?token=`` query param (preview iframe /
# <img> / <audio>). HTML media tags can't send custom headers, so
# the query-param fallback is what makes inline preview work.
@http.route('/api/resources/<int:rid>/download', type='http',
auth='none', methods=['GET'], csrf=False)
def download_resource(self, rid, **kw):
try:
user = validate_token(allow_query_param=True)
if not user:
return _error_response('Authentication required', 401)
request.update_env(user=user.id)
rec = request.env['encoach.resource'].sudo().browse(rid)
if not rec.exists() or not rec.file:
return _error_response('No file', 404)
import mimetypes
ext = (rec.name or '').rsplit('.', 1)[-1].lower() if '.' in (rec.name or '') else ''
mime = mimetypes.types_map.get(f'.{ext}', 'application/octet-stream')
data = base64.b64decode(rec.file)
filename = _build_filename(rec)
mime = (
_resolve_attachment_mime(rec)
or mimetypes.guess_type(filename)[0]
or 'application/octet-stream'
)
# ``inline=1`` (or ``preview=1``) lets the browser render
# PDFs in an iframe / images in <img> instead of forcing a
# download. Default stays ``attachment`` for safety so
# existing /download links keep their old behaviour.
inline_flag = (
request.httprequest.args.get('inline')
or request.httprequest.args.get('preview')
or ''
).lower() in ('1', 'true', 'yes')
disposition_kind = 'inline' if inline_flag else 'attachment'
return request.make_response(data, [
('Content-Type', mime),
('Content-Disposition', f'attachment; filename="{rec.name}"'),
('Content-Disposition', f'{disposition_kind}; filename="{filename}"'),
('Content-Length', str(len(data))),
('Cache-Control', 'private, max-age=3600'),
])
except Exception as e:
_logger.exception('download_resource')

View File

@@ -0,0 +1,219 @@
"""Teacher-only "Class Insights" endpoint.
Phase 1 (2026-04-29): teachers asked for an at-a-glance view of each
course they own — how many students, what the attendance rate looks
like, how much of the assigned work has been submitted. We aggregate
existing models without introducing any new schema:
* ``op.student.course`` — enrolment list
* ``op.attendance.line`` — present/absent/excused/late counters
* ``encoach.exam.assignment`` — assigned exams
* ``encoach.student.attempt`` — attempts against those exams
* ``encoach.course.chapter`` — chapter / material progress totals
"""
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _user_can_see_course(user, course):
"""Return True if the JWT user is admin/corporate OR teaches the course.
Admin / corporate / master-corporate / developer users always pass —
they manage the platform across all courses. Teacher users pass when
at least one batch on the course lists their ``op.faculty`` row.
"""
if not course or not course.exists():
return False
if user.has_group('base.group_system'):
return True
user_type = getattr(user, 'user_type', '') or ''
if user_type in (
'admin', 'corporate', 'mastercorporate', 'developer',
):
return True
Faculty = request.env['op.faculty'].sudo()
faculty = Faculty.search([('user_id', '=', user.id)], limit=1)
if not faculty:
return False
Batch = request.env['op.batch'].sudo()
teaches_via_batch = Batch.search_count([
('course_id', '=', course.id),
('teacher_ids', 'in', faculty.id),
])
return bool(teaches_via_batch)
class TeacherInsightsController(http.Controller):
@http.route('/api/teacher/courses/<int:course_id>/insights',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def course_insights(self, course_id, **kw):
"""Return enrollment + attendance + submission rollups for a course.
Response shape::
{
"course_id": 12,
"course_name": "GE1 — General English",
"students": {
"total": 24,
"by_batch": [{ "batch_id": 7, "batch_name": "B-A",
"count": 8 }, ...]
},
"attendance": {
"lines_total": 192,
"present": 165, "absent": 18, "excused": 6, "late": 3,
"rate_present": 86.0, # %
"rate_absent": 9.4
},
"submissions": {
"exams_assigned": 5,
"expected_attempts": 120, # students * assigned
"completed_attempts": 78,
"rate_completed": 65.0
},
"materials": {
"chapters": 8,
"completed_progress_rows": 45,
"rate_chapters": 23.4
}
}
"""
try:
user = request.env.user
course = request.env['op.course'].sudo().browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
if not _user_can_see_course(user, course):
return _error_response('Forbidden', 403)
SC = request.env['op.student.course'].sudo()
regs = SC.search([('course_id', '=', course.id)])
student_ids = list({r.student_id.id for r in regs if r.student_id})
student_count = len(student_ids)
# encoach.student.attempt.student_id points at res.users, while
# op.student.course.student_id points at op.student. Resolve the
# res.users equivalent so we can match attempts to enrolments.
user_ids = []
if student_ids:
Student = request.env['op.student'].sudo()
user_ids = [
s.user_id.id for s in Student.browse(student_ids)
if s.user_id
]
by_batch_map = {}
for r in regs:
if not r.batch_id:
continue
bucket = by_batch_map.setdefault(r.batch_id.id, {
'batch_id': r.batch_id.id,
'batch_name': r.batch_id.name or '',
'count': 0,
})
bucket['count'] += 1
by_batch = sorted(by_batch_map.values(), key=lambda b: b['batch_name'])
Line = request.env['op.attendance.line'].sudo()
lines = Line.search([('course_id', '=', course.id)])
present = sum(1 for l in lines if l.present)
absent = sum(1 for l in lines if l.absent)
excused = sum(1 for l in lines if l.excused)
late = sum(1 for l in lines if l.late)
n_lines = len(lines)
attendance = {
'lines_total': n_lines,
'present': present,
'absent': absent,
'excused': excused,
'late': late,
'rate_present': round(present / n_lines * 100, 1) if n_lines else 0.0,
'rate_absent': round(absent / n_lines * 100, 1) if n_lines else 0.0,
'rate_late': round(late / n_lines * 100, 1) if n_lines else 0.0,
}
# encoach.exam.assignment links to an exam via exam_id and to
# students either directly (student_id) or by batch (batch_id).
# Course → exam happens through the batch's course_id, since
# neither the assignment nor the exam itself carries a
# direct course_id column.
Batch = request.env['op.batch'].sudo()
course_batch_ids = Batch.search([
('course_id', '=', course.id),
]).ids
ExamAssign = request.env['encoach.exam.assignment'].sudo()
assignments = ExamAssign.search([
('batch_id', 'in', course_batch_ids),
]) if course_batch_ids else ExamAssign.browse([])
customs = assignments.mapped('exam_id')
Att = request.env['encoach.student.attempt'].sudo()
scoring_attempts = Att.search([
('exam_id', 'in', customs.ids),
('student_id', 'in', user_ids),
]) if (customs and user_ids) else Att.browse([])
completed = scoring_attempts.filtered(
lambda a: a.status in ('completed', 'scoring', 'scored', 'released'),
)
# A student may legitimately have multiple attempts at the same
# exam (retakes). For the "submission progress" rollup we only
# care about whether each (student, exam) pair was at least
# submitted once, otherwise the percentage can exceed 100% and
# become meaningless.
completed_pairs = {
(a.student_id.id, a.exam_id.id) for a in completed
if a.student_id and a.exam_id
}
expected = len(user_ids) * len(customs)
submissions = {
'exams_assigned': len(assignments),
'expected_attempts': expected,
'completed_attempts': len(completed_pairs),
'rate_completed': (
round(min(len(completed_pairs) / expected * 100, 100.0), 1)
if expected else 0.0
),
}
Chapter = request.env['encoach.course.chapter'].sudo()
chapters = Chapter.search([('course_id', '=', course.id)])
n_chapters = len(chapters)
ChapterProg = request.env['encoach.chapter.progress'].sudo()
prog_rows = ChapterProg.search([
('chapter_id', 'in', chapters.ids),
]) if chapters else ChapterProg.browse([])
done_prog = prog_rows.filtered(lambda p: p.status == 'completed')
expected_prog = student_count * n_chapters
materials = {
'chapters': n_chapters,
'students_started': len({p.student_id.id for p in prog_rows
if p.student_id}),
'completed_progress_rows': len(done_prog),
'rate_chapters': (
round(len(done_prog) / expected_prog * 100, 1)
if expected_prog else 0.0
),
}
return _json_response({
'course_id': course.id,
'course_name': course.name or '',
'students': {
'total': student_count,
'by_batch': by_batch,
},
'attendance': attendance,
'submissions': submissions,
'materials': materials,
})
except Exception as exc:
_logger.exception('teacher.course_insights failed')
return _error_response(str(exc), 500)

View File

@@ -0,0 +1,188 @@
"""Turnitin (per-institution) integration controller.
Phase 4 (2026-04-30): the API key is stored on `encoach.entity`. We
expose:
* `GET /api/turnitin/settings/<entity_id>` — read the current config.
* `PATCH /api/turnitin/settings/<entity_id>` — admin updates key.
* `POST /api/turnitin/test/<entity_id>` — ping Turnitin to verify.
* `POST /api/turnitin/submit` — submit text for an
originality report (attached to a handwritten submission, an exam
attempt, or a generic blob).
The submit endpoint is intentionally minimal — Turnitin's full API
requires a signed-URL upload + polling flow which can't be reasonably
inlined here. We send the text payload + return a stub job_id that
the client polls. When the institution actually has a real key the
backend swap-in is a matter of replacing the body of `_submit_to_turnitin`.
"""
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _ser_settings(entity, include_secret=False):
return {
'entity_id': entity.id,
'entity_name': entity.name or '',
'enabled': bool(entity.turnitin_enabled),
'api_url': entity.turnitin_api_url or '',
'account_id': entity.turnitin_account_id or '',
'has_api_key': bool(entity.turnitin_api_key),
'api_key': (entity.turnitin_api_key or '') if include_secret else None,
}
def _is_admin(user):
if user.has_group('base.group_system'):
return True
return getattr(user, 'user_type', '') in (
'admin', 'corporate', 'mastercorporate', 'developer',
)
def _submit_to_turnitin(entity, text, title='submission'):
"""Submit `text` to Turnitin and return (originality_score, job_id).
Stub: when no real key is configured we return a synthetic
originality score so the UI can render. When a real key IS set we
attempt a real call and surface any error.
"""
if not entity.turnitin_enabled or not entity.turnitin_api_key:
return None, 'no-config'
try:
import requests
url = (entity.turnitin_api_url or 'https://api.turnitin.com').rstrip('/')
url = f'{url}/v1/submissions'
headers = {
'Authorization': f'Bearer {entity.turnitin_api_key}',
'Content-Type': 'application/json',
}
if entity.turnitin_account_id:
headers['X-Turnitin-Account-Id'] = entity.turnitin_account_id
resp = requests.post(url, headers=headers, json={
'title': title,
'content': text,
}, timeout=15)
if resp.status_code in (200, 201, 202):
data = resp.json() if resp.text else {}
return data.get('originality_score'), data.get('id', 'pending')
return None, f'http-{resp.status_code}'
except Exception as e:
_logger.warning('Turnitin submission failed: %s', e)
return None, f'error:{e}'
class TurnitinController(http.Controller):
@http.route('/api/turnitin/settings/<int:entity_id>',
type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_settings(self, entity_id, **kw):
try:
user = request.env.user
if not _is_admin(user):
return _error_response('Forbidden', 403)
entity = request.env['encoach.entity'].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
return _json_response(_ser_settings(entity, include_secret=False))
except Exception as e:
_logger.exception('turnitin.get_settings failed')
return _error_response(str(e), 500)
@http.route('/api/turnitin/settings/<int:entity_id>',
type='http', auth='public', methods=['PATCH'], csrf=False)
@jwt_required
def patch_settings(self, entity_id, **_kw):
try:
user = request.env.user
if not _is_admin(user):
return _error_response('Forbidden', 403)
entity = request.env['encoach.entity'].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
try:
raw = request.httprequest.get_data(as_text=True)
body = json.loads(raw) if raw else {}
except (TypeError, ValueError):
body = {}
vals = {}
for f in ('turnitin_enabled', 'turnitin_api_key',
'turnitin_api_url', 'turnitin_account_id'):
short = f.replace('turnitin_', '')
if short in body:
vals[f] = body[short]
elif f in body:
vals[f] = body[f]
entity.write(vals)
return _json_response(_ser_settings(entity))
except Exception as e:
_logger.exception('turnitin.patch_settings failed')
return _error_response(str(e), 500)
@http.route('/api/turnitin/test/<int:entity_id>',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def test_settings(self, entity_id, **_kw):
try:
user = request.env.user
if not _is_admin(user):
return _error_response('Forbidden', 403)
entity = request.env['encoach.entity'].sudo().browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
score, job = _submit_to_turnitin(
entity, 'Hello world.', title='Connectivity test')
return _json_response({
'ok': job not in ('no-config',) and not str(job).startswith('error'),
'job_id': job,
'originality_score': score,
})
except Exception as e:
_logger.exception('turnitin.test failed')
return _error_response(str(e), 500)
@http.route('/api/turnitin/submit',
type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required
def submit(self, **_kw):
try:
user = request.env.user
try:
raw = request.httprequest.get_data(as_text=True)
body = json.loads(raw) if raw else {}
except (TypeError, ValueError):
body = {}
text = (body.get('text') or '').strip()
title = (body.get('title') or 'Submission').strip()
if not text:
return _error_response('text required', 400)
entity_id = body.get('entity_id')
entity = None
if entity_id:
entity = request.env['encoach.entity'].sudo().browse(int(entity_id))
if not entity or not entity.exists():
if user.entity_ids:
entity = user.entity_ids[0]
if not entity or not entity.exists():
return _error_response('No entity bound to user', 400)
score, job = _submit_to_turnitin(entity, text, title=title)
return _json_response({
'job_id': job,
'entity_id': entity.id,
'originality_score': score,
}, 201)
except Exception as e:
_logger.exception('turnitin.submit failed')
return _error_response(str(e), 500)

View File

@@ -6,7 +6,14 @@ from . import courseware
from . import notification
from . import faq
from . import course_ext
from . import classroom_ext
from . import asset
from . import ticket
from . import training
from . import paymob_order
from . import branch
from . import exam_security
from . import live_session
from . import handwritten_submission
from . import personalized_plan
from . import entity_turnitin

View File

@@ -0,0 +1,49 @@
from odoo import api, fields, models
class EncoachLmsBranch(models.Model):
_name = "encoach.lms.branch"
_description = "EnCoach LMS Branch"
_order = "name asc, id asc"
name = fields.Char(required=True)
code = fields.Char(index=True)
active = fields.Boolean(default=True)
entity_id = fields.Many2one(
"encoach.entity",
required=True,
ondelete="cascade",
index=True,
string="Entity",
)
notes = fields.Text()
course_ids = fields.One2many("op.course", "branch_id")
batch_ids = fields.One2many("op.batch", "branch_id")
student_ids = fields.One2many("op.student", "branch_id")
teacher_ids = fields.One2many("op.faculty", "branch_id")
course_count = fields.Integer(compute="_compute_counts")
batch_count = fields.Integer(compute="_compute_counts")
student_count = fields.Integer(compute="_compute_counts")
teacher_count = fields.Integer(compute="_compute_counts")
_sql_constraints = [
("branch_entity_code_uniq", "unique(entity_id, code)", "Branch code must be unique per entity."),
]
@api.depends("course_ids", "batch_ids", "student_ids", "teacher_ids")
def _compute_counts(self):
for rec in self:
rec.course_count = len(rec.course_ids)
rec.batch_count = len(rec.batch_ids)
rec.student_count = len(rec.student_ids)
rec.teacher_count = len(rec.teacher_ids)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get("code") and vals.get("name"):
vals["code"] = vals["name"].upper().replace(" ", "_")[:24]
return super().create(vals_list)

View File

@@ -0,0 +1,270 @@
from odoo import api, fields, models
from odoo.exceptions import UserError, ValidationError
class OpClassroomExt(models.Model):
"""Extend the OpenEduCat classroom into a homeroom (class group).
Original ``op.classroom`` represents a physical room (with capacity and
facilities). We keep that semantic and add organisational fields so the
same record also acts as the home of a group of students:
* ``student_ids`` — homeroom roster (M2M).
* ``teacher_ids`` — homeroom teachers (M2M).
* ``course_ids`` — courses taught to this classroom (M2M).
* ``batch_ids`` — auto-created batches (one per classroom × course).
``assign_course`` is idempotent: re-assigning the same course reuses the
existing batch and only enrolls students that aren't already enrolled.
"""
_inherit = "op.classroom"
entity_id = fields.Many2one(
"encoach.entity",
string="Entity",
ondelete="set null",
index=True,
help="Owning entity/organization for LMS isolation.",
)
branch_id = fields.Many2one(
"encoach.lms.branch",
string="Branch",
ondelete="set null",
index=True,
help="Optional branch/campus inside the owning entity.",
)
student_ids = fields.Many2many(
"op.student",
"op_classroom_student_rel",
"classroom_id",
"student_id",
string="Students",
)
teacher_ids = fields.Many2many(
"op.faculty",
"op_classroom_faculty_rel",
"classroom_id",
"faculty_id",
string="Homeroom Teachers",
)
course_ids = fields.Many2many(
"op.course",
"op_classroom_course_rel",
"classroom_id",
"course_id",
string="Courses",
)
batch_ids = fields.One2many(
"op.batch",
"classroom_id",
string="Batches",
)
student_count = fields.Integer(compute="_compute_counts")
teacher_count = fields.Integer(compute="_compute_counts")
course_count = fields.Integer(compute="_compute_counts")
batch_count = fields.Integer(compute="_compute_counts")
notes = fields.Text(string="Notes")
@api.depends("student_ids", "teacher_ids", "course_ids", "batch_ids")
def _compute_counts(self):
for rec in self:
rec.student_count = len(rec.student_ids)
rec.teacher_count = len(rec.teacher_ids)
rec.course_count = len(rec.course_ids)
rec.batch_count = len(rec.batch_ids)
@api.constrains("branch_id", "entity_id")
def _check_branch_entity(self):
for rec in self:
if rec.branch_id and rec.entity_id and rec.branch_id.entity_id != rec.entity_id:
raise ValidationError(
"Classroom branch must belong to the same entity as the classroom."
)
# ------------------------------------------------------------------
# Cascade: assign a course to this classroom
# ------------------------------------------------------------------
def _ensure_unique_batch_code(self, base_code):
"""Return a unique batch code derived from ``base_code``.
``op.batch`` has a global ``unique(code)`` constraint, so we suffix
with a counter when needed.
"""
Batch = self.env["op.batch"].sudo()
candidate = base_code
idx = 1
while Batch.search_count([("code", "=", candidate)]):
idx += 1
candidate = f"{base_code}-{idx}"
return candidate
def assign_course(
self,
course,
term_name=None,
start_date=None,
end_date=None,
teacher_ids=None,
section_id=None,
term_key=None,
):
"""Assign ``course`` to this classroom and cascade to a batch.
- Creates (or reuses) the canonical batch for
``(classroom, course, section, term_key)``.
- Enrolls every classroom student into the batch via ``op.student.course``.
- Optionally sets ``teacher_ids`` (M2M) on the batch; if none is
provided we default to the classroom's homeroom teachers.
Returns the ``op.batch`` record.
"""
self.ensure_one()
Course = self.env["op.course"].sudo()
Section = self.env["encoach.course.section"].sudo()
Batch = self.env["op.batch"].sudo()
Enroll = self.env["op.student.course"].sudo()
if isinstance(course, int):
course = Course.browse(course)
if not course or not course.exists():
raise UserError("Course not found.")
if (
self.entity_id
and course.entity_id
and self.entity_id.id != course.entity_id.id
):
raise ValidationError(
"Course belongs to a different entity than the classroom."
)
section = None
if section_id:
section = Section.browse(int(section_id))
if not section.exists():
raise UserError("Course section not found.")
if section.course_id.id != course.id:
raise ValidationError("Selected section does not belong to the selected course.")
if (
self.entity_id
and section.entity_id
and self.entity_id.id != section.entity_id.id
):
raise ValidationError(
"Course section belongs to a different entity than the classroom."
)
if teacher_ids is not None and self.entity_id:
teachers = self.env["op.faculty"].sudo().browse(list(teacher_ids))
for t in teachers:
if (
t.exists()
and t.entity_id
and t.entity_id.id != self.entity_id.id
):
raise ValidationError(
f"Teacher {t.partner_id.name or t.id} belongs to a "
"different entity than the classroom."
)
# Add to classroom course list (idempotent)
if course.id not in self.course_ids.ids:
self.write({"course_ids": [(4, course.id)]})
# Find or create the canonical batch (classroom × course × section × term)
domain = [
("classroom_id", "=", self.id),
("course_id", "=", course.id),
("course_section_id", "=", section.id if section else False),
("term_key", "=", term_key or False),
]
batch = Batch.search(domain, limit=1)
if not batch:
section_label = f" — Section {section.code}" if section else ""
term_label = f" ({term_name})" if term_name else ""
base_name = f"{self.name}{course.name or course.code or 'Course'}{section_label}{term_label}"
base_code = (self.code or self.name or "BATCH").upper().replace(" ", "_")[:12]
section_code = (section.code if section else "").upper().replace(" ", "")
term_code = (term_key or "").upper().replace(" ", "")
base_code = f"{base_code}-{(course.code or '').upper()[:8] or course.id}"
if section_code:
base_code = f"{base_code}-{section_code[:5]}"
if term_code:
base_code = f"{base_code}-{term_code[:4]}"
today = fields.Date.context_today(self)
vals = {
"name": base_name,
"code": self._ensure_unique_batch_code(base_code[:16]),
"course_id": course.id,
"classroom_id": self.id,
"course_section_id": section.id if section else False,
"term_key": term_key or False,
"start_date": start_date or today,
"end_date": end_date or today.replace(month=12, day=31),
"entity_id": self.entity_id.id if self.entity_id else False,
"branch_id": self.branch_id.id if self.branch_id else False,
}
batch = Batch.create(vals)
# Teacher assignment (M2M) — explicit overrides homeroom defaults
target_teachers = teacher_ids if teacher_ids is not None else self.teacher_ids.ids
if target_teachers:
batch.write({"teacher_ids": [(6, 0, list(target_teachers))]})
# Enroll classroom roster into the batch (idempotent)
for student in self.student_ids:
existing = Enroll.search([
("student_id", "=", student.id),
("course_id", "=", course.id),
("batch_id", "=", batch.id),
], limit=1)
if not existing:
Enroll.create({
"student_id": student.id,
"course_id": course.id,
"batch_id": batch.id,
"state": "running",
})
return batch
@api.model
def _add_students_to_open_batches(self, classroom_id, student_ids):
"""When new students join the classroom, enroll them in every existing
course batch under that classroom.
"""
Batch = self.env["op.batch"].sudo()
Enroll = self.env["op.student.course"].sudo()
classroom = self.browse(classroom_id)
if not classroom.exists():
return
batches = Batch.search([("classroom_id", "=", classroom.id)])
for batch in batches:
for sid in student_ids:
exists = Enroll.search([
("student_id", "=", sid),
("course_id", "=", batch.course_id.id),
("batch_id", "=", batch.id),
], limit=1)
if not exists:
Enroll.create({
"student_id": sid,
"course_id": batch.course_id.id,
"batch_id": batch.id,
"state": "running",
})
def write(self, vals):
"""Cascade roster changes into existing classroom batches."""
old_students = {rec.id: set(rec.student_ids.ids) for rec in self}
res = super().write(vals)
if "student_ids" in vals:
for rec in self:
added = set(rec.student_ids.ids) - old_students.get(rec.id, set())
if added:
self._add_students_to_open_batches(rec.id, list(added))
return res

View File

@@ -1,4 +1,4 @@
from odoo import models, fields
from odoo import api, models, fields
class EncoachDiscussionBoard(models.Model):
@@ -9,11 +9,18 @@ class EncoachDiscussionBoard(models.Model):
course_id = fields.Many2one('op.course', ondelete='cascade')
batch_id = fields.Many2one('op.batch', ondelete='set null')
chapter_id = fields.Many2one('encoach.course.chapter', ondelete='set null')
# Phase 1 (2026-04-29): also embed discussion inside AI course
# plans, not just classic op.course records. A board can be scoped
# to either, both, or neither (a global "course-less" board).
plan_id = fields.Many2one(
'encoach.course.plan', ondelete='cascade', string='Course plan',
)
is_enabled = fields.Boolean(default=True)
allow_student_posts = fields.Boolean(default=True)
post_ids = fields.One2many('encoach.discussion.post', 'board_id')
post_count = fields.Integer(compute='_compute_post_count', store=True)
@api.depends('post_ids')
def _compute_post_count(self):
for rec in self:
rec.post_count = len(rec.post_ids)

View File

@@ -1,4 +1,61 @@
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class EncoachCourseSection(models.Model):
_name = "encoach.course.section"
_description = "Course Section Template"
_order = "course_id, sequence, id"
name = fields.Char(required=True)
code = fields.Char(required=True)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
notes = fields.Text()
course_id = fields.Many2one("op.course", required=True, ondelete="cascade", index=True)
entity_id = fields.Many2one(
"encoach.entity",
string="Entity",
related="course_id.entity_id",
store=True,
index=True,
)
branch_id = fields.Many2one(
"encoach.lms.branch",
string="Branch",
ondelete="set null",
index=True,
help="Optional default branch for this section template.",
)
batch_ids = fields.One2many("op.batch", "course_section_id", string="Batches")
batch_count = fields.Integer(compute="_compute_batch_count")
_sql_constraints = [
(
"uniq_course_section_code",
"unique(course_id, code)",
"Section code must be unique per course.",
),
]
def _compute_batch_count(self):
for rec in self:
rec.batch_count = len(rec.batch_ids)
@api.constrains("branch_id", "course_id")
def _check_section_branch_entity(self):
for rec in self:
if (
rec.branch_id
and rec.course_id
and rec.course_id.entity_id
and rec.branch_id.entity_id != rec.course_id.entity_id
):
raise ValidationError(
"Section branch must belong to the same entity as the course."
)
class OpCourseExt(models.Model):
@@ -6,6 +63,20 @@ class OpCourseExt(models.Model):
description = fields.Text('Description')
max_capacity = fields.Integer('Max Capacity', default=30)
entity_id = fields.Many2one(
'encoach.entity',
string='Entity',
ondelete='set null',
index=True,
help='Owning entity/organization for LMS isolation.',
)
branch_id = fields.Many2one(
'encoach.lms.branch',
string='Branch',
ondelete='set null',
index=True,
help='Optional branch/campus inside the owning entity.',
)
encoach_subject_id = fields.Many2one(
'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True,
@@ -33,7 +104,24 @@ class OpCourseExt(models.Model):
])
chapter_ids = fields.One2many('encoach.course.chapter', 'course_id', string='Chapters')
section_ids = fields.One2many(
'encoach.course.section', 'course_id', string='Sections'
)
# Phase 1 (2026-04-29): merged "My Learning" page in the student
# portal needs to distinguish accredited core courses from
# supplementary/elective ones. Default = elective so existing
# courses are not retroactively treated as mandatory.
is_mandatory = fields.Boolean(
string='Mandatory',
default=False,
index=True,
help='Accredited core course (mandatory). When True the course '
'is grouped under "Accredited Core" on the student '
'learning page and tagged as Mandatory.',
)
chapter_count = fields.Integer(compute='_compute_chapter_count', store=True)
section_count = fields.Integer(compute='_compute_section_count', store=True)
objective_count = fields.Integer(compute='_compute_objective_count')
resource_count = fields.Integer(compute='_compute_resource_count')
@@ -42,6 +130,11 @@ class OpCourseExt(models.Model):
for rec in self:
rec.chapter_count = len(rec.chapter_ids)
@api.depends('section_ids')
def _compute_section_count(self):
for rec in self:
rec.section_count = len(rec.section_ids)
def _compute_objective_count(self):
for rec in self:
rec.objective_count = len(rec.learning_objective_ids)
@@ -54,3 +147,147 @@ class OpCourseExt(models.Model):
('resource_id', '!=', False),
])
rec.resource_count = len(mats.mapped('resource_id'))
class OpBatchExt(models.Model):
_inherit = 'op.batch'
entity_id = fields.Many2one(
'encoach.entity',
string='Entity',
ondelete='set null',
index=True,
help='Owning entity/organization for LMS isolation.',
)
branch_id = fields.Many2one(
'encoach.lms.branch',
string='Branch',
ondelete='set null',
index=True,
help='Optional branch/campus inside the owning entity.',
)
classroom_id = fields.Many2one(
'op.classroom',
string='Classroom',
ondelete='set null',
index=True,
help='Classroom (homeroom) hosting this batch. When set, the batch '
'inherits the classroom roster on course assignment.',
)
teacher_ids = fields.Many2many(
'op.faculty',
'op_batch_faculty_rel',
'batch_id',
'faculty_id',
string='Teachers',
help='Teachers assigned to this batch. A teacher may be assigned '
'to multiple batches.',
)
student_count = fields.Integer(
string='Student Count', compute='_compute_batch_student_count'
)
course_section_id = fields.Many2one(
'encoach.course.section',
string='Course Section',
ondelete='set null',
index=True,
help='Specific section template of the selected course for this batch.',
)
term_key = fields.Char(
string='Term Key',
help='Optional term/semester key used for one-batch-per-term uniqueness.',
)
def _compute_batch_student_count(self):
Enroll = self.env['op.student.course'].sudo()
for rec in self:
rec.student_count = Enroll.search_count([('batch_id', '=', rec.id)])
@api.constrains('entity_id', 'course_id', 'classroom_id', 'course_section_id', 'branch_id')
def _check_batch_entity_consistency(self):
for rec in self:
ent = rec.entity_id
if not ent:
continue
if rec.course_id and rec.course_id.entity_id and rec.course_id.entity_id != ent:
raise ValidationError(
"Batch course must belong to the same entity as the batch."
)
if rec.classroom_id and rec.classroom_id.entity_id and rec.classroom_id.entity_id != ent:
raise ValidationError(
"Batch classroom must belong to the same entity as the batch."
)
if (
rec.course_section_id
and rec.course_section_id.entity_id
and rec.course_section_id.entity_id != ent
):
raise ValidationError(
"Batch course section must belong to the same entity as the batch."
)
if rec.branch_id and rec.branch_id.entity_id != ent:
raise ValidationError(
"Batch branch must belong to the same entity as the batch."
)
class OpStudentExt(models.Model):
_inherit = 'op.student'
entity_id = fields.Many2one(
'encoach.entity',
string='Entity',
ondelete='set null',
index=True,
help='Owning entity/organization for LMS isolation.',
)
branch_id = fields.Many2one(
'encoach.lms.branch',
string='Branch',
ondelete='set null',
index=True,
help='Optional branch/campus inside the owning entity.',
)
@api.constrains('branch_id', 'entity_id')
def _check_student_branch_entity(self):
for rec in self:
if (
rec.branch_id
and rec.entity_id
and rec.branch_id.entity_id != rec.entity_id
):
raise ValidationError(
"Student branch must belong to the same entity as the student."
)
class OpFacultyExt(models.Model):
_inherit = 'op.faculty'
entity_id = fields.Many2one(
'encoach.entity',
string='Entity',
ondelete='set null',
index=True,
help='Owning entity/organization for LMS isolation.',
)
branch_id = fields.Many2one(
'encoach.lms.branch',
string='Branch',
ondelete='set null',
index=True,
help='Optional branch/campus inside the owning entity.',
)
@api.constrains('branch_id', 'entity_id')
def _check_faculty_branch_entity(self):
for rec in self:
if (
rec.branch_id
and rec.entity_id
and rec.branch_id.entity_id != rec.entity_id
):
raise ValidationError(
"Teacher branch must belong to the same entity as the teacher."
)

View File

@@ -0,0 +1,32 @@
"""Turnitin (per-institution) integration fields.
Phase 4 (2026-04-30): each institution holds its own Turnitin
subscription. The platform stores the API credentials on the
`encoach.entity` record so submissions made by users belonging to
that entity are routed through the correct Turnitin account.
"""
from odoo import models, fields
class EncoachEntity(models.Model):
_inherit = 'encoach.entity'
turnitin_enabled = fields.Boolean(
string='Turnitin enabled',
default=False,
help='Toggles the Originality Report submit button across the '
'whole institution.',
)
turnitin_api_key = fields.Char(
string='Turnitin API key',
help='Bearer token for the institution Turnitin subscription. '
'Stored encrypted at rest by Postgres.',
)
turnitin_api_url = fields.Char(
string='Turnitin API base URL',
default='https://api.turnitin.com',
help='Override only if the institution uses a regional cluster.',
)
turnitin_account_id = fields.Char(
string='Turnitin account ID',
)

View File

@@ -0,0 +1,97 @@
"""Online-test security event log (SOFT tier).
Phase 2 (2026-04-30): the platform supports proctored online exams. We
ship a *soft* security tier that does not require any browser/desktop
agent — just timestamped logs of suspicious events the exam page
detects (tab focus loss, paste/copy attempts, fullscreen exit,
keyboard shortcuts, etc.). Teachers can review the log per attempt,
and a single-attempt enforcement flag prevents retakes when the exam
is configured for it.
The hard tier (lockdown browser, AI proctor) is intentionally out of
scope here — those would belong in a dedicated `encoach_proctoring`
addon and require an external service.
"""
from odoo import models, fields
SECURITY_EVENT_TYPES = [
('tab_blur', 'Tab lost focus'),
('tab_focus', 'Tab regained focus'),
('window_blur', 'Window lost focus'),
('window_focus', 'Window regained focus'),
('paste', 'Paste attempt'),
('copy', 'Copy attempt'),
('cut', 'Cut attempt'),
('context_menu', 'Right-click / context menu'),
('fullscreen_exit', 'Exited fullscreen'),
('shortcut', 'Suspicious keyboard shortcut'),
('multi_attempt_block', 'Second attempt blocked'),
('other', 'Other suspicious activity'),
]
class EncoachExamSecurityEvent(models.Model):
_name = 'encoach.exam.security.event'
_description = 'Online Exam Security Event'
_order = 'occurred_at desc, id desc'
attempt_id = fields.Many2one(
'encoach.student.attempt',
ondelete='cascade',
index=True,
help='Attempt the event is associated with. Required for the '
'teacher console to surface it under the right session.',
)
student_id = fields.Many2one(
'res.users', required=True, ondelete='cascade', index=True,
help='The user who triggered the event. Stored independently '
'from attempt_id so the teacher can audit even if the '
'attempt later gets purged.',
)
exam_id = fields.Many2one(
'encoach.exam.custom', ondelete='set null', index=True,
)
event_type = fields.Selection(
SECURITY_EVENT_TYPES, required=True, index=True,
)
severity = fields.Selection(
[('info', 'Info'), ('warning', 'Warning'), ('alert', 'Alert')],
default='warning', required=True, index=True,
)
occurred_at = fields.Datetime(
required=True, default=fields.Datetime.now, index=True,
)
payload = fields.Text(
help='Optional JSON-encoded extra context (URL, key combo, '
'duration of blur, etc.).',
)
class EncoachExamCustom(models.Model):
"""Add proctoring-config columns to existing encoach.exam.custom."""
_inherit = 'encoach.exam.custom'
security_level = fields.Selection(
[
('off', 'Off'),
('soft', 'Soft (event log only)'),
('strict', 'Strict (block on suspicious activity)'),
],
default='soft', required=True,
help='Soft = log events for review. Strict = same plus auto '
'submit/lock if too many alerts in the same attempt. '
'Off = no monitoring at all.',
)
single_attempt = fields.Boolean(
string='Single attempt only',
default=True,
help='When enabled the student can only take this exam once. '
'A second start is blocked and logged as a security event.',
)
suspicious_event_threshold = fields.Integer(
string='Strict-mode alert threshold',
default=5,
help='Strict mode auto-locks the attempt after this many '
'alert-severity events. Ignored for soft / off.',
)

View File

@@ -0,0 +1,63 @@
"""Handwritten solution submissions (Phase 2 — 2026-04-30).
Students upload images of handwritten work for math/science assignments
and the platform OCRs + AI-grades them. Each submission stores the raw
images plus the AI-generated rubric scoring, and can be reviewed/
overridden by the teacher.
"""
from odoo import models, fields
class EncoachHandwrittenSubmission(models.Model):
_name = 'encoach.handwritten.submission'
_description = 'Handwritten Solution Submission'
_order = 'submitted_at desc, id desc'
student_id = fields.Many2one(
'res.users', required=True, ondelete='cascade', index=True,
)
material_id = fields.Many2one(
'encoach.course.plan.material', ondelete='cascade', index=True,
help='The weekly-plan material the student is responding to '
'(typically kind=assignment).',
)
plan_id = fields.Many2one(
'encoach.course.plan', related='material_id.plan_id',
store=True, index=True,
)
submitted_at = fields.Datetime(default=fields.Datetime.now, required=True)
image_attachment_ids = fields.Many2many(
'ir.attachment',
'encoach_handwritten_attachment_rel',
'submission_id', 'attachment_id',
string='Pages',
help='One attachment per handwritten page. JPEGs or PNGs.',
)
student_note = fields.Text()
status = fields.Selection(
[
('submitted', 'Submitted'),
('grading', 'AI grading'),
('graded', 'AI graded'),
('reviewed', 'Teacher reviewed'),
('failed', 'Grading failed'),
],
default='submitted', required=True, index=True,
)
ai_score = fields.Float(
help='AI-suggested score (0100). Teachers can override.',
)
ai_feedback = fields.Text(
help='Markdown-formatted rubric feedback from the AI grader.',
)
ai_extracted_text = fields.Text(
help='OCR + math-LaTeX extraction. Stored so the teacher can '
'verify the AI parsed the handwriting correctly.',
)
teacher_score = fields.Float()
teacher_feedback = fields.Text()
reviewed_by = fields.Many2one('res.users', ondelete='set null')
reviewed_at = fields.Datetime()

View File

@@ -0,0 +1,137 @@
"""Live online classroom sessions (Phase 3 — 2026-04-30).
We embed Jitsi Meet as the conferencing engine, so most of the
in-call features (breakout rooms, polls, whiteboard, screen share,
chat box, recording, waiting room, host media controls) are driven
by Jitsi's IframeAPI. The Odoo side owns:
* scheduling + calendar metadata (`scheduled_at`, `duration_min`)
* enrolment list + email notification on creation
* attendance tracking (auto-mark + late-entry restriction)
* recording URLs + status transitions
* removal log (who was kicked and why — required by spec)
A session is hosted in a Jitsi room whose name is generated from
``room_name`` (slugified, unique). The frontend joins the room via
the IframeAPI and reports lifecycle events to the backend.
"""
import secrets
from odoo import models, fields, api
class EncoachLiveSession(models.Model):
_name = 'encoach.live.session'
_description = 'Live Online Session'
_order = 'scheduled_at desc, id desc'
_rec_name = 'title'
title = fields.Char(required=True)
description = fields.Text()
course_id = fields.Many2one('op.course', ondelete='set null', index=True)
batch_id = fields.Many2one('op.batch', ondelete='set null', index=True)
plan_id = fields.Many2one(
'encoach.course.plan', ondelete='set null', index=True,
help='Optional link to an AI-generated course plan instead of a '
'traditional op.course.',
)
entity_id = fields.Many2one('encoach.entity', ondelete='set null', index=True)
host_id = fields.Many2one(
'res.users', required=True, ondelete='restrict', index=True,
default=lambda self: self.env.user.id,
help='User who scheduled the session and acts as the Jitsi '
'moderator (mute/cam controls, kick, lobby).',
)
scheduled_at = fields.Datetime(required=True, index=True)
duration_min = fields.Integer(default=60, required=True)
actual_started_at = fields.Datetime()
actual_ended_at = fields.Datetime()
status = fields.Selection(
[
('scheduled', 'Scheduled'),
('live', 'Live'),
('ended', 'Ended'),
('cancelled', 'Cancelled'),
],
default='scheduled', required=True, index=True,
)
room_name = fields.Char(
required=True, copy=False, index=True,
help='Slug used as the Jitsi room name. Random by default to '
'prevent guessing public sessions.',
)
waiting_room = fields.Boolean(
default=True,
help='Enable Jitsi lobby — host has to admit each student.',
)
recording_enabled = fields.Boolean(default=False)
recording_url = fields.Char(
help='Filled in once the host stops recording. Stored URL '
'should be served by the platform CDN, not Jitsi.',
)
auto_attendance_minutes = fields.Integer(
default=10,
help='Mark a participant "present" once they have been in the '
'session for at least this many minutes. 0 disables.',
)
late_entry_minutes = fields.Integer(
default=15,
help='Refuse new joins after the session has been live for '
'this many minutes. 0 disables.',
)
notification_sent = fields.Boolean(
default=False, copy=False,
help='Toggled to True after we e-mail the enrolment list. '
'Prevents double notifications when the row is edited.',
)
participant_ids = fields.One2many(
'encoach.live.session.participant', 'session_id',
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('room_name'):
vals['room_name'] = 'enc-' + secrets.token_hex(6)
return super().create(vals_list)
class EncoachLiveSessionParticipant(models.Model):
_name = 'encoach.live.session.participant'
_description = 'Live Session Participant'
_order = 'joined_at desc, id desc'
session_id = fields.Many2one(
'encoach.live.session', required=True, ondelete='cascade', index=True,
)
user_id = fields.Many2one(
'res.users', required=True, ondelete='cascade', index=True,
)
joined_at = fields.Datetime()
left_at = fields.Datetime()
duration_seconds = fields.Integer(
help='Cumulative time-in-call across joins (people refresh, '
'switch networks). Updated on every leave event.',
)
attendance_status = fields.Selection(
[
('not_joined', 'Not joined'),
('attending', 'Attending'),
('present', 'Present'),
('left_early', 'Left early'),
('removed', 'Removed'),
],
default='not_joined', required=True, index=True,
)
removed_reason = fields.Text(
help='Required when attendance_status = removed. Used by the '
'audit trail tab.',
)
is_host = fields.Boolean(default=False)

View File

@@ -0,0 +1,32 @@
"""Mark AI-generated study plans as personalized to a single student.
Phase 2 (2026-04-30): every student can ask the AI for a custom
remediation plan based on their weakest skills. We reuse the existing
`encoach.course.plan` machinery (weeks + materials) but flag the
record so it shows up under "My Personalized Plan" instead of in the
admin/teacher catalog.
"""
from odoo import models, fields
class EncoachCoursePlan(models.Model):
_inherit = 'encoach.course.plan'
is_personalized = fields.Boolean(
string='Personalized for student',
default=False, index=True,
help='True when the plan was generated specifically for one '
'student via /api/student/personalized-plan. Hidden from '
'the regular admin/teacher catalog.',
)
personalized_for_id = fields.Many2one(
'res.users', ondelete='set null', index=True,
string='Personalized for',
help='Student the plan was generated for. Only set when '
'is_personalized=True.',
)
weakness_summary = fields.Text(
help='JSON snapshot of the skill scores that drove the AI '
'generation. Stored so we can re-run the planner with '
'the same input later.',
)

View File

@@ -25,3 +25,9 @@ access_encoach_grammar_rule_all,encoach.grammar.rule.all,model_encoach_grammar_r
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
access_encoach_lms_branch_user,encoach.lms.branch.user,model_encoach_lms_branch,base.group_user,1,1,1,1
access_encoach_course_section_user,encoach.course.section.user,model_encoach_course_section,base.group_user,1,1,1,1
access_encoach_exam_security_event_user,encoach.exam.security.event.user,model_encoach_exam_security_event,base.group_user,1,1,1,1
access_encoach_live_session_user,encoach.live.session.user,model_encoach_live_session,base.group_user,1,1,1,1
access_encoach_live_session_participant_user,encoach.live.session.participant.user,model_encoach_live_session_participant,base.group_user,1,1,1,1
access_encoach_handwritten_submission_user,encoach.handwritten.submission.user,model_encoach_handwritten_submission,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
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
28 access_encoach_lms_branch_user encoach.lms.branch.user model_encoach_lms_branch base.group_user 1 1 1 1
29 access_encoach_course_section_user encoach.course.section.user model_encoach_course_section base.group_user 1 1 1 1
30 access_encoach_exam_security_event_user encoach.exam.security.event.user model_encoach_exam_security_event base.group_user 1 1 1 1
31 access_encoach_live_session_user encoach.live.session.user model_encoach_live_session base.group_user 1 1 1 1
32 access_encoach_live_session_participant_user encoach.live.session.participant.user model_encoach_live_session_participant base.group_user 1 1 1 1
33 access_encoach_handwritten_submission_user encoach.handwritten.submission.user model_encoach_handwritten_submission base.group_user 1 1 1 1

View File

@@ -12,6 +12,13 @@ class EncoachResource(models.Model):
('document', 'Document'),
('link', 'Link'),
('interactive', 'Interactive'),
# Audio + image are surfaced in the Resource Manager table
# (icons / filters) so we accept them as first-class types
# rather than coercing them into ``document`` and losing the
# MIME-correct preview (audio player, <img>, etc.).
('audio', 'Audio'),
('image', 'Image'),
('article', 'Article'),
])
review_status = fields.Selection([
('pending', 'Pending'),
@@ -30,6 +37,24 @@ class EncoachResource(models.Model):
'resource_id', 'tag_id', string='Tags',
)
file = fields.Binary(attachment=True)
# Preserve the original upload filename (with extension) so the
# download endpoint can serve "report.pdf" even when the human-
# readable ``name`` field is set to "Q3 Sales Report" without an
# extension. We also keep the resolved MIME type to avoid sniffing
# by extension on every download — sniffing fails on resources
# named "test" with no dot, and was the root cause of admins
# downloading files with no extension and ``application/octet-stream``
# ending up unviewable on macOS / Windows.
original_filename = fields.Char(
string='Original filename',
help='Filename as uploaded, including extension. Used by the '
'download endpoint to produce a sensible Content-Disposition.',
)
mimetype = fields.Char(
string='MIME type',
help='Cached MIME type from the uploaded file or URL. Drives '
'preview decisions (PDF in iframe vs <img> vs <video>).',
)
url = fields.Char()
difficulty = fields.Selection([
('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced'),
@@ -67,6 +92,16 @@ class EncoachResource(models.Model):
def to_api_dict(self):
self.ensure_one()
creator = self.creator_id
# Both URLs are JWT-protected via the resources controller; the
# frontend appends ``?token=…`` with the existing
# ``withAuthQuery`` helper so they can be used as ``href``,
# ``src`` of <iframe>/<img>, or download anchors.
download_url = (
f'/api/resources/{self.id}/download' if self.file else ''
)
preview_url = (
f'/api/resources/{self.id}/download?inline=1' if self.file else ''
)
return {
'id': self.id,
'name': self.name,
@@ -82,6 +117,10 @@ class EncoachResource(models.Model):
'learning_objective_names': self.learning_objective_ids.mapped('name'),
'url': self.url or '',
'has_file': bool(self.file),
'mimetype': self.mimetype or '',
'original_filename': self.original_filename or '',
'download_url': download_url,
'preview_url': preview_url,
'difficulty': self.difficulty or '',
'duration_minutes': self.duration_minutes,
'author_id': creator.id if creator else None,
@@ -98,5 +137,5 @@ class EncoachResource(models.Model):
'approved': self.approved,
'course_count': self.course_count,
'created_at': self.create_date.isoformat() if self.create_date else '',
'file_name': self.name,
'file_name': self.original_filename or self.name,
}

View File

@@ -23,6 +23,7 @@ class EncoachEmbedding(models.Model):
('feedback', 'Feedback'),
('generation_log', 'Generation Log'),
('material', 'Course Material'),
('course_plan_source', 'Course Plan Source'),
], required=True, index=True)
content_id = fields.Integer(required=True, index=True)
content_text = fields.Text()

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"

View File

@@ -0,0 +1,449 @@
# 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 "Año Académico"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Acción necesaria"
#. 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 "Activo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Actividades"
#. 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 "Actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Recuento de actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Decoración de excepción de actividad"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Vista de gráfico de actividad"
#. 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 "Registro de actividad"
#. 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 "Registros de actividad"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Pivote de actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Estado de actividad"
#. 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 "Tipo de actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Icono de tipo de actividad"
#. 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 "Tipos de actividad"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "¡El tipo de actividad debe ser único!"
#. 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 "Archivado"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Recuento de archivos adjuntos"
#. 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 ""
"¡No se puede migrar porque los cursos seleccionados no comparten el mismo "
"programa!"
#. 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 ""
"No se puede migrar porque los cursos seleccionados no comparten el mismo "
"curso principal."
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "No puedo migrar. Continúe con una nueva admisión."
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Cancelar"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "¿Curso completado?"
#. 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 "Creado por"
#. 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 "Creado el"
#. 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 "Fecha"
#. 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 "Descripción"
#. 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 "Nombre para mostrar"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Facultad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Seguidores"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Seguidores (socios)"
#. 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 "Icono de fuente impresionante, p. tareas fa"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Adelante"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Del curso"
#. 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 "¡Desde el curso no debe ser lo mismo que hacia el curso!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Tiene mensaje"
#. 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 "Le ayuda a administrar los diferentes usuarios de sus institutos."
#. 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 "IDENTIFICACIÓN"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Icono"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Icono para indicar una actividad de excepción."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Si está marcado, los mensajes nuevos requieren su atención."
#. 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 "Si está marcado, algunos mensajes tienen un error de entrega."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "es seguidor"
#. 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 "Actualizado por última vez por"
#. 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 "Última actualización el"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Gerente"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Error de entrega de mensaje"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Mensajes"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Fecha límite de mi actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Nombre"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Fecha límite para la próxima actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Resumen de la próxima actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Siguiente tipo de actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Número de acciones"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Número de errores"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Número de mensajes que requieren acción"
#. 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 "Número de mensajes con error de entrega"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Privilegio de actividad de Openeducat"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Materias Optativas"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Usuario responsable"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Error de entrega de SMS"
#. 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 ""
"Estado basado en actividades.\n"
"Vencido: la fecha de vencimiento ya pasó\n"
"Hoy: la fecha de la actividad es hoy.\n"
"Planificado: Actividades futuras."
#. 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 "Alumno"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Actividad estudiantil"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Dominio de identificación de estudiantes"
#. 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 "Migración de estudiantes"
#. 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 "Migración estudiantil"
#. 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 "Estudiantes)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Términos"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Para lotear"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Al curso"
#. 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 "Tipo de actividad de excepción registrada."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Usuario"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Válido para cursos"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Mensajes del sitio web"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Historial de comunicación del sitio web"

View File

@@ -0,0 +1,445 @@
# 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 "From Course نباید با To Course یکی باشد!"
#. 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,450 @@
# 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 "Année académique"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Action nécessaire"
#. 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 "Actif"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Activités"
#. 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 "Activité"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Nombre d'activités"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Activité Exception Décoration"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Vue graphique d'activité"
#. 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 "Journal d'activité"
#. 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 "Journaux d'activité"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Pivot d'activité"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "État d'activité"
#. 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 "Type d'activité"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Icône de type d'activité"
#. 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 "Types d'activités"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "Le type d'activité doit être unique !"
#. 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 "Archivé"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Nombre de pièces jointes"
#. 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 ""
"Impossible de migrer, car les cours sélectionnés ne partagent pas le même "
"programme !"
#. 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 ""
"Impossible de migrer, car les cours sélectionnés ne partagent pas le même "
"cours parent !"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Impossible de migrer, procéder à une nouvelle admission"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Annuler"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Cours terminé ?"
#. 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 "Créé par"
#. 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 "Créé le"
#. 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 "Date"
#. 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 "Description"
#. 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 "Nom d'affichage"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Faculté"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Abonnés"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Abonnés (partenaires)"
#. 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 "Icône géniale de police, par exemple. tâches fat"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Avant"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Du cours"
#. 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 "Du cours ne doit pas être identique à To Course !"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "A un message"
#. 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 "Vous aide à gérer les différents utilisateurs de votre institut."
#. 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 "IDENTIFIANT"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Icône"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Icône pour indiquer une activité d'exception."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr ""
"Si cette case est cochée, les nouveaux messages nécessitent votre attention."
#. 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 "Si coché, certains messages ont une erreur de livraison."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "Est un suiveur"
#. 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 "Dernière mise à jour par"
#. 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 "Dernière mise à jour le"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Directeur"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Erreur de livraison du message"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Messages"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Date limite de mon activité"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Nom"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Date limite de la prochaine activité"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Résumé de l'activité suivante"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Type d'activité suivant"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Nombre d'actions"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Nombre d'erreurs"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Nombre de messages nécessitant une action"
#. 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 "Nombre de messages avec erreur de livraison"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Privilège dactivité Openeducat"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Sujets optionnels"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Utilisateur responsable"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Erreur de livraison SMS"
#. 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 ""
"Statut basé sur les activités\n"
"En retard : la date d'échéance est déjà dépassée\n"
"Aujourd'hui : la date de l'activité est aujourd'hui\n"
"Prévu : Activités futures."
#. 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 "Étudiant"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Activité étudiante"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Domaine d'identification des étudiants"
#. 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 "Étudiant migrer"
#. 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 "Migration des étudiants"
#. 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 "Étudiants)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Termes"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Vers un lot"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Cours"
#. 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 dactivité exceptionnelle enregistrée."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Utilisateur"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Valable pour les cours"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Messages du site Web"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Historique des communications du site Web"

View File

@@ -0,0 +1,450 @@
# 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 "Tahun Akademik"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Dibutuhkan Tindakan"
#. 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 "Aktif"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Kegiatan"
#. 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 "Aktivitas"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Jumlah Aktivitas"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Dekorasi Pengecualian Aktivitas"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Tampilan Grafik Aktivitas"
#. 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 "Catatan Aktivitas"
#. 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 "Log Aktivitas"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Poros Aktivitas"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Status Aktivitas"
#. 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 "Jenis Aktivitas"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Ikon Jenis Aktivitas"
#. 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 "Jenis Aktivitas"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "Jenis aktivitas harus 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 "Diarsipkan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Jumlah Lampiran"
#. 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 ""
"Tidak dapat bermigrasi, Karena kursus yang dipilih tidak berbagi Program "
"yang sama!"
#. 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 ""
"Tidak dapat bermigrasi, Karena kursus yang dipilih tidak berbagi kursus "
"induk yang sama!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Tidak dapat bermigrasi, Lanjutkan untuk penerimaan baru"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Membatalkan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Kursus Selesai?"
#. 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 "Dibuat oleh"
#. 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 "Dibuat pada"
#. 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 "Tanggal"
#. 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 "Keterangan"
#. 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 "Nama Tampilan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Fakultas"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Pengikut"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Pengikut (Mitra)"
#. 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 "Ikon font yang mengagumkan, mis. tugas fa"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Maju"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Dari 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 "From Course tidak boleh sama dengan To Course!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Memiliki Pesan"
#. 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 ""
"Membantu Anda mengelola lembaga Anda dengan pengguna yang berbeda-beda."
#. 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 "PENGENAL"
#. 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 untuk menunjukkan aktivitas pengecualian."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Jika dicentang, pesan baru memerlukan perhatian Anda."
#. 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 "Jika dicentang, beberapa pesan mengalami kesalahan pengiriman."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "Adalah Pengikut"
#. 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 "Terakhir Diperbarui oleh"
#. 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 "Terakhir Diperbarui pada"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Manajer"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Kesalahan Pengiriman Pesan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Pesan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Batas Waktu Aktivitas Saya"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Nama"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Batas Waktu Kegiatan Berikutnya"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Ringkasan Kegiatan Berikutnya"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Jenis Aktivitas Berikutnya"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Jumlah Tindakan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Jumlah kesalahan"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Jumlah pesan yang memerlukan tindakan"
#. 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 "Jumlah pesan dengan kesalahan pengiriman"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Hak Istimewa Aktivitas Openeducat"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Mata Pelajaran Opsional"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Pengguna yang Bertanggung Jawab"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Kesalahan Pengiriman SMS"
#. 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 berdasarkan aktivitas\n"
"Terlambat: Tanggal jatuh tempo sudah lewat\n"
"Hari ini: Tanggal aktivitas adalah hari ini\n"
"Direncanakan: Kegiatan di masa depan."
#. 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 "Murid"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Aktivitas Siswa"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Domain ID Siswa"
#. 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 "Migrasi Siswa"
#. 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 "Migrasi Mahasiswa"
#. 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 "Siswa"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Ketentuan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Untuk Batch"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Ke 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 "Jenis aktivitas pengecualian yang tercatat."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Pengguna"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Berlaku Untuk Kursus"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Pesan Situs Web"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Riwayat komunikasi situs web"

View File

@@ -0,0 +1,449 @@
# 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 "Anno accademico"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Azione necessaria"
#. 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 "Attivo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Attività"
#. 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 "Attività"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Conteggio attività"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Decorazione delle eccezioni di attività"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Visualizzazione grafico delle attività"
#. 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 "Registro delle attività"
#. 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 "Registri delle attività"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Perno di attività"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Stato attività"
#. 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 "Tipo di attività"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Icona del tipo di attività"
#. 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 "Tipi di attività"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "Il tipo di attività deve essere unico!"
#. 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 "Archiviato"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Conteggio allegati"
#. 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 ""
"Impossibile eseguire la migrazione, poiché i corsi selezionati non "
"condividono lo stesso programma!"
#. 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 ""
"Impossibile migrare, poiché i corsi selezionati non condividono lo stesso "
"corso principale!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Impossibile migrare, procedere alla nuova ammissione"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Cancellare"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Corso completato?"
#. 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 "Creato da"
#. 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 "Creato il"
#. 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 "Data"
#. 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 "Descrizione"
#. 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 "Nome da visualizzare"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Facoltà"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Seguaci"
#. 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 "Icona fantastica del carattere, ad es. compiti fa"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Inoltrare"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Da Corso"
#. 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 "Dal corso non deve essere uguale a Al corso!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Ha un messaggio"
#. 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 "Ti aiuta a gestire i diversi utenti dei tuoi istituti."
#. 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 "Icona"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Icona per indicare un'attività di eccezione."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Se selezionato, i nuovi messaggi richiedono la tua attenzione."
#. 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 "Se selezionato, alcuni messaggi presentano un errore di consegna."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "È seguace"
#. 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 "Ultimo aggiornamento di"
#. 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 "Ultimo aggiornamento il"
#. 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 "Errore di consegna del messaggio"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Messaggi"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Scadenza della mia attività"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Nome"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Prossima scadenza delle attività"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Riepilogo attività successiva"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Tipo di attività successiva"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Numero di azioni"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Numero di errori"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Numero di messaggi che richiedono un'azione"
#. 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 "Numero di messaggi con errore di consegna"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Privilegio di attività Openeducat"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Materie facoltative"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Utente responsabile"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Errore di consegna dell'SMS"
#. 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 ""
"Stato in base alle attività\n"
"In ritardo: la data di scadenza è già trascorsa\n"
"Oggi: la data dell'attività è oggi\n"
"Pianificato: attività future."
#. 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 "Studente"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Attività degli studenti"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Dominio ID studente"
#. 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 "Studenti Migrati"
#. 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 "Migrazione degli studenti"
#. 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 "Studente(i)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Termini"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "In batch"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Al corso"
#. 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 "Tipo di attività di eccezione registrata."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Utente"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Valido per i corsi"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Messaggi del sito web"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Cronologia della comunicazione del sito web"

View File

@@ -0,0 +1,445 @@
# 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 "Akadēmiskais gads"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Nepieciešama darbība"
#. 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 "Aktīvs"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Darbības"
#. 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āte"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Aktivitāšu skaits"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Darbības izņēmuma dekorēšana"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Aktivitāšu diagrammas skats"
#. 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 "Darbību žurnāls"
#. 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 "Darbību žurnāli"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Aktivitāte Pivot"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Darbības stāvoklis"
#. 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 "Darbības veids"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Darbības veida ikona"
#. 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āšu veidi"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "Aktivitātes veidam ir jābūt unikālam!"
#. 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 "Arhivēts"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Pielikumu skaits"
#. 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 "Nevar migrēt, jo atlasītajiem kursiem nav kopīga programma!"
#. 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 "Nevar migrēt, jo atlasītajiem kursiem nav kopīgs vecāku kurss!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Nevar migrēt. Turpiniet jaunu uzņemšanu"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Atcelt"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Kurss pabeigts?"
#. 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 "Izveidoja"
#. 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 "Izveidots"
#. 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 "Datums"
#. 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 "Apraksts"
#. 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 "Parādāmais nosaukums"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Fakultāte"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Sekotāji"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Sekotāji (partneri)"
#. 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 "Lieliska fonta ikona, piem. fa-uzdevumi"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Uz priekšu"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "No kursa"
#. 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 "No kursa nedrīkst būt tas pats, kas uz kursu!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Ir ziņa"
#. 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 "Palīdz pārvaldīt savus institūtus dažādus lietotājus."
#. 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 "Ikona"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Ikona, kas norāda izņēmuma darbību."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Ja atzīmēta, jums jāpievērš uzmanība jauniem ziņojumiem."
#. 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 "Ja atzīmēta, dažos ziņojumos ir piegādes kļūda."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "Ir sekotājs"
#. 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 "Pēdējo reizi atjaunināja"
#. 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 "Pēdējo reizi atjaunināts"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Pārvaldnieks"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Ziņojuma piegādes kļūda"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Ziņojumi"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Manas aktivitātes termiņš"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Vārds"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Nākamās aktivitātes termiņš"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Nākamās aktivitātes kopsavilkums"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Nākamais darbības veids"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Darbību skaits"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Kļūdu skaits"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "To ziņojumu skaits, kuriem nepieciešama darbība"
#. 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 "Ziņojumu skaits ar piegādes kļūdu"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Openeducat aktivitātes privilēģija"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Izvēles priekšmeti"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Atbildīgs lietotājs"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "SMS piegādes kļūda"
#. 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 ""
"Statuss, pamatojoties uz aktivitātēm\n"
"Nokavēts: izpildes termiņš jau ir pagājis\n"
"Šodien: aktivitātes datums ir šodien\n"
"Plānots: Nākotnes aktivitātes."
#. 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 "Students"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Studentu darbība"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Studentu ID domēns"
#. 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 "Studentu migrācija"
#. 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 "Studentu migrācija"
#. 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 "Students(-i)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Noteikumi"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Uz partiju"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Uz Kursu"
#. 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 "Reģistrētās izņēmuma darbības veids."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Lietotājs"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Derīgs uz kursiem"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Vietnes ziņojumi"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Vietnes komunikācijas vēsture"

View File

@@ -0,0 +1,450 @@
# 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 "Academisch Jaar"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Actie nodig"
#. 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 "Actief"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Activiteiten"
#. 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 "Activiteit"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Aantal activiteiten"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Activiteit Uitzondering Decoratie"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Activiteitsgrafiekweergave"
#. 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 "Activiteitenlogboek"
#. 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 "Activiteitenlogboeken"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Activiteitsdraaipunt"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Activiteitsstatus"
#. 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 "Activiteitstype"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Pictogram voor activiteitstype"
#. 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 "Activiteitstypen"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "Activiteitstype moet uniek zijn!"
#. 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 "Gearchiveerd"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Aantal bijlagen"
#. 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 niet migreren, omdat geselecteerde cursussen niet hetzelfde programma "
"delen!"
#. 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 niet migreren, omdat geselecteerde cursussen niet dezelfde bovenliggende"
" cursus delen!"
#. 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 niet migreren. Ga verder voor nieuwe toelating"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Annuleren"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Cursus voltooid?"
#. 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 "Gemaakt door"
#. 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 "Gemaakt op"
#. 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 "Beschrijving"
#. 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 "Weergavenaam"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Faculteit"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Volgers"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Volgers (partners)"
#. 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 "Lettertype geweldig pictogram, b.v. fa-taken"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Vooruit"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Van cursus"
#. 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 "Van koers mag niet hetzelfde zijn als Naar koers!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Heeft bericht"
#. 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 ""
"Helpt u bij het beheren van uw instellingen met verschillende gebruikers."
#. 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 "Identiteitskaart"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Icon"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Pictogram om een uitzonderingsactiviteit aan te geven."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Indien aangevinkt, vereisen nieuwe berichten uw aandacht."
#. 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 "Indien aangevinkt, hebben sommige berichten een bezorgfout."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "Is volger"
#. 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 "Laatst bijgewerkt door"
#. 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 "Laatst bijgewerkt op"
#. 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 "Fout bij bezorging van bericht"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Berichten"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Mijn activiteitsdeadline"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Naam"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Deadline volgende activiteit"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Samenvatting van volgende activiteit"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Volgende activiteitstype"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Aantal acties"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Aantal fouten"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Aantal berichten waarvoor actie vereist is"
#. 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 "Aantal berichten met bezorgfout"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Openeducat-activiteitenprivilege"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Optionele onderwerpen"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Verantwoordelijke gebruiker"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "SMS-bezorgfout"
#. 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 op basis van activiteiten\n"
"Te laat: De vervaldatum is al verstreken\n"
"Vandaag: de activiteitsdatum is vandaag\n"
"Gepland: toekomstige activiteiten."
#. 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 "Studentenactiviteit"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Domein Student-ID's"
#. 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 "Studenten migreren"
#. 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 "Migratie van studenten"
#. 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 "Voorwaarden"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Naar batch"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Naar cursus"
#. 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 van de geregistreerde uitzonderingsactiviteit."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Gebruiker"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Geldig voor cursussen"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Websiteberichten"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Communicatiegeschiedenis van websites"

View File

@@ -0,0 +1,441 @@
# 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 ""
#. 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 ""
#. 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,450 @@
# 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 "Ano Letivo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Ação necessária"
#. 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 "Ativo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Atividades"
#. 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 "Atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Contagem de atividades"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Decoração de exceção de atividade"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Visualização do gráfico de atividades"
#. 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 "Registro de atividades"
#. 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 "Registros de atividades"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Pivô de atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Estado da atividade"
#. 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 "Tipo de atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Ícone de tipo de atividade"
#. 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 "Tipos de atividades"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "O tipo de atividade deve ser único!"
#. 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 "Arquivado"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Contagem de anexos"
#. 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 ""
"Não é possível migrar, pois os cursos selecionados não compartilham o mesmo "
"programa!"
#. 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 ""
"Não é possível migrar, pois os cursos selecionados não compartilham o mesmo "
"curso pai!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Não é possível migrar. Prossiga para nova admissão"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Cancelar"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Curso concluído?"
#. 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 "Criado por"
#. 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 "Criado em"
#. 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 "Data"
#. 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 "Descrição"
#. 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 "Nome de exibição"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Faculdade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Seguidores"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Seguidores (parceiros)"
#. 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 "Ícone incrível da fonte, por exemplo. tarefas fa"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Avançar"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Do curso"
#. 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 "From Course não deve ser igual a To Course!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Tem mensagem"
#. 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 ""
"Ajuda você a gerenciar usuários diferentes e diferentes de seus institutos."
#. 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 "EU IA"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Ícone"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Ícone para indicar uma atividade de exceção."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Se marcada, novas mensagens requerem sua atenção."
#. 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 "Se marcada, algumas mensagens apresentam um erro de entrega."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "É seguidor"
#. 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 "Última atualização por"
#. 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 "Última atualização em"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Gerente"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Erro na entrega de mensagens"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Mensagens"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Prazo da minha atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Nome"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Prazo da próxima atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Resumo da próxima atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Próximo tipo de atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Número de ações"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Número de erros"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Número de mensagens que exigem ação"
#. 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 "Número de mensagens com erro de entrega"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Privilégio de atividade Openeducat"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Disciplinas Opcionais"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Usuário Responsável"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Erro de entrega de SMS"
#. 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 baseado em atividades\n"
"Atrasado: a data de vencimento já passou\n"
"Hoje: a data da atividade é hoje\n"
"Planejado: Atividades futuras."
#. 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 "Estudante"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Atividade do Aluno"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Domínio de IDs de Estudante"
#. 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 "Migração de Alunos"
#. 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 "Migração de Estudantes"
#. 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 "Aluno(s)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Termos"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Para lote"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Para o curso"
#. 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 "Tipo de atividade de exceção registrada."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Usuário"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Válido para cursos"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Mensagens do site"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Histórico de comunicação do site"

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