Compare commits
25 Commits
6712d1d551
...
v4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62075c30aa | ||
|
|
d5b1987bba | ||
|
|
747e911829 | ||
|
|
744cede1a3 | ||
|
|
3b62075d7e | ||
|
|
a6f2140a9d | ||
|
|
35ccc0dfb2 | ||
|
|
096b042daf | ||
|
|
0ed7f88cab | ||
|
|
ed8e75d88c | ||
|
|
971e9860c8 | ||
|
|
8d173b93cb | ||
| a5a3a2dc62 | |||
|
|
fa6f4976c3 | ||
|
|
e2aa8031ff | ||
|
|
1223074bde | ||
|
|
75ee0f1fe0 | ||
|
|
d34180e107 | ||
|
|
eef3edf7e8 | ||
|
|
a554ef5d42 | ||
| b1b3d20eb4 | |||
|
|
bab588b9da | ||
| e33a9a61bb | |||
|
|
e1f059069f | ||
| 7024197c7b |
47
.cursorindexingignore
Normal file
47
.cursorindexingignore
Normal 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
|
||||
74
.gitea/workflows/deploy.yml
Normal file
74
.gitea/workflows/deploy.yml
Normal 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
12
.gitignore
vendored
@@ -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/
|
||||
|
||||
BIN
backend/GE1 Course Outline_ Fall AY25-26.pdf
Normal file
BIN
backend/GE1 Course Outline_ Fall AY25-26.pdf
Normal file
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
410
backend/custom_addons/encoach_ai/data/agents_defaults.xml
Normal file
410
backend/custom_addons/encoach_ai/data/agents_defaults.xml
Normal 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>
|
||||
@@ -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
|
||||
|
||||
358
backend/custom_addons/encoach_ai/models/ai_agent.py
Normal file
358
backend/custom_addons/encoach_ai/models/ai_agent.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
531
backend/custom_addons/encoach_ai/services/agent_runtime.py
Normal file
531
backend/custom_addons/encoach_ai/services/agent_runtime.py
Normal 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)
|
||||
418
backend/custom_addons/encoach_ai/services/agent_tools.py
Normal file
418
backend/custom_addons/encoach_ai/services/agent_tools.py
Normal 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,
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
150
backend/custom_addons/encoach_ai/services/free_image.py
Normal file
150
backend/custom_addons/encoach_ai/services/free_image.py
Normal 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()
|
||||
128
backend/custom_addons/encoach_ai/services/free_tts.py
Normal file
128
backend/custom_addons/encoach_ai/services/free_tts.py
Normal 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,
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
187
backend/custom_addons/encoach_ai/services/provider_router.py
Normal file
187
backend/custom_addons/encoach_ai/services/provider_router.py
Normal 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')
|
||||
@@ -1 +1,2 @@
|
||||
from . import ai_course
|
||||
from . import course_plan
|
||||
|
||||
1445
backend/custom_addons/encoach_ai_course/controllers/course_plan.py
Normal file
1445
backend/custom_addons/encoach_ai_course/controllers/course_plan.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
426
backend/custom_addons/encoach_ai_course/models/course_plan.py
Normal file
426
backend/custom_addons/encoach_ai_course/models/course_plan.py
Normal 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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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''
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,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
158
backend/custom_addons/encoach_ai_course/services/deliverables.py
Normal file
158
backend/custom_addons/encoach_ai_course/services/deliverables.py
Normal 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,
|
||||
}
|
||||
@@ -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 1–3 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
|
||||
@@ -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()}'
|
||||
@@ -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
|
||||
281
backend/custom_addons/encoach_ai_course/services/rag_context.py
Normal file
281
backend/custom_addons/encoach_ai_course/services/rag_context.py
Normal 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()
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
'encoach_ai',
|
||||
'encoach_taxonomy',
|
||||
'encoach_resources',
|
||||
'encoach_scoring',
|
||||
'encoach_exam_template',
|
||||
'encoach_ai_course',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
174
backend/custom_addons/encoach_lms_api/controllers/branches.py
Normal file
174
backend/custom_addons/encoach_lms_api/controllers/branches.py
Normal 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
246
backend/custom_addons/encoach_lms_api/controllers/handwritten.py
Normal file
246
backend/custom_addons/encoach_lms_api/controllers/handwritten.py
Normal 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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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')
|
||||
|
||||
@@ -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>'
|
||||
)
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
188
backend/custom_addons/encoach_lms_api/controllers/turnitin.py
Normal file
188
backend/custom_addons/encoach_lms_api/controllers/turnitin.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
49
backend/custom_addons/encoach_lms_api/models/branch.py
Normal file
49
backend/custom_addons/encoach_lms_api/models/branch.py
Normal 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)
|
||||
|
||||
270
backend/custom_addons/encoach_lms_api/models/classroom_ext.py
Normal file
270
backend/custom_addons/encoach_lms_api/models/classroom_ext.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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.',
|
||||
)
|
||||
@@ -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 (0–100). 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()
|
||||
137
backend/custom_addons/encoach_lms_api/models/live_session.py
Normal file
137
backend/custom_addons/encoach_lms_api/models/live_session.py
Normal 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)
|
||||
@@ -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.',
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
24
backend/openeducat_erp-19.0/openeducat_erp-19.0/.coveragerc
Normal file
24
backend/openeducat_erp-19.0/openeducat_erp-19.0/.coveragerc
Normal 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*
|
||||
6
backend/openeducat_erp-19.0/openeducat_erp-19.0/.gitattributes
vendored
Normal file
6
backend/openeducat_erp-19.0/openeducat_erp-19.0/.gitattributes
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Normalise line endings:
|
||||
* text=auto
|
||||
|
||||
# Prevent certain files from being exported:
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
38
backend/openeducat_erp-19.0/openeducat_erp-19.0/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
backend/openeducat_erp-19.0/openeducat_erp-19.0/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
36
backend/openeducat_erp-19.0/openeducat_erp-19.0/.gitignore
vendored
Normal file
36
backend/openeducat_erp-19.0/openeducat_erp-19.0/.gitignore
vendored
Normal 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
|
||||
@@ -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']
|
||||
178
backend/openeducat_erp-19.0/openeducat_erp-19.0/LICENSE
Normal file
178
backend/openeducat_erp-19.0/openeducat_erp-19.0/LICENSE
Normal 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.
|
||||
115
backend/openeducat_erp-19.0/openeducat_erp-19.0/README.md
Normal file
115
backend/openeducat_erp-19.0/openeducat_erp-19.0/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# OpenEduCat Community Edition 🎓
|
||||
|
||||
[](LICENSE)
|
||||
[](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 you’re 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 institution’s 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 🗺️
|
||||
|
||||
We’re continuously evolving! Here’s a glimpse of what’s 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! 💻🎉*
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -0,0 +1 @@
|
||||
This module provide feature of Activity Management.
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 "تاريخ اتصالات الموقع"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 "تاریخچه ارتباطات وب سایت"
|
||||
@@ -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 d’activité 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 d’activité 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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 ""
|
||||
@@ -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
Reference in New Issue
Block a user