Merge pull request 'v4' (#1) from v4 into main
Some checks failed
Deploy to Staging / Deploy backend + frontend to staging (push) Has been cancelled

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-04-26 00:38:07 +02:00
1492 changed files with 401582 additions and 700 deletions

View File

@@ -0,0 +1,49 @@
name: Deploy to Staging
# Triggered on every push to main (after PR merge).
# Runs ONLY the deploy job — not tests — so the server
# always reflects the latest main without waiting for the
# full CI suite to finish. The existing ci.yml handles lint/tests.
on:
push:
branches: [main]
concurrency:
group: deploy-backend
cancel-in-progress: true
jobs:
deploy:
name: Deploy backend + frontend to staging
runs-on: self-hosted
steps:
- name: Pull latest code
run: |
cd /opt/encoach/encoach_backend_new_v2
git fetch origin
git reset --hard origin/main
echo "Now at: $(git log -1 --oneline)"
- name: Rebuild and restart containers
run: |
cd /opt/encoach/encoach_backend_new_v2
docker compose \
-f docker-compose.yml \
-f /opt/encoach/overrides/encoach.override.yml \
up -d --build --remove-orphans
echo "Containers after deploy:"
docker ps --format "table {{.Names}}\t{{.Status}}" | grep -E "encoach-v4|encoach-frontend"
- name: Smoke test
run: |
echo "Waiting 20s for Odoo to settle..."
sleep 20
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8069/api/health)
echo "/api/health => HTTP $STATUS"
if [ "$STATUS" != "200" ]; then
echo "WARNING: /api/health returned $STATUS (Odoo may still be starting)"
fi
FE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/)
echo "frontend / => HTTP $FE"
test "$FE" = "200" || exit 1

11
.gitignore vendored
View File

@@ -88,9 +88,13 @@ addons_enterprise/
addons_extra/ addons_extra/
new_project/enterprise-17/ 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/ new_project/openeducat_erp-19.0/
backend/openeducat_erp-19.0/
new_project/openeducat_erp-19.0.zip new_project/openeducat_erp-19.0.zip
new_project/openeducate_enterprise-17.zip new_project/openeducate_enterprise-17.zip
new_project/encoach_frontend_new_v1-main.zip new_project/encoach_frontend_new_v1-main.zip
@@ -105,3 +109,6 @@ htmlcov/
# Poetry # Poetry
poetry.lock poetry.lock
# Odoo DB backups (local only, not source-controlled)
backups/

Binary file not shown.

View File

@@ -17,12 +17,17 @@
"author": "EnCoach", "author": "EnCoach",
"depends": ["base", "encoach_core", "encoach_api"], "depends": ["base", "encoach_core", "encoach_api"],
"external_dependencies": { "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": [ "data": [
"security/ir.model.access.csv", "security/ir.model.access.csv",
"views/ai_settings_views.xml", "views/ai_settings_views.xml",
"data/ai_defaults.xml", "data/ai_defaults.xml",
"data/agents_defaults.xml",
], ],
"installable": True, "installable": True,
"application": True, "application": True,

View File

@@ -3,3 +3,5 @@ from . import coach_controller
from . import media_controller from . import media_controller
from . import prompt_controller from . import prompt_controller
from . import feedback_controller from . import feedback_controller
from . import agents_controller
from . import ai_settings_controller

View File

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

View File

@@ -24,6 +24,25 @@ def _get_json():
return {} 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): class AIController(http.Controller):
"""Handles /api/ai/* endpoints consumed by frontend AI components.""" """Handles /api/ai/* endpoints consumed by frontend AI components."""
@@ -37,7 +56,7 @@ class AIController(http.Controller):
return _json_response({"answer": "", "suggestions": []}) return _json_response({"answer": "", "suggestions": []})
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService 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", "")) result = ai.search_with_rag(query, context=body.get("context", ""))
return _json_response(result) return _json_response(result)
except Exception as e: except Exception as e:
@@ -69,7 +88,7 @@ class AIController(http.Controller):
body = _get_json() body = _get_json()
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService 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( result = ai.generate_insights(
body.get("data", {}), body.get("data", {}),
insight_type=body.get("type", "general"), insight_type=body.get("type", "general"),
@@ -85,7 +104,7 @@ class AIController(http.Controller):
def ai_alerts(self, **kw): def ai_alerts(self, **kw):
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService 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") context = request.params.get("context", "dashboard")
result = ai.generate_insights( result = ai.generate_insights(
{"context": context, "request": "alerts"}, {"context": context, "request": "alerts"},
@@ -103,7 +122,7 @@ class AIController(http.Controller):
body = _get_json() body = _get_json()
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService 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( narrative = ai.generate_report_narrative(
body.get("report_type", "performance"), body.get("report_type", "performance"),
body.get("data", {}), body.get("data", {}),
@@ -119,7 +138,7 @@ class AIController(http.Controller):
body = _get_json() body = _get_json()
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService 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( result = ai.batch_optimize(
body.get("items", []), body.get("items", []),
optimization_type=body.get("type", "schedule"), optimization_type=body.get("type", "schedule"),
@@ -135,7 +154,7 @@ class AIController(http.Controller):
body = _get_json() body = _get_json()
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService 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") skill = body.get("skill", "writing")
if skill == "speaking": if skill == "speaking":
result = ai.grade_speaking( result = ai.grade_speaking(
@@ -160,7 +179,7 @@ class AIController(http.Controller):
body = _get_json() body = _get_json()
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService 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( result = ai.generate_content_dedup(
body.get("content_type", "reading_passage"), body.get("content_type", "reading_passage"),
body.get("brief", {}), body.get("brief", {}),
@@ -204,7 +223,7 @@ class AIController(http.Controller):
body = _get_json() body = _get_json()
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env) ai = OpenAIService(request.env, language=_request_language())
messages = [ messages = [
{"role": "system", "content": ( {"role": "system", "content": (
"You are an educational taxonomy expert. Suggest topics for the given domain and level. " "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() body = _get_json()
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env) ai = OpenAIService(request.env, language=_request_language())
messages = [ messages = [
{"role": "system", "content": ( {"role": "system", "content": (
"Create a personalized learning plan. Return JSON: " "Create a personalized learning plan. Return JSON: "
@@ -246,7 +265,7 @@ class AIController(http.Controller):
body = _get_json() body = _get_json()
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env) ai = OpenAIService(request.env, language=_request_language())
messages = [ messages = [
{"role": "system", "content": ( {"role": "system", "content": (
"Generate a course outline. Return JSON: {\"chapters\": " "Generate a course outline. Return JSON: {\"chapters\": "
@@ -264,7 +283,7 @@ class AIController(http.Controller):
body = _get_json() body = _get_json()
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env) ai = OpenAIService(request.env, language=_request_language())
messages = [ messages = [
{"role": "system", "content": ( {"role": "system", "content": (
"Generate detailed chapter content for a course. Return JSON: " "Generate detailed chapter content for a course. Return JSON: "
@@ -283,7 +302,7 @@ class AIController(http.Controller):
body = _get_json() body = _get_json()
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env) ai = OpenAIService(request.env, language=_request_language())
messages = [ messages = [
{"role": "system", "content": ( {"role": "system", "content": (
"Create an assessment rubric. Return JSON: {\"rubric\": " "Create an assessment rubric. Return JSON: {\"rubric\": "
@@ -350,7 +369,7 @@ class AIController(http.Controller):
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService 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: if not ai.client:
raise RuntimeError("OpenAI not configured") raise RuntimeError("OpenAI not configured")
@@ -480,7 +499,7 @@ class AIController(http.Controller):
body = _get_json() body = _get_json()
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService 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) has_ai = bool(ai.client)
except Exception: except Exception:
ai, has_ai = None, False ai, has_ai = None, False
@@ -1353,7 +1372,7 @@ class AIController(http.Controller):
except KeyError: except KeyError:
return _json_response({"error": "encoach.exam.custom model not available"}, 500) 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 = { exam_vals = {
"title": title, "title": title,
"label": label, "label": label,
@@ -1539,11 +1558,46 @@ class AIController(http.Controller):
quality_summary["failed"], quality_summary["warned"], 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({ return _json_response({
"exam_id": exam.id, "exam_id": exam.id,
"status": exam.status, "status": exam.status,
"template_id": template_id, "template_id": template_id,
"total_questions": total_questions, "total_questions": total_questions,
"approval_request_id": approval_request_id,
"quality": quality_summary, "quality": quality_summary,
"schema_validation": { "schema_validation": {
"verdict": schema_report["verdict"], "verdict": schema_report["verdict"],
@@ -1618,7 +1672,7 @@ class AIController(http.Controller):
body = _get_json() body = _get_json()
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env) ai = OpenAIService(request.env, language=_request_language())
messages = [ messages = [
{"role": "system", "content": ( {"role": "system", "content": (
"You are an educational materials expert. Suggest learning materials " "You are an educational materials expert. Suggest learning materials "
@@ -1639,7 +1693,7 @@ class AIController(http.Controller):
body = _get_json() body = _get_json()
try: try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService 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( result = ai.generate_content(
body.get("content_type", "explanation"), body.get("content_type", "explanation"),
{"topic_id": topic_id, **body}, {"topic_id": topic_id, **body},

View File

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

View File

@@ -23,12 +23,25 @@ def _get_json():
return {} 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): class CoachController(http.Controller):
"""Handles /api/coach/* endpoints consumed by frontend AI coaching components.""" """Handles /api/coach/* endpoints consumed by frontend AI coaching components."""
def _get_coach(self): def _get_coach(self):
from odoo.addons.encoach_ai.services.coach_service import CoachService 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 ── # ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
@http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False) @http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False)

View File

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

View File

@@ -2,4 +2,5 @@ from . import ai_settings
from . import ai_log from . import ai_log
from . import ai_prompt from . import ai_prompt
from . import ai_feedback from . import ai_feedback
from . import ai_agent
from . import constants from . import constants

View File

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

View File

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

View File

@@ -5,3 +5,7 @@ access_ai_prompt_admin,encoach.ai.prompt admin,model_encoach_ai_prompt,base.grou
access_ai_prompt_user,encoach.ai.prompt user,model_encoach_ai_prompt,base.group_user,1,0,0,0 access_ai_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_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_feedback_user,encoach.ai.feedback user,model_encoach_ai_feedback,base.group_user,1,1,1,0
access_ai_agent_admin,encoach.ai.agent admin,model_encoach_ai_agent,base.group_system,1,1,1,1
access_ai_agent_user,encoach.ai.agent user,model_encoach_ai_agent,base.group_user,1,0,0,0
access_ai_tool_admin,encoach.ai.tool admin,model_encoach_ai_tool,base.group_system,1,1,1,1
access_ai_tool_user,encoach.ai.tool user,model_encoach_ai_tool,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_ai_prompt_user encoach.ai.prompt user model_encoach_ai_prompt base.group_user 1 0 0 0
6 access_ai_feedback_admin encoach.ai.feedback admin model_encoach_ai_feedback base.group_system 1 1 1 1
7 access_ai_feedback_user encoach.ai.feedback user model_encoach_ai_feedback base.group_user 1 1 1 0
8 access_ai_agent_admin encoach.ai.agent admin model_encoach_ai_agent base.group_system 1 1 1 1
9 access_ai_agent_user encoach.ai.agent user model_encoach_ai_agent base.group_user 1 0 0 0
10 access_ai_tool_admin encoach.ai.tool admin model_encoach_ai_tool base.group_system 1 1 1 1
11 access_ai_tool_user encoach.ai.tool user model_encoach_ai_tool base.group_user 1 0 0 0

View File

@@ -7,3 +7,8 @@ from .elai_service import ElaiService
from .coach_service import CoachService from .coach_service import CoachService
from . import cefr_mapper # canonical CEFR / band / theta mapper (P0.9) 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 question_validator # schema + quality gate for AI-generated questions (P1.6/P1.1)
from . import agent_tools # registry of tool handlers used by AgentRuntime
from .agent_runtime import AgentRuntime # LangGraph-backed core agent runtime
from . import provider_router # capability -> active-provider resolver
from . import free_image # offline Pillow-based image placeholder
from . import free_tts # gTTS + silent-MP3 audio fallbacks

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,45 @@ except ImportError:
_openai_mod = None _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: class OpenAIService:
"""Wraps the OpenAI Python SDK with Odoo settings and logging.""" """Wraps the OpenAI Python SDK with Odoo settings and logging."""
def __init__(self, env): def __init__(self, env, *, language=None):
self.env = env self.env = env
self._get_param = env["ir.config_parameter"].sudo().get_param self._get_param = env["ir.config_parameter"].sudo().get_param
self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes") self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes")
@@ -29,13 +64,77 @@ class OpenAIService:
import os import os
api_key = os.environ.get("OPENAI_API_KEY", "") api_key = os.environ.get("OPENAI_API_KEY", "")
if _openai_mod and 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: else:
self.client = None self.client = None
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o") 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.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): 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: try:
self.env["encoach.ai.log"].sudo().create({ self.env["encoach.ai.log"].sudo().create({
"service": "openai", "service": "openai",
@@ -50,15 +149,41 @@ class OpenAIService:
"input_preview": (inp or "")[:500], "input_preview": (inp or "")[:500],
"output_preview": (out or "")[:500], "output_preview": (out or "")[:500],
}) })
except Exception: except Exception as exc:
_logger.warning("Failed to log AI call", exc_info=True) # 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): def _check_enabled(self):
if not self.enabled: if not self.enabled:
raise RuntimeError("AI is disabled — enable in Settings > AI Configuration") 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): 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 last_exc = None
for attempt in range(self.max_retries): for attempt in range(self.max_retries):
try: try:
@@ -66,6 +191,12 @@ class OpenAIService:
except Exception as exc: except Exception as exc:
last_exc = exc last_exc = exc
err_str = str(exc).lower() 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_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 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: 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: if not self.client:
raise RuntimeError("OpenAI not configured — set API key in AI Settings") raise RuntimeError("OpenAI not configured — set API key in AI Settings")
model = model or self.model model = model or self.model
messages = self._inject_language(messages)
t0 = time.time() t0 = time.time()
try: try:
def _call(): def _call():
@@ -108,6 +240,7 @@ class OpenAIService:
if not self.client: if not self.client:
raise RuntimeError("OpenAI not configured — set API key in AI Settings") raise RuntimeError("OpenAI not configured — set API key in AI Settings")
model = model or self.model model = model or self.model
messages = self._inject_language(messages)
t0 = time.time() t0 = time.time()
try: try:
def _call(): def _call():
@@ -133,6 +266,60 @@ class OpenAIService:
"""Use the fast/cheap model for classification, tagging, simple tasks.""" """Use the fast/cheap model for classification, tagging, simple tasks."""
return self.chat(messages, model=self.fast_model, **kwargs) 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): def grade_writing(self, rubric, task_text, response_text):
"""Grade a writing response using GPT with a rubric.""" """Grade a writing response using GPT with a rubric."""
messages = [ messages = [

View File

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

View File

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

View File

@@ -0,0 +1,853 @@
"""REST endpoints for AI course-plan generation and browsing.
All endpoints sit under ``/api/ai/course-plan`` so they don't collide
with the existing ``/api/ai-course/...`` English / IELTS generation
endpoints. Every route is JWT-guarded via the shared ``@jwt_required``
decorator and returns JSON.
"""
import base64
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required,
validate_token,
_json_response,
_error_response,
_get_json_body,
_paginate,
)
from odoo.addons.encoach_ai_course.services.course_plan_pipeline import (
CoursePlanPipeline,
)
from odoo.addons.encoach_ai_course.services.deliverables import (
compute_deliverables,
)
from odoo.addons.encoach_ai_course.services.media_service import MediaService
_logger = logging.getLogger(__name__)
def _request_language():
"""Return the UI language sent by the frontend as a short ISO code."""
try:
raw = (
request.httprequest.headers.get('X-UI-Language')
or request.httprequest.headers.get('Accept-Language')
or 'en'
)
except Exception:
raw = 'en'
return str(raw).split(',')[0].split(';')[0].split('-')[0].strip().lower() or 'en'
def _entity_scope():
"""Return (entity_ids, is_superadmin) for current JWT user."""
user = request.env.user.sudo()
is_super = bool(user.has_group('base.group_system'))
entity_ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
return entity_ids, is_super
def _default_entity_id_from_scope():
entity_ids, is_super = _entity_scope()
if entity_ids:
return entity_ids[0]
if is_super:
return False
raise PermissionError('User is not linked to any entity')
def _ensure_entity_access(entity_id):
if not entity_id:
raise PermissionError('entity_id is required')
entity_ids, is_super = _entity_scope()
if is_super:
return int(entity_id)
if not entity_ids:
raise PermissionError('User is not linked to any entity')
if int(entity_id) not in entity_ids:
raise PermissionError('Entity access denied')
return int(entity_id)
class CoursePlanController(http.Controller):
def _plan_domain(self, extra=None):
domain = list(extra or [])
entity_ids, is_super = _entity_scope()
if is_super:
return domain
if not entity_ids:
return domain + [('id', '=', 0)]
return domain + [('entity_id', 'in', entity_ids)]
def _assert_plan_access(self, plan):
if not plan or not plan.exists():
raise ValueError('Plan not found')
entity_ids, is_super = _entity_scope()
if is_super:
return
if not plan.entity_id or plan.entity_id.id not in entity_ids:
raise PermissionError('Entity access denied')
def _get_plan_scoped(self, plan_id):
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
self._assert_plan_access(plan)
return plan
def _assert_material_access(self, material):
if not material or not material.exists():
raise ValueError('Material not found')
self._assert_plan_access(material.plan_id)
# ------------------------------------------------------------------
# POST /api/ai/course-plan
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def generate_plan(self, **kw):
try:
body = _get_json_body()
if not (body.get('title') or '').strip():
return _error_response('title is required', 400)
if body.get('entity_id'):
entity_id = _ensure_entity_access(int(body['entity_id']))
else:
entity_id = _default_entity_id_from_scope()
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
plan = pipeline.generate_plan(body)
if entity_id:
plan.sudo().write({'entity_id': entity_id})
return _json_response({'data': plan.to_api_dict(include_weeks=True)})
except Exception as exc:
_logger.exception('course-plan.generate failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_plans(self, **kw):
try:
params = request.httprequest.args
domain = self._plan_domain([])
search = (params.get('search') or '').strip()
if search:
domain.append(('name', 'ilike', search))
if params.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(params.get('entity_id')))))
Plan = request.env['encoach.course.plan'].sudo()
offset, limit, page = _paginate({
'page': params.get('page', 0),
'size': params.get('size', 20),
})
total = Plan.search_count(domain)
records = Plan.search(
domain, offset=offset, limit=limit,
order='create_date desc, id desc',
)
return _json_response({
'items': [r.to_api_dict(include_weeks=False) for r in records],
'page': {'page': page, 'size': limit, 'total': total},
})
except Exception as exc:
_logger.exception('course-plan.list failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/<id>
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_plan(self, plan_id, **kw):
try:
plan = self._get_plan_scoped(plan_id)
return _json_response({
'data': plan.to_api_dict(include_weeks=True, include_materials=True),
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.get failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# DELETE /api/ai/course-plan/<id>
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def delete_plan(self, plan_id, **kw):
try:
plan = self._get_plan_scoped(plan_id)
plan.unlink()
return _json_response({'success': True})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.delete failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# POST /api/ai/course-plan/<id>/weeks/<n>/materials
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def generate_week_materials(self, plan_id, week_number, **kw):
try:
self._get_plan_scoped(plan_id)
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
materials = pipeline.generate_week_materials(plan_id, week_number)
return _json_response({
'items': [m.to_api_dict() for m in materials],
'count': len(materials),
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.generate_week_materials failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/<id>/weeks/<n>/materials
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_week_materials(self, plan_id, week_number, **kw):
try:
self._get_plan_scoped(plan_id)
week = request.env['encoach.course.plan.week'].sudo().search([
('plan_id', '=', int(plan_id)),
('week_number', '=', int(week_number)),
], limit=1)
if not week:
return _error_response('Week not found', 404)
return _json_response({
'items': [m.to_api_dict() for m in week.material_ids],
'count': len(week.material_ids),
})
except Exception as exc:
_logger.exception('course-plan.list_week_materials failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ==================================================================
# PHASE A — Reference sources (RAG grounding)
# ==================================================================
@http.route('/api/ai/course-plan/<int:plan_id>/sources',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_sources(self, plan_id, **kw):
try:
plan = self._get_plan_scoped(plan_id)
return _json_response({
'items': [s.to_api_dict() for s in plan.source_ids],
'count': len(plan.source_ids),
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.list_sources failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/ai/course-plan/<int:plan_id>/sources',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def create_source(self, plan_id, **kw):
try:
plan = self._get_plan_scoped(plan_id)
ct = request.httprequest.content_type or ''
files = request.httprequest.files
uploaded = files.get('file')
if 'application/json' in ct:
body = _get_json_body() or {}
else:
body = {**kw}
kind = (body.get('kind') or '').strip() or (
'file' if uploaded else
('url' if (body.get('url') or '').strip() else 'text')
)
name = (body.get('name') or '').strip()
auto_index = body.get('auto_index')
if isinstance(auto_index, str):
auto_index = auto_index.lower() not in ('0', 'false', 'no', 'off')
elif auto_index is None:
auto_index = True
vals = {
'plan_id': plan.id,
'kind': kind,
'auto_index': bool(auto_index),
}
if kind == 'file':
if not uploaded:
return _error_response('No file uploaded', 400)
payload = uploaded.read()
vals['file'] = base64.b64encode(payload)
vals['file_name'] = uploaded.filename or 'source'
vals['mime_type'] = uploaded.mimetype or ''
vals['name'] = name or uploaded.filename or 'source'
elif kind == 'url':
url = (body.get('url') or '').strip()
if not url:
return _error_response('URL is required', 400)
vals['url'] = url
vals['name'] = name or url
else:
text = (body.get('inline_text') or body.get('text') or '').strip()
if not text:
return _error_response('Inline text is required', 400)
vals['inline_text'] = text
vals['name'] = name or 'Inline text'
rec = request.env['encoach.course.plan.source'].sudo().create(vals)
return _json_response({'data': rec.to_api_dict()})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.create_source failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# POST /api/ai/course-plan/<plan_id>/sources/from-resources
# ------------------------------------------------------------------
# Attach one or more existing library resources (``encoach.resource``,
# the items shown under /admin/resources) as RAG sources for a plan.
#
# Body shape: ``{ "resource_ids": [<int>, ...] }``. We dedupe against
# already-linked resources so re-clicking "Attach" is a no-op rather
# than producing duplicate index entries. Each new row auto-indexes
# via the ``encoach.course.plan.source`` create() hook.
@http.route('/api/ai/course-plan/<int:plan_id>/sources/from-resources',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def attach_library_resources(self, plan_id, **kw):
try:
plan = self._get_plan_scoped(plan_id)
body = _get_json_body() or {}
raw_ids = body.get('resource_ids') or body.get('ids') or []
if not isinstance(raw_ids, list):
return _error_response('resource_ids must be a list', 400)
try:
resource_ids = [int(x) for x in raw_ids if x is not None]
except (TypeError, ValueError):
return _error_response('resource_ids must contain integers', 400)
if not resource_ids:
return _error_response('No resource ids provided', 400)
Resource = request.env['encoach.resource'].sudo()
Source = request.env['encoach.course.plan.source'].sudo()
already_linked = set(
plan.source_ids.filtered('resource_id').mapped('resource_id.id')
)
attached, skipped, missing = [], [], []
for rid in resource_ids:
if rid in already_linked:
skipped.append(rid)
continue
res = Resource.browse(rid)
if not res.exists():
missing.append(rid)
continue
rec = Source.create({
'plan_id': plan.id,
'kind': 'resource',
'resource_id': res.id,
'name': res.name or f'Resource #{res.id}',
'file_name': res.name or '',
'mime_type': '',
})
attached.append(rec.to_api_dict())
return _json_response({
'attached': attached,
'skipped_existing': skipped,
'missing': missing,
'count': len(attached),
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.attach_library_resources failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>/index',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def reindex_source(self, plan_id, source_id, **kw):
try:
self._get_plan_scoped(plan_id)
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
if not rec.exists() or rec.plan_id.id != int(plan_id):
return _error_response('Source not found', 404)
rec.action_index()
return _json_response({'data': rec.to_api_dict()})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.reindex_source failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/ai/course-plan/<int:plan_id>/sources/<int:source_id>',
type='http', auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def delete_source(self, plan_id, source_id, **kw):
try:
self._get_plan_scoped(plan_id)
rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id))
if not rec.exists() or rec.plan_id.id != int(plan_id):
return _error_response('Source not found', 404)
rec.unlink()
return _json_response({'success': True})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.delete_source failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ==================================================================
# PHASE B — Deliverables preview / progress
# ==================================================================
@http.route('/api/ai/course-plan/<int:plan_id>/deliverables',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_deliverables(self, plan_id, **kw):
try:
plan = self._get_plan_scoped(plan_id)
return _json_response(compute_deliverables(plan))
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.deliverables failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ==================================================================
# PHASE C — Multimedia generation per material
# ==================================================================
def _resolve_material(self, material_id):
rec = request.env['encoach.course.plan.material'].sudo().browse(int(material_id))
if not rec.exists():
return None
self._assert_material_access(rec)
return rec
@http.route('/api/ai/course-plan/material/<int:material_id>',
type='http', auth='none', methods=['PATCH'], csrf=False)
@jwt_required
def update_material(self, material_id, **kw):
"""Edit generated material metadata/content without regenerating."""
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
body = _get_json_body() or {}
vals = {}
if 'title' in body:
new_title = (body.get('title') or '').strip()
if not new_title:
return _error_response('title cannot be empty', 400)
vals['title'] = new_title
if 'summary' in body:
vals['summary'] = (body.get('summary') or '').strip()
if 'is_static' in body:
vals['is_static'] = bool(body.get('is_static'))
if 'share_date' in body:
vals['share_date'] = body.get('share_date') or False
if 'body' in body:
vals['body_json'] = json.dumps(body.get('body') or {}, ensure_ascii=False)
if 'body_text' in body:
body_text = (body.get('body_text') or '').strip()
vals['body_text'] = body_text
if 'body' not in body and body_text:
# Keep non-technical editing simple: if only plain text is
# provided, mirror it into a minimal JSON structure.
vals['body_json'] = json.dumps({'text': body_text}, ensure_ascii=False)
if vals:
material.sudo().write(vals)
return _json_response({'data': material.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.update_material failed')
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/material/<int:material_id>/media/audio',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def gen_audio(self, material_id, **kw):
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
body = _get_json_body() or {}
svc = MediaService(request.env)
media = svc.synthesize_audio(
material,
voice=body.get('voice'),
language=body.get('language') or 'en-GB',
gender=body.get('gender') or 'female',
provider=body.get('provider') or 'auto',
)
return _json_response({'data': media.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.gen_audio failed')
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/material/<int:material_id>/media/image',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def gen_image(self, material_id, **kw):
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
body = _get_json_body() or {}
svc = MediaService(request.env)
media = svc.generate_image(
material,
custom_prompt=body.get('prompt'),
size=body.get('size') or '1024x1024',
style=body.get('style') or 'natural',
quality=body.get('quality') or 'standard',
)
return _json_response({'data': media.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.gen_image failed')
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/material/<int:material_id>/media/video',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def gen_video(self, material_id, **kw):
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
svc = MediaService(request.env)
media = svc.compose_video(material)
return _json_response({'data': media.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.gen_video failed')
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/material/<int:material_id>/media',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_material_media(self, material_id, **kw):
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
return _json_response({
'items': [m.to_api_dict() for m in material.media_ids],
'count': len(material.media_ids),
})
except Exception as exc:
_logger.exception('course-plan.list_material_media failed')
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/media/<id>/raw
# ------------------------------------------------------------------
# Streams the binary backing a media row. Used as the ``src`` for
# ``<img>`` / ``<audio>`` / ``<video>`` tags AND as the target of
# download links — the only difference is the ``?download=1`` flag.
#
# Accepts the JWT via the standard ``Authorization: Bearer …`` header
# OR via ``?token=<jwt>`` / ``?access_token=<jwt>`` so plain ``<img>``
# tags can render the asset without us blob-converting every fetch.
@http.route('/api/ai/course-plan/media/<int:media_id>/raw',
type='http', auth='none', methods=['GET'], csrf=False)
def stream_media(self, media_id, **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.course.plan.media'].sudo().browse(int(media_id))
if not rec.exists() or not rec.attachment_id:
return _error_response('Media not found', 404)
self._assert_plan_access(rec.plan_id)
attachment = rec.attachment_id
payload = attachment.raw or b''
if not payload and attachment.datas:
# Older rows store the binary base64-encoded under ``datas``.
payload = base64.b64decode(attachment.datas)
if not payload:
return _error_response('Media payload missing', 410)
mimetype = attachment.mimetype or rec.mime_type or 'application/octet-stream'
filename = attachment.name or f'media-{rec.id}'
disposition = (
f'attachment; filename="{filename}"'
if request.httprequest.args.get('download')
else f'inline; filename="{filename}"'
)
headers = [
('Content-Type', mimetype),
('Content-Length', str(len(payload))),
('Content-Disposition', disposition),
# Aggressive caching is safe — the URL is keyed by the row id
# and the binary is immutable once generated. Setting a 1h
# max-age avoids re-streaming the same WAV/PNG every time the
# admin re-opens the media drawer.
('Cache-Control', 'private, max-age=3600'),
]
return request.make_response(payload, headers=headers)
except Exception as exc:
_logger.exception('course-plan.stream_media failed')
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/media/<int:media_id>',
type='http', auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def delete_media(self, media_id, **kw):
try:
rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
if not rec.exists():
return _error_response('Media not found', 404)
self._assert_plan_access(rec.plan_id)
if rec.attachment_id:
rec.attachment_id.unlink()
rec.unlink()
return _json_response({'success': True})
except Exception as exc:
_logger.exception('course-plan.delete_media failed')
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/media',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def gen_week_media(self, plan_id, week_number, **kw):
"""Generate every applicable modality for every material in a week.
Body shape: ``{ kinds: ['audio', 'image', 'video'] }``.
Default is ``['audio', 'image']`` — video is opt-in because it
depends on ffmpeg + the audio + image steps and is slower.
"""
try:
plan = self._get_plan_scoped(plan_id)
week = plan.week_ids.filtered(
lambda w: w.week_number == int(week_number),
)
if not week:
return _error_response('Week not found', 404)
week = week[0]
body = _get_json_body() or {}
kinds = body.get('kinds') or ['audio', 'image']
kinds = [str(k).lower() for k in kinds]
svc = MediaService(request.env)
results = []
for material in week.material_ids:
if 'audio' in kinds and material.material_type in (
'listening_script', 'speaking_prompt',
):
results.append(svc.synthesize_audio(material).to_api_dict())
if 'image' in kinds and material.material_type in (
'reading_text', 'listening_script', 'vocabulary_list',
):
try:
results.append(svc.generate_image(material).to_api_dict())
except Exception as exc:
results.append({'error': str(exc), 'material_id': material.id})
if 'video' in kinds and material.material_type == 'listening_script':
results.append(svc.compose_video(material).to_api_dict())
return _json_response({'items': results, 'count': len(results)})
except Exception as exc:
_logger.exception('course-plan.gen_week_media failed')
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
# ==================================================================
# PHASE D — Plan assignments
# ==================================================================
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_assignments(self, plan_id, **kw):
try:
plan = self._get_plan_scoped(plan_id)
return _json_response({
'items': [a.to_api_dict() for a in plan.assignment_ids],
'count': len(plan.assignment_ids),
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.list_assignments failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def create_assignment(self, plan_id, **kw):
try:
plan = self._get_plan_scoped(plan_id)
body = _get_json_body() or {}
mode = (body.get('mode') or 'batch').strip()
vals = {
'plan_id': plan.id,
'mode': mode,
'message': (body.get('message') or '').strip(),
'due_date': body.get('due_date') or False,
'state': 'active',
}
if mode == 'batch':
if not body.get('batch_id'):
return _error_response('batch_id is required', 400)
batch_id = int(body['batch_id'])
batch = request.env['op.batch'].sudo().browse(batch_id)
if not batch.exists():
return _error_response('Batch not found', 404)
if plan.entity_id and batch.entity_id and plan.entity_id.id != batch.entity_id.id:
return _error_response('Batch entity does not match plan entity', 400)
vals['batch_id'] = batch_id
elif mode == 'students':
ids = body.get('student_user_ids') or []
if not isinstance(ids, list) or not ids:
return _error_response('student_user_ids is required', 400)
vals['student_user_ids'] = [(6, 0, [int(i) for i in ids])]
elif mode == 'entities':
ids = body.get('entity_ids') or []
if not isinstance(ids, list) or not ids:
return _error_response('entity_ids is required', 400)
checked = [_ensure_entity_access(int(i)) for i in ids]
vals['entity_ids'] = [(6, 0, checked)]
else:
return _error_response('Invalid mode', 400)
rec = request.env['encoach.course.plan.assignment'].sudo().create(vals)
return _json_response({'data': rec.to_api_dict()})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.create_assignment failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
@http.route('/api/ai/course-plan/<int:plan_id>/assignments/<int:assignment_id>',
type='http', auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def delete_assignment(self, plan_id, assignment_id, **kw):
try:
self._get_plan_scoped(plan_id)
rec = request.env['encoach.course.plan.assignment'].sudo().browse(
int(assignment_id),
)
if not rec.exists() or rec.plan_id.id != int(plan_id):
return _error_response('Assignment not found', 404)
rec.unlink()
return _json_response({'success': True})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.delete_assignment failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ==================================================================
# PHASE E — Student-side endpoints
# ==================================================================
@http.route('/api/student/course-plans',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def student_list_plans(self, **kw):
"""Return the course plans assigned to the authenticated user.
Visibility rules (OR):
1. There exists an assignment with ``mode='students'`` listing
the current user.
2. There exists an assignment with ``mode='batch'`` whose
batch contains an ``op.student.course`` whose student's
``user_id`` matches the current user.
"""
try:
user = request.env.user
Assignment = request.env['encoach.course.plan.assignment'].sudo()
assignments = Assignment.search([('state', '=', 'active')])
visible = []
for a in assignments:
if a.mode == 'students' and user.id in a.student_user_ids.ids:
visible.append(a)
continue
if a.mode == 'batch' and user.id in a.expand_user_ids():
visible.append(a)
continue
if a.mode == 'entities' and user.id in a.expand_user_ids():
visible.append(a)
seen = set()
out = []
for a in visible:
if a.plan_id.id in seen:
continue
seen.add(a.plan_id.id)
plan_data = a.plan_id.to_api_dict(
include_weeks=False, include_materials=False,
)
plan_data['assignment'] = a.to_api_dict()
out.append(plan_data)
return _json_response({'items': out, 'count': len(out)})
except Exception as exc:
_logger.exception('course-plan.student_list_plans failed')
return _error_response(str(exc), 500)
@http.route('/api/student/course-plans/<int:plan_id>',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def student_get_plan(self, plan_id, **kw):
try:
user = request.env.user
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
allowed = False
for a in plan.assignment_ids.filtered(lambda x: x.state == 'active'):
if a.mode == 'students' and user.id in a.student_user_ids.ids:
allowed = True
break
if a.mode == 'batch' and user.id in a.expand_user_ids():
allowed = True
break
if a.mode == 'entities' and user.id in a.expand_user_ids():
allowed = True
break
if not allowed:
return _error_response('Plan not assigned to you', 403)
return _json_response({
'data': plan.to_api_dict(
include_weeks=True,
include_materials=True,
include_media=True,
),
})
except Exception as exc:
_logger.exception('course-plan.student_get_plan failed')
return _error_response(str(exc), 500)

View File

@@ -1,2 +1,6 @@
from . import ai_generation_log from . import ai_generation_log
from . import ai_ielts_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

View File

@@ -0,0 +1,336 @@
"""Course Plan models.
A *course plan* is the AI-generated, structured outline of a full course
(similar to the UTAS GE1 outline: objectives, per-skill learning outcomes,
grammar scope, assessment split, and a week-by-week delivery plan).
Distinct from the existing exam / exercise generation pipeline:
* ``encoach.ai.generation.log`` generates **exam questions**.
* ``encoach.course.plan`` generates **teaching content** — weeks,
reading texts, listening scripts, speaking prompts, grammar lessons, etc.
Large, loosely-structured JSON (objectives, learning outcomes grouped by
skill, grammar topics, assessment breakdown, learning resources) lives on
the header as ``Text`` columns to keep the schema boring. Per-week rows
and per-week materials each get their own table because they are
generated incrementally and users want to drill into them.
"""
import json
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
SKILL_SELECTION = [
('reading', 'Reading'),
('writing', 'Writing'),
('listening', 'Listening'),
('speaking', 'Speaking'),
('grammar', 'Grammar'),
('vocabulary', 'Vocabulary'),
('integrated', 'Integrated'),
]
MATERIAL_TYPE_SELECTION = [
('reading_text', 'Reading Text'),
('listening_script', 'Listening Script'),
('speaking_prompt', 'Speaking Prompt'),
('writing_prompt', 'Writing Prompt'),
('grammar_lesson', 'Grammar Lesson'),
('vocabulary_list', 'Vocabulary List'),
('practice', 'Practice Exercises'),
('other', 'Other'),
]
class CoursePlan(models.Model):
_name = 'encoach.course.plan'
_description = 'AI-generated Course Plan'
_order = 'create_date desc, id desc'
name = fields.Char(required=True)
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')
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 '',
'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',
)
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.',
)
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()
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',
'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 '',
}
if include_media:
out['media'] = [m.to_api_dict() for m in self.media_ids]
return out

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,9 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink 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_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_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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_ai_generation_log_user encoach.ai.generation.log.user model_encoach_ai_generation_log base.group_user 1 1 1 1
3 access_encoach_ai_ielts_generation_log_user encoach.ai.ielts.generation.log.user model_encoach_ai_ielts_generation_log base.group_user 1 1 1 1
4 access_encoach_course_plan_user encoach.course.plan.user model_encoach_course_plan base.group_user 1 1 1 1
5 access_encoach_course_plan_week_user encoach.course.plan.week.user model_encoach_course_plan_week base.group_user 1 1 1 1
6 access_encoach_course_plan_material_user encoach.course.plan.material.user model_encoach_course_plan_material base.group_user 1 1 1 1
7 access_encoach_course_plan_source_user encoach.course.plan.source.user model_encoach_course_plan_source base.group_user 1 1 1 1
8 access_encoach_course_plan_media_user encoach.course.plan.media.user model_encoach_course_plan_media base.group_user 1 1 1 1
9 access_encoach_course_plan_assignment_user encoach.course.plan.assignment.user model_encoach_course_plan_assignment base.group_user 1 1 1 1

View File

@@ -1,2 +1,6 @@
from .english_pipeline import EnglishPipeline from .english_pipeline import EnglishPipeline
from .ielts_pipeline import IeltsPipeline from .ielts_pipeline import IeltsPipeline
from .course_plan_pipeline import CoursePlanPipeline
from .source_indexer import SourceIndexer
from .media_service import MediaService
from .deliverables import compute_deliverables

View File

@@ -0,0 +1,774 @@
"""Course plan generation pipeline.
Two public entry points:
* :py:meth:`generate_plan` — given a short brief (course title, CEFR level,
duration, skill coverage, grammar focus, resources), produce a full
curriculum outline and persist it as an
:py:class:`encoach.course.plan` record, with one
:py:class:`encoach.course.plan.week` row per planned week.
* :py:meth:`generate_week_materials` — given an existing plan and a
week number, produce the actual teaching content for that week
(reading text, listening script, speaking prompts, grammar mini-lesson
+ practice, writing prompt, vocabulary list) and persist each as an
:py:class:`encoach.course.plan.material` row.
We deliberately ask the LLM to return strict JSON and then normalise it
server-side — the frontend gets a stable shape no matter how loose the
model's output is. Any parse failure is swallowed and reported back
through the standard error channel so the caller can retry without the
server crashing.
"""
import json
import logging
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
except ImportError:
OpenAIService = None
# Markers that indicate the OpenAI account is unusable until the operator
# fixes billing / keys / quota — i.e. retrying right now will never help.
# We mirror the OpenAI service's own non-retryable list so a 429 caused by
# `insufficient_quota` triggers the free fallback instead of bubbling up
# as a 500 from the wizard's "Finish" button.
_AI_PERMANENT_FAILURE_MARKERS = (
"insufficient_quota",
"invalid_api_key",
"incorrect_api_key",
"account_deactivated",
"billing_hard_limit_reached",
"openai not configured",
"ai is disabled",
)
# AgentRuntime is the LangGraph-backed engine. When the feature flag
# ``encoach_ai.use_langgraph_runtime`` is true (default) and an agent with
# the matching key is configured, the pipeline routes through the agent
# instead of calling OpenAIService directly. This keeps the existing
# fall-back path so the pipeline still works if the agent layer is broken
# or being upgraded.
try:
from odoo.addons.encoach_ai.services.agent_runtime import AgentRuntime
except ImportError: # pragma: no cover - optional dep
AgentRuntime = None
_logger = logging.getLogger(__name__)
def _is_permanent_ai_failure(message):
"""Return True iff the AI error indicates a non-transient condition.
These are operator-fixable problems (out of credit, wrong key, AI
feature switched off) where retrying would just hang the wizard. In
those cases the pipeline degrades to a deterministic skeleton plan
so the user still gets a usable record.
"""
if not message:
return False
low = str(message).lower()
return any(m in low for m in _AI_PERMANENT_FAILURE_MARKERS)
# JSON schema we coax the LLM into following. Keeping this as a prompt
# string (rather than an OpenAI function call) makes it portable if the
# underlying `chat_json` implementation ever changes providers.
_PLAN_JSON_HINT = """
Return JSON with exactly this shape:
{
"description": "<2-4 sentence course description incl. CEFR>",
"objectives": ["<overall course objective>", ...],
"outcomes": {
"reading": [{"code": "RLO1", "description": "..."}, ...],
"writing": [{"code": "WLO1", "description": "..."}, ...],
"listening": [{"code": "LLO1", "description": "..."}, ...],
"speaking": [{"code": "SLO1", "description": "..."}, ...],
"vocabulary": [{"code": "VLO1", "description": "..."}, ...],
"grammar": [{"code": "GLO1", "description": "..."}, ...]
},
"grammar": [
{"code": "GT1", "label": "Present tense",
"sub_items": ["present simple", "present continuous"]},
...
],
"assessment": {
"continuous_assessment": {"total_weight": 50, "components":
[{"name":"MTE","weight":30}, {"name":"Oral Presentation","weight":10}, ...]},
"final_exam": {"total_weight": 50}
},
"resources": [
{"type": "textbook", "citation": "..."},
{"type": "stm", "citation": "..."}
],
"weeks": [
{
"week_number": 1,
"date_label": "7-11 Sep. 2025",
"unit": "One",
"focus": "Personal introductions, simple present",
"items": [
{"skill": "reading", "outcome_codes": ["RLO1","RLO2"], "remarks": "..."},
{"skill": "writing", "outcome_codes": ["WLO1","WLO2"], "remarks": "..."},
{"skill": "listening", "outcome_codes": ["LLO1"], "remarks": ""},
{"skill": "speaking", "outcome_codes": ["SLO1","SLO2"], "remarks": ""},
{"skill": "grammar", "outcome_codes": ["GLO1"], "remarks": ""}
]
},
...
]
}
Use the exact outcome codes across `outcomes` and `weeks[*].items[*].outcome_codes`.
"""
_WEEK_JSON_HINT = """
Return JSON with exactly this shape:
{
"materials": [
{
"skill": "reading",
"material_type": "reading_text",
"title": "...",
"summary": "1-2 sentence teacher note",
"body": {
"text": "<reading passage ~350-450 words>",
"questions": [
{"q": "...", "type": "multiple_choice",
"options": ["A","B","C","D"], "answer": "A"}
]
}
},
{
"skill": "listening",
"material_type": "listening_script",
"title": "...",
"summary": "...",
"body": {
"script": "<3-4 minute dialogue or monologue>",
"comprehension_questions": [
{"q": "...", "answer": "..."}
]
}
},
{
"skill": "speaking",
"material_type": "speaking_prompt",
"title": "...",
"summary": "...",
"body": {
"prompts": ["...", "..."],
"useful_language": ["..."]
}
},
{
"skill": "writing",
"material_type": "writing_prompt",
"title": "...",
"summary": "...",
"body": {
"prompt": "...",
"word_count": 150,
"model_paragraph": "..."
}
},
{
"skill": "grammar",
"material_type": "grammar_lesson",
"title": "...",
"summary": "...",
"body": {
"explanation": "...",
"examples": ["...","..."],
"practice": [
{"q":"...", "answer":"..."}
]
}
},
{
"skill": "vocabulary",
"material_type": "vocabulary_list",
"title": "...",
"summary": "...",
"body": {
"words": [
{"term":"...", "pos":"n.", "definition":"...", "example":"..."}
]
}
}
]
}
Only include skills present in the week's items list.
"""
class CoursePlanPipeline:
"""Wrap the LLM call, normalise the JSON, persist the result."""
def __init__(self, env, *, language="en"):
self.env = env
self.language = language
if OpenAIService is None:
raise RuntimeError(
"OpenAIService is not available — encoach_ai is not installed."
)
self.ai = OpenAIService(env, language=language)
# Decide once per instance whether to route through the LangGraph
# AgentRuntime or fall back to the direct chat_json path.
self._use_agent = self._resolve_agent_flag(env)
@staticmethod
def _resolve_agent_flag(env):
if AgentRuntime is None:
return False
try:
raw = env["ir.config_parameter"].sudo().get_param(
"encoach_ai.use_langgraph_runtime", "True",
)
except Exception:
return False
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def _agent(self, key):
"""Lazily build an AgentRuntime for ``key`` if the flag allows it."""
if not self._use_agent or AgentRuntime is None:
return None
try:
return AgentRuntime.from_key(self.env, key, language=self.language)
except Exception:
_logger.exception("AgentRuntime.from_key(%s) failed", key)
return None
# ------------------------------------------------------------------
# Plan-level generation
# ------------------------------------------------------------------
def generate_plan(self, brief):
"""Generate the full course plan header + week rows from a brief.
:param brief: ``dict`` with optional keys:
title, cefr_level, total_weeks, contact_hours_per_week,
skills_division, grammar_focus (list), resources (list),
learner_profile (string), notes (string), course_id (int),
language (string ISO-639-1).
:returns: ``encoach.course.plan`` record.
"""
title = (brief.get('title') or '').strip() or 'Untitled course'
cefr = (brief.get('cefr_level') or 'a2').lower()
total_weeks = int(brief.get('total_weeks') or 12)
contact_hours = int(brief.get('contact_hours_per_week') or 18)
skills_division = (brief.get('skills_division') or '').strip()
grammar_focus = brief.get('grammar_focus') or []
resources = brief.get('resources') or []
learner_profile = (brief.get('learner_profile') or '').strip()
notes = (brief.get('notes') or '').strip()
system_msg = (
"You are an expert English language curriculum designer. "
"You produce structured course outlines suitable for a "
"general foundation programme. You MUST return valid JSON "
"that matches the schema in the user prompt exactly. Never "
"wrap the JSON in prose."
)
user_msg = (
f"Design a {total_weeks}-week course titled \"{title}\" at "
f"CEFR {cefr.upper()} with approximately {contact_hours} "
f"contact hours per week.\n"
f"Skills division: {skills_division or 'auto'}.\n"
f"Grammar focus: {', '.join(grammar_focus) or 'auto'}.\n"
f"Resources to reference: "
f"{'; '.join(resources) if resources else 'none'}.\n"
f"Learner profile: {learner_profile or 'mixed L1 adult learners'}.\n"
f"Additional notes: {notes or 'none'}.\n\n"
+ _PLAN_JSON_HINT
)
# Prefer the LangGraph agent if one is configured; fall back to the
# direct OpenAI call so the feature still works if the agent table
# is empty or the runtime fails to compile.
content = self._invoke_agent_or_chat(
agent_key="course_planner",
system_msg=system_msg,
user_msg=user_msg,
variables={
"title": title,
"cefr_level": cefr,
"total_weeks": total_weeks,
},
temperature=0.4,
max_tokens=4096,
action="course_plan.generate",
)
# If the LLM is unavailable (quota exhausted, missing key,
# disabled, network error) we degrade to a deterministic stub
# plan instead of raising. The wizard's "Finish" button needs to
# always succeed in producing a record the user can open and
# iterate on; a hard failure here leaves the frontend stuck on a
# 5+ minute spinner while the OpenAI client retries.
used_fallback = False
ai_error = None
if content is None or 'error' in content:
ai_error = (content or {}).get('error', 'AI generation failed.')
if _is_permanent_ai_failure(ai_error):
_logger.warning(
"Course plan AI unavailable (%s); using free fallback",
ai_error,
)
content = self._build_fallback_plan_content(
title=title,
cefr=cefr,
total_weeks=total_weeks,
contact_hours=contact_hours,
skills_division=skills_division,
grammar_focus=grammar_focus,
resources=resources,
learner_profile=learner_profile,
)
used_fallback = True
else:
# Genuine transient failure — surface it to the caller so
# they can retry. The frontend toasts the error message.
raise RuntimeError(ai_error)
description = (content.get('description') or '').strip()
if used_fallback:
description = (
description
+ "\n\n[Auto-generated skeleton — OpenAI unavailable. "
"Update the AI provider settings or restore billing, "
"then click Regenerate.]"
).strip()
plan_vals = {
'name': title,
'cefr_level': cefr if cefr in {
'pre_a1', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2'
} else 'a2',
'total_weeks': total_weeks,
'contact_hours_per_week': contact_hours,
'skills_division': skills_division,
'description': description,
'objectives_json': json.dumps(content.get('objectives') or [], ensure_ascii=False),
'outcomes_json': json.dumps(content.get('outcomes') or {}, ensure_ascii=False),
'grammar_json': json.dumps(content.get('grammar') or [], ensure_ascii=False),
'assessment_json': json.dumps(content.get('assessment') or {}, ensure_ascii=False),
'resources_json': json.dumps(content.get('resources') or [], ensure_ascii=False),
'brief_json': json.dumps(brief, ensure_ascii=False),
'status': 'generated' if not used_fallback else 'draft',
}
if brief.get('course_id'):
try:
plan_vals['course_id'] = int(brief['course_id'])
except (TypeError, ValueError):
pass
plan = self.env['encoach.course.plan'].sudo().create(plan_vals)
# Create week rows.
Week = self.env['encoach.course.plan.week'].sudo()
for w in content.get('weeks') or []:
try:
Week.create({
'plan_id': plan.id,
'week_number': int(w.get('week_number') or 0),
'date_label': (w.get('date_label') or '').strip(),
'unit': (w.get('unit') or '').strip(),
'focus': (w.get('focus') or '').strip(),
'items_json': json.dumps(w.get('items') or [], ensure_ascii=False),
})
except Exception as exc: # pragma: no cover - defensive
_logger.warning("Skipping bad week row: %s", exc)
return plan
# ------------------------------------------------------------------
# Week-level material generation
# ------------------------------------------------------------------
def generate_week_materials(self, plan_id, week_number):
"""Generate teaching materials for one week and persist them.
Any existing materials for the same plan_id + week_number are
replaced — callers that want to keep old versions should copy
them before re-running.
"""
plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
raise ValueError('Plan not found')
week = plan.week_ids.filtered(lambda w: w.week_number == int(week_number))
if not week:
raise ValueError(f'Week {week_number} not found on plan {plan_id}')
week = week[0]
outcomes = plan._loads(plan.outcomes_json, {})
items = week._loads(week.items_json, [])
system_msg = (
"You are an expert English language teacher creating ready-"
"to-use classroom materials. Your output MUST be valid JSON "
"matching the schema in the user prompt. Keep reading texts "
"close to the target word count for the CEFR level. Keep "
"listening scripts natural and conversational. All tasks "
"must target the outcome codes supplied."
)
user_msg = (
f"Course: {plan.name}\n"
f"CEFR: {(plan.cefr_level or '').upper()}\n"
f"Week {week.week_number}{week.date_label or ''}\n"
f"Unit: {week.unit or ''}\n"
f"Focus: {week.focus or ''}\n\n"
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
f"Full outcome catalogue (for looking up codes):\n"
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
+ _WEEK_JSON_HINT
)
content = self._invoke_agent_or_chat(
agent_key="course_week_materials",
system_msg=system_msg,
user_msg=user_msg,
variables={
"course": plan.name,
"cefr_level": (plan.cefr_level or "").lower(),
"week_number": week.week_number,
},
temperature=0.6,
max_tokens=6000,
action="course_plan.generate_week",
)
used_week_fallback = False
if content is None or 'error' in content:
ai_error = (content or {}).get('error', 'AI generation failed.')
if _is_permanent_ai_failure(ai_error):
_logger.warning(
"Week materials AI unavailable (%s); using free fallback",
ai_error,
)
content = self._build_fallback_week_content(
plan_name=plan.name,
cefr=(plan.cefr_level or "").lower(),
week_number=week.week_number,
items=items,
)
used_week_fallback = True
else:
raise RuntimeError(ai_error)
# Wipe any previous materials for this week so re-generating is
# idempotent and we never accumulate duplicates.
existing = self.env['encoach.course.plan.material'].sudo().search([
('plan_id', '=', plan.id), ('week_id', '=', week.id),
])
if existing:
# Keep instructor-curated static rows; only replace generated ones.
existing.filtered(lambda m: not m.is_static).unlink()
Material = self.env['encoach.course.plan.material'].sudo()
created = []
skeleton_note = (
"[Auto-generated skeleton — OpenAI unavailable. "
"Update the AI provider settings or restore billing, "
"then regenerate this week's materials.]"
)
for m in content.get('materials') or []:
try:
summary = (m.get('summary') or '').strip()
if used_week_fallback:
summary = (summary + "\n\n" + skeleton_note).strip()
rec = Material.create({
'plan_id': plan.id,
'week_id': week.id,
'skill': (m.get('skill') or 'integrated').strip().lower(),
'material_type': (m.get('material_type') or 'other').strip(),
'title': (m.get('title') or '').strip() or 'Untitled',
'summary': summary,
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
'body_text': self._flatten_body(m.get('body') or {}),
})
created.append(rec)
except Exception as exc: # pragma: no cover - defensive
_logger.warning("Skipping bad material row: %s", exc)
return created
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _chat_json(self, messages, **kwargs):
"""Best-effort wrapper around ``ai.chat_json``.
The underlying service may raise (network, invalid key, etc.),
or return a dict with an ``error`` field when content moderation
rejects the request. We normalise both to a dict so callers can
just check ``'error' in result``.
"""
try:
return self.ai.chat_json(messages, **kwargs)
except Exception as exc:
_logger.exception("Course plan AI call failed")
return {'error': str(exc)}
def _invoke_agent_or_chat(self, *, agent_key, system_msg, user_msg,
variables, temperature, max_tokens, action):
"""Route through AgentRuntime when available; fall back to chat_json.
Both branches return the same shape — a dict the caller can
``json.loads``-style consume — so the rest of the pipeline doesn't
change. We pass ``user_msg`` as the payload because the agent's own
system prompt is normally the one used; only when the agent is
missing do we pass the inline ``system_msg``.
"""
runtime = self._agent(agent_key)
if runtime is not None:
# The pipeline owns the JSON schema for backward-compat, so we
# forward the schema-bearing user message into the agent. The
# agent's stored system prompt covers the role/rules; we add
# the schema as ``extra_system`` so it's heeded but auditable.
final = runtime.invoke(
variables=variables,
payload=user_msg,
extra_system=system_msg,
)
agent_error = final.get("error")
if agent_error:
# Permanent failures (no quota, bad key, AI off) will fail
# the same way through the legacy chat_json path, so don't
# double the wait — surface the error and let the caller
# decide (generate_plan triggers the free fallback).
if _is_permanent_ai_failure(agent_error):
_logger.warning(
"agent %s permanent failure (%s); skipping legacy fallback",
agent_key, agent_error,
)
return {"error": agent_error}
_logger.warning(
"agent %s failed (%s); falling back to direct chat_json",
agent_key, agent_error,
)
else:
output = final.get("output")
if isinstance(output, dict):
return output
# Text output — try parsing once, otherwise fall back.
try:
return json.loads(final.get("output_raw") or "{}")
except Exception:
pass
# Fallback path: plain OpenAI call (legacy).
return self._chat_json(
[
{"role": "system", "content": system_msg},
{"role": "user", "content": user_msg},
],
temperature=temperature,
max_tokens=max_tokens,
action=action,
)
# ------------------------------------------------------------------
# Free fallback — used when OpenAI returns a permanent failure (no
# quota, bad key, AI disabled). We synthesize a structurally-valid
# plan so the wizard still completes and the user gets a record they
# can edit or regenerate. The shape mirrors the JSON schema in
# _PLAN_JSON_HINT exactly so downstream serializers don't notice.
# ------------------------------------------------------------------
@staticmethod
def _build_fallback_plan_content(
*, title, cefr, total_weeks, contact_hours, skills_division,
grammar_focus, resources, learner_profile,
):
cefr_upper = (cefr or "a2").upper()
skills_text = (skills_division or "").strip() or (
"Reading & Writing balanced with Listening & Speaking"
)
outcomes = {
"reading": [{"code": "RLO1", "description": f"Read level-appropriate ({cefr_upper}) texts and identify main ideas."}],
"writing": [{"code": "WLO1", "description": "Plan and write short structured paragraphs on familiar topics."}],
"listening": [{"code": "LLO1", "description": "Follow short dialogues and monologues at normal speed."}],
"speaking": [{"code": "SLO1", "description": "Hold short conversations on personal and study-related topics."}],
"vocabulary": [{"code": "VLO1", "description": "Use a level-appropriate active vocabulary across the four skills."}],
"grammar": [{"code": "GLO1", "description": "Use the targeted grammar structures accurately in context."}],
}
grammar_blocks = [
{"code": f"GT{i+1}", "label": label.strip() or f"Topic {i+1}",
"sub_items": []}
for i, label in enumerate((grammar_focus or [])[:6])
] or [
{"code": "GT1", "label": "Present tenses", "sub_items": ["present simple", "present continuous"]},
{"code": "GT2", "label": "Past tenses", "sub_items": ["past simple", "past continuous"]},
]
weeks = []
for w in range(1, max(1, int(total_weeks or 12)) + 1):
weeks.append({
"week_number": w,
"date_label": f"Week {w}",
"unit": f"Unit {((w - 1) // 2) + 1}",
"focus": f"Skeleton focus for week {w} — replace via Regenerate.",
"items": [
{"skill": "reading", "outcome_codes": ["RLO1"], "remarks": ""},
{"skill": "writing", "outcome_codes": ["WLO1"], "remarks": ""},
{"skill": "listening", "outcome_codes": ["LLO1"], "remarks": ""},
{"skill": "speaking", "outcome_codes": ["SLO1"], "remarks": ""},
{"skill": "grammar", "outcome_codes": ["GLO1"], "remarks": ""},
],
})
return {
"description": (
f"{cefr_upper} general course over {total_weeks} weeks, "
f"approximately {contact_hours} contact hours per week. "
f"Coverage: {skills_text}. "
f"Profile: {learner_profile or 'mixed adult learners'}."
),
"objectives": [
f"Develop integrated {cefr_upper}-level skills across reading, writing, listening and speaking.",
"Build active vocabulary and accurate use of target grammar.",
"Use language confidently in personal, social and study contexts.",
],
"outcomes": outcomes,
"grammar": grammar_blocks,
"assessment": {
"continuous_assessment": {
"total_weight": 50,
"components": [
{"name": "MTE", "weight": 30},
{"name": "Continuous tasks", "weight": 20},
],
},
"final_exam": {"total_weight": 50},
},
"resources": [
{"type": "textbook", "citation": (resources[0] if resources else "TBD — replace via Regenerate")},
],
"weeks": weeks,
}
# ------------------------------------------------------------------
# Free fallback for per-week materials. Mirrors the schema in
# _WEEK_JSON_HINT — placeholder content per skill so the teacher
# has a starter row they can edit or regenerate later.
# ------------------------------------------------------------------
@staticmethod
def _build_fallback_week_content(*, plan_name, cefr, week_number, items):
cefr_upper = (cefr or "a2").upper()
# Only produce materials for the skills that the week's plan
# actually contains, so the teacher's outline is preserved.
wanted_skills = []
seen = set()
for it in items or []:
s = (it.get("skill") or "").strip().lower()
if s and s not in seen:
wanted_skills.append(s)
seen.add(s)
if not wanted_skills:
wanted_skills = ["reading", "writing", "listening", "speaking", "grammar", "vocabulary"]
templates = {
"reading": {
"material_type": "reading_text",
"title": f"Week {week_number} reading — placeholder",
"summary": f"Skeleton reading task at {cefr_upper}. Replace via Regenerate.",
"body": {
"text": (
"Placeholder reading passage. Replace with a "
f"{cefr_upper}-level text of around 400 words on "
"a familiar personal or study-related topic."
),
"questions": [
{"q": "What is the main idea?", "type": "short_answer", "answer": "TBD"},
],
},
},
"writing": {
"material_type": "writing_prompt",
"title": f"Week {week_number} writing — placeholder",
"summary": "Skeleton writing task. Replace via Regenerate.",
"body": {
"prompt": "Write a short paragraph about your weekly routine.",
"word_count": 150,
"model_paragraph": "(Add a model paragraph after regenerating.)",
},
},
"listening": {
"material_type": "listening_script",
"title": f"Week {week_number} listening — placeholder",
"summary": "Skeleton listening script. Replace via Regenerate.",
"body": {
"script": (
"(Placeholder script.) Two friends discuss their "
"morning routines and weekend plans."
),
"comprehension_questions": [
{"q": "Who is speaking?", "answer": "Two friends."},
],
},
},
"speaking": {
"material_type": "speaking_prompt",
"title": f"Week {week_number} speaking — placeholder",
"summary": "Skeleton speaking prompts. Replace via Regenerate.",
"body": {
"prompts": [
"Describe a typical day in your life.",
"Talk about something you do at the weekend.",
],
"useful_language": ["usually", "often", "sometimes", "never"],
},
},
"grammar": {
"material_type": "grammar_lesson",
"title": f"Week {week_number} grammar — placeholder",
"summary": "Skeleton grammar mini-lesson. Replace via Regenerate.",
"body": {
"explanation": "Target structure for the week — replace via Regenerate.",
"examples": ["I work every day.", "She doesn't drink coffee."],
"practice": [
{"q": "He ___ (work) in a bank.", "answer": "works"},
],
},
},
"vocabulary": {
"material_type": "vocabulary_list",
"title": f"Week {week_number} vocabulary — placeholder",
"summary": "Skeleton vocabulary set. Replace via Regenerate.",
"body": {
"words": [
{"term": "routine", "pos": "n.", "definition": "a regular sequence of activities", "example": "My morning routine is busy."},
],
},
},
}
materials = []
for skill in wanted_skills:
t = templates.get(skill)
if t is None:
continue
materials.append({"skill": skill, **t})
return {"materials": materials}
@staticmethod
def _flatten_body(body):
"""Produce a plain-text dump of a material body for quick preview.
Not every shape is predictable (the model sometimes inserts
unusual keys), so we do a shallow walk and join string values
with newlines.
"""
if not isinstance(body, dict):
return ''
lines = []
for key, value in body.items():
if isinstance(value, str):
lines.append(f"{key}: {value}")
elif isinstance(value, list):
lines.append(f"{key}:")
for item in value:
if isinstance(item, str):
lines.append(f" - {item}")
elif isinstance(item, dict):
parts = []
for k, v in item.items():
if isinstance(v, (str, int, float)):
parts.append(f"{k}={v}")
if parts:
lines.append(" - " + ", ".join(parts))
elif isinstance(value, dict):
lines.append(f"{key}: " + json.dumps(value, ensure_ascii=False))
return "\n".join(lines)

View File

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

View File

@@ -0,0 +1,620 @@
"""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 tempfile
import time
from typing import Optional
_logger = logging.getLogger(__name__)
from odoo.addons.encoach_ai.services import provider_router
from odoo.addons.encoach_ai.services.provider_router import (
classify_provider_error,
should_fallback,
)
# --- 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."""
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]})
chain = provider_router.resolve_chain(
self.env, 'audio', requested=provider if provider != 'auto' else None,
)
errors = []
for prov in chain:
try:
result = self._call_audio_provider(
prov, text=text, 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}')
# -- 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 on PATH."""
if shutil.which('ffmpeg') 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', '-y',
'-loop', '1', '-i', i_path,
'-i', a_path,
'-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '192k',
'-shortest',
'-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,'
'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=white',
v_path,
]
t0 = time.time()
proc = subprocess.run(
cmd, capture_output=True, check=False, timeout=180,
)
elapsed = time.time() - t0
if proc.returncode != 0:
err = (proc.stderr or b'').decode('utf-8', errors='replace')[-500:]
raise RuntimeError(f'ffmpeg failed: {err}')
with open(v_path, 'rb') as f:
video_bytes = f.read()
attach = _attach_bytes(
self.env,
name=f'plan-{plan.id}-week-{material.week_number}'
f'-{material.material_type}-{material.id}.mp4',
mime_type='video/mp4',
data=video_bytes,
res_model='encoach.course.plan.media',
res_id=media.id,
)
media.write({
'attachment_id': attach.id,
'mime_type': 'video/mp4',
'size_bytes': len(video_bytes),
'duration_seconds': float(audio.duration_seconds or elapsed or 0),
'width': 1280,
'height': 720,
'provider': 'ffmpeg',
'status': 'ready',
'error': False,
})
return media
def _compose_video_static(self, media, material):
"""Last-resort: a tiny MP4-shaped image-only stub.
We don't pretend to render a true video without ffmpeg — instead
we attach the placeholder PNG with an MP4 mime so the LMS can
still display *something*. The media row is marked ``ready`` but
flagged as ``static`` so admins can re-generate later.
"""
from odoo.addons.encoach_ai.services.free_image import (
render_placeholder,
)
plan = material.plan_id
png = render_placeholder(
material.title or 'Course material',
subtitle=_image_subtitle(material, plan=plan) + ' · static',
size='1280x720',
seed=material.id,
)
attach = _attach_bytes(
self.env,
name=f'plan-{plan.id}-week-{material.week_number}'
f'-{material.material_type}-{material.id}-static.png',
mime_type='image/png',
data=png,
res_model='encoach.course.plan.media',
res_id=media.id,
)
media.write({
'attachment_id': attach.id,
'mime_type': 'image/png',
'size_bytes': len(png),
'duration_seconds': 0.0,
'width': 1280,
'height': 720,
'provider': 'static',
'status': 'ready',
'error': 'ffmpeg not available — served as static placeholder image',
})
return media

View File

@@ -0,0 +1,280 @@
"""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
from datetime import datetime
_logger = logging.getLogger(__name__)
def _extract_pdf(payload: bytes) -> str:
"""Best-effort PDF text extraction.
Tries ``pypdf`` first (newer, maintained), then ``PyPDF2`` (older,
still common). Both raise on encrypted PDFs we can't decrypt — we
swallow that and let the caller record the error.
"""
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('')
return '\n\n'.join(p for p in pages if p).strip()
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.')
if rtype == 'pdf' or file_name.lower().endswith('.pdf'):
return _extract_pdf(payload)
if rtype == 'document' and file_name.lower().endswith(('.docx', '.doc')):
return _extract_docx(payload)
# Fall back to plain-text decoding for txt/md/csv/json.
if file_name.lower().endswith((
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
'.log', '.rst',
)):
return payload.decode('utf-8', errors='replace').strip()
# Best-effort: try PDF first, then DOCX, then UTF-8.
for fn in (_extract_pdf, _extract_docx):
try:
text = fn(payload)
if text:
return text
except Exception:
continue
try:
return payload.decode('utf-8', errors='replace').strip()
except Exception as exc:
raise RuntimeError(
f'Cannot decode library resource binary: {exc}',
) from exc
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')
mime = (source.mime_type or '').lower()
name = (source.file_name or '').lower()
if mime == 'application/pdf' or name.endswith('.pdf'):
return _extract_pdf(payload)
if (mime in (
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword',
) or name.endswith('.docx') or name.endswith('.doc')):
return _extract_docx(payload)
# Whitelist plain-text-shaped uploads explicitly. Anything else
# (xlsx, png, mp3, zip, …) must be rejected with a clear error
# rather than silently UTF-8-decoded into garbage that we'd
# then "successfully" embed and surface as a usable RAG source.
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'mime={mime!r} name={source.file_name!r}. '
f'Supported: PDF, DOCX/DOC, 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():
source.write({
'status': 'failed',
'error': 'Extracted no text from source',
'chunks_count': 0,
'extracted_chars': 0,
})
return {'status': 'failed', 'error': 'empty'}
try:
svc = EmbeddingService(self.env)
metadata = {
'plan_id': source.plan_id.id,
'source_id': source.id,
'kind': source.kind,
'title': source.name or source.file_name or source.url or '',
'entity_id': source.plan_id.id,
}
chunks = svc.upsert(
self.CONTENT_TYPE, source.id, text, metadata,
)
except Exception as exc:
source.write({
'status': 'failed',
'error': str(exc)[:500],
})
_logger.exception('Embedding failed for source %s', source.id)
return {'status': 'failed', 'error': str(exc)}
source.write({
'status': 'indexed',
'error': False,
'chunks_count': len(chunks),
'extracted_chars': len(text),
'indexed_at': datetime.utcnow(),
})
return {
'status': 'indexed',
'chunks_count': len(chunks),
'extracted_chars': len(text),
}

View File

@@ -94,12 +94,39 @@ def _get_jwt_secret():
return secret return secret
def validate_token(): def _extract_bearer_token(allow_query_param: bool = False) -> str | None:
"""Decode JWT Bearer token and return the corresponding ``res.users`` record or 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", "") 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 return None
token = auth_header[7:]
secret = _get_jwt_secret() secret = _get_jwt_secret()
if not secret: if not secret:
_logger.error("System parameter 'encoach.jwt_secret' is not configured") _logger.error("System parameter 'encoach.jwt_secret' is not configured")

View File

@@ -57,17 +57,33 @@ def _ser_workflow(wf):
def _ser_request(req): def _ser_request(req):
stage = req.current_stage_id 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 { return {
'id': req.id, 'id': req.id,
'workflow_id': req.workflow_id.id or None, 'workflow_id': req.workflow_id.id or None,
'workflow_name': req.workflow_id.name if req.workflow_id else '', 'workflow_name': req.workflow_id.name if req.workflow_id else '',
'res_model': req.res_model or '', 'res_model': req.res_model or '',
'res_id': req.res_id or 0, 'res_id': req.res_id or 0,
'target_name': target_name,
'target_status': target_status,
'state': req.state or 'draft', 'state': req.state or 'draft',
'requester_id': req.requester_id.id or None, 'requester_id': req.requester_id.id or None,
'requester_name': req.requester_id.name if req.requester_id else '', 'requester_name': req.requester_id.name if req.requester_id else '',
'current_stage_id': stage.id or None, 'current_stage_id': stage.id or None,
'current_stage_sequence': stage.sequence if stage else 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 '', 'bypass_reason': req.bypass_reason or '',
'created_at': req.created_at.isoformat() if req.created_at else None, 'created_at': req.created_at.isoformat() if req.created_at else None,
} }
@@ -234,6 +250,16 @@ class ApprovalWorkflowController(http.Controller):
methods=['GET'], csrf=False) methods=['GET'], csrf=False)
@jwt_required @jwt_required
def list_requests(self, **kw): 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: try:
M = request.env['encoach.approval.request'].sudo() M = request.env['encoach.approval.request'].sudo()
domain = [] domain = []
@@ -241,6 +267,14 @@ class ApprovalWorkflowController(http.Controller):
domain.append(('state', '=', kw['state'])) domain.append(('state', '=', kw['state']))
if kw.get('workflow_id'): if kw.get('workflow_id'):
domain.append(('workflow_id', '=', int(kw['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') recs = M.search(domain, order='created_at desc, id desc')
items = [_ser_request(r) for r in recs] items = [_ser_request(r) for r in recs]
return _json_response({ return _json_response({
@@ -270,8 +304,23 @@ class ApprovalWorkflowController(http.Controller):
if not req_rec.exists(): if not req_rec.exists():
return _error_response('Request not found', 404) 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(): with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage: if stage:
stage.write({ stage.write({
'status': 'approved', 'status': 'approved',
@@ -296,6 +345,16 @@ class ApprovalWorkflowController(http.Controller):
}) })
else: else:
req_rec.write({'state': 'approved'}) 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, return _json_response({'success': True, 'id': req_id,
'state': req_rec.state}) 'state': req_rec.state})
@@ -318,8 +377,20 @@ class ApprovalWorkflowController(http.Controller):
req_rec = request.env['encoach.approval.request'].sudo().browse(req_id) req_rec = request.env['encoach.approval.request'].sudo().browse(req_id)
if not req_rec.exists(): if not req_rec.exists():
return _error_response('Request not found', 404) 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(): with request.env.cr.savepoint():
stage = req_rec.current_stage_id
if stage: if stage:
stage.write({ stage.write({
'status': 'rejected', 'status': 'rejected',
@@ -327,6 +398,14 @@ class ApprovalWorkflowController(http.Controller):
'acted_at': datetime.now(), 'acted_at': datetime.now(),
}) })
req_rec.write({'state': 'rejected'}) 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, return _json_response({'success': True, 'id': req_id,
'state': req_rec.state}) 'state': req_rec.state})
except Exception as e: except Exception as e:
@@ -339,18 +418,33 @@ class ApprovalWorkflowController(http.Controller):
methods=['GET'], csrf=False) methods=['GET'], csrf=False)
@jwt_required @jwt_required
def list_users(self, **kw): 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: try:
Users = request.env['res.users'].sudo() 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() search = (kw.get('search') or '').strip()
if search: if search:
domain += ['|', ('name', 'ilike', search), ('login', 'ilike', 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 = [{ items = [{
'id': u.id, 'id': u.id,
'name': u.name or u.login, 'name': u.name or u.login,
'login': u.login or '', 'login': u.login or '',
'email': u.email or '', 'email': u.email or '',
'user_type': u.user_type or '',
} for u in recs] } for u in recs]
return _json_response({'items': items, 'results': items, 'total': len(items)}) return _json_response({'items': items, 'results': items, 'total': len(items)})
except Exception as e: except Exception as e:

View File

@@ -11,6 +11,18 @@ _logger = logging.getLogger(__name__)
ENTITY_MODEL = 'encoach.entity' 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): def _entity_to_dict(entity):
d = entity.to_api_dict() if hasattr(entity, 'to_api_dict') else { d = entity.to_api_dict() if hasattr(entity, 'to_api_dict') else {
'id': entity.id, '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): class EntityController(http.Controller):
@http.route('/api/entities', type='http', auth='public', @http.route('/api/entities', type='http', auth='public',
@@ -59,6 +81,62 @@ class EntityController(http.Controller):
_logger.exception('list entities failed') _logger.exception('list entities failed')
return _error_response(str(e), 500) 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', @http.route('/api/entities/<int:entity_id>', type='http', auth='public',
methods=['GET'], csrf=False) methods=['GET'], csrf=False)
@jwt_required @jwt_required

View File

@@ -142,9 +142,23 @@ class EncoachExamScheduleController(http.Controller):
if batch_ids: if batch_ids:
vals['batch_ids'] = [(6, 0, [int(b) for b in 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', []) student_ids = body.get('student_ids', [])
if 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) rec = request.env['encoach.exam.schedule'].sudo().create(vals)
@@ -155,6 +169,46 @@ class EncoachExamScheduleController(http.Controller):
_logger.exception('exam-schedule create failed') _logger.exception('exam-schedule create failed')
return _json_response({'error': str(e)}, 500) 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): def _create_individual_assignments(self, schedule):
"""Create individual assignment records for each targeted student.""" """Create individual assignment records for each targeted student."""
Assignment = request.env['encoach.exam.assignment'].sudo() Assignment = request.env['encoach.exam.assignment'].sudo()
@@ -233,7 +287,9 @@ class EncoachExamScheduleController(http.Controller):
if 'batch_ids' in body: if 'batch_ids' in body:
vals['batch_ids'] = [(6, 0, [int(b) for b in body['batch_ids']])] vals['batch_ids'] = [(6, 0, [int(b) for b in body['batch_ids']])]
if 'student_ids' in body: 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: if 'state' in body:
vals['state'] = body['state'] vals['state'] = body['state']
if vals: if vals:

View File

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

View File

@@ -224,7 +224,15 @@ class AcademicController(http.Controller):
'term_end_date': str(e), 'term_end_date': str(e),
'academic_year_id': year.id, '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}) q1.write({'parent_id': parent.id})
q2.write({'parent_id': parent.id}) q2.write({'parent_id': parent.id})
quarters += [q1, q2] quarters += [q1, q2]

View File

@@ -11,6 +11,48 @@ _logger = logging.getLogger(__name__)
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _entity_scope():
"""Return (entity_ids, is_superadmin) for the current user."""
user = request.env.user.sudo()
is_super = bool(user.has_group('base.group_system'))
ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
return ids, is_super
def _ensure_entity_access(entity_id):
"""Raise PermissionError if current user cannot 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():
"""Pick the default entity for creation from the current user 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, field='entity_id'):
"""Apply entity isolation domain to a model query."""
domain = list(base_domain or [])
ids, is_super = _entity_scope()
if is_super:
return domain
if not ids:
return domain + [('id', '=', 0)]
return domain + [(field, 'in', ids)]
def _serialize_course(c): def _serialize_course(c):
subj = getattr(c, 'encoach_subject_id', False) subj = getattr(c, 'encoach_subject_id', False)
tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag']) tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag'])
@@ -43,6 +85,8 @@ def _serialize_course(c):
'chapter_count': getattr(c, 'chapter_count', 0) or 0, 'chapter_count': getattr(c, 'chapter_count', 0) or 0,
'resource_count': getattr(c, 'resource_count', 0) or 0, 'resource_count': getattr(c, 'resource_count', 0) or 0,
'objective_count': getattr(c, 'objective_count', 0) or 0, 'objective_count': getattr(c, 'objective_count', 0) or 0,
'entity_id': c.entity_id.id if hasattr(c, 'entity_id') and c.entity_id else None,
'entity_name': c.entity_id.name if hasattr(c, 'entity_id') and c.entity_id else '',
} }
@@ -75,6 +119,8 @@ def _serialize_student(s):
'batch_name': batch_name, 'batch_name': batch_name,
'partner_id': partner.id, 'partner_id': partner.id,
'user_id': s.user_id.id if s.user_id else None, 'user_id': s.user_id.id if s.user_id else None,
'entity_id': s.entity_id.id if hasattr(s, 'entity_id') and s.entity_id else None,
'entity_name': s.entity_id.name if hasattr(s, 'entity_id') and s.entity_id else '',
} }
@@ -97,6 +143,8 @@ def _serialize_teacher(f):
'department_name': dept.name if dept else '', 'department_name': dept.name if dept else '',
'specialization': getattr(f, 'specialization', '') or '', 'specialization': getattr(f, 'specialization', '') or '',
'subject_names': [sub.name for sub in f.subject_ids] if hasattr(f, 'subject_ids') else [], 'subject_names': [sub.name for sub in f.subject_ids] if hasattr(f, 'subject_ids') else [],
'entity_id': f.entity_id.id if hasattr(f, 'entity_id') and f.entity_id else None,
'entity_name': f.entity_id.name if hasattr(f, 'entity_id') and f.entity_id else '',
} }
@@ -127,6 +175,8 @@ def _serialize_batch(b):
'max_students': getattr(b, 'max_students', 0) or 0, 'max_students': getattr(b, 'max_students', 0) or 0,
'student_count': len(students), 'student_count': len(students),
'students': students, 'students': students,
'entity_id': b.entity_id.id if hasattr(b, 'entity_id') and b.entity_id else None,
'entity_name': b.entity_id.name if hasattr(b, 'entity_id') and b.entity_id else '',
} }
@@ -139,9 +189,11 @@ class LmsCoreController(http.Controller):
def list_courses(self, **kw): def list_courses(self, **kw):
try: try:
Course = request.env['op.course'].sudo() Course = request.env['op.course'].sudo()
domain = [] domain = _scoped_entity_domain([])
if kw.get('status'): if kw.get('status'):
pass # op.course has no status field by default pass # op.course has no status field by default
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
offset, limit, page = _paginate(kw) offset, limit, page = _paginate(kw)
total = Course.search_count(domain) total = Course.search_count(domain)
records = Course.search(domain, offset=offset, limit=limit, order='id desc') records = Course.search(domain, offset=offset, limit=limit, order='id desc')
@@ -154,7 +206,7 @@ class LmsCoreController(http.Controller):
}) })
except Exception as e: except Exception as e:
_logger.exception('list_courses failed') _logger.exception('list_courses failed')
return _error_response(str(e), 500) return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['GET'], csrf=False) @http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required @jwt_required
@@ -163,6 +215,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.course'].sudo().browse(course_id) rec = request.env['op.course'].sudo().browse(course_id)
if not rec.exists(): if not rec.exists():
return _error_response('Not found', 404) return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
return _json_response({'data': _serialize_course(rec)}) return _json_response({'data': _serialize_course(rec)})
except Exception as e: except Exception as e:
_logger.exception('get_course failed') _logger.exception('get_course failed')
@@ -173,10 +228,17 @@ class LmsCoreController(http.Controller):
def create_course(self, **kw): def create_course(self, **kw):
try: try:
body = _get_json_body() body = _get_json_body()
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 = { vals = {
'name': body.get('name', ''), 'name': body.get('name', ''),
'code': body.get('code', ''), 'code': body.get('code', ''),
} }
if entity_id:
vals['entity_id'] = entity_id
if body.get('description'): if body.get('description'):
vals['description'] = body['description'] vals['description'] = body['description']
if body.get('max_capacity'): if body.get('max_capacity'):
@@ -197,7 +259,7 @@ class LmsCoreController(http.Controller):
return _json_response({'data': _serialize_course(rec)}) return _json_response({'data': _serialize_course(rec)})
except Exception as e: except Exception as e:
_logger.exception('create_course failed') _logger.exception('create_course failed')
return _error_response(str(e), 500) return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) @http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required @jwt_required
@@ -206,6 +268,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.course'].sudo().browse(course_id) rec = request.env['op.course'].sudo().browse(course_id)
if not rec.exists(): if not rec.exists():
return _error_response('Not found', 404) return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body() body = _get_json_body()
vals = {} vals = {}
for k in ('name', 'code', 'description'): for k in ('name', 'code', 'description'):
@@ -225,12 +290,14 @@ class LmsCoreController(http.Controller):
vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])] vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])]
if 'tag_ids' in body: if 'tag_ids' in body:
vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])] vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])]
if 'entity_id' in body:
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
if vals: if vals:
rec.write(vals) rec.write(vals)
return _json_response({'data': _serialize_course(rec)}) return _json_response({'data': _serialize_course(rec)})
except Exception as e: except Exception as e:
_logger.exception('update_course failed') _logger.exception('update_course failed')
return _error_response(str(e), 500) return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['DELETE'], csrf=False) @http.route('/api/courses/<int:course_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required @jwt_required
@@ -239,6 +306,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.course'].sudo().browse(course_id) rec = request.env['op.course'].sudo().browse(course_id)
if not rec.exists(): if not rec.exists():
return _json_response({'success': True}) return _json_response({'success': True})
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
force = str(kw.get('force', '')).lower() in ('1', 'true', 'yes') force = str(kw.get('force', '')).lower() in ('1', 'true', 'yes')
enrollments = request.env['op.student.course'].sudo().search([('course_id', '=', course_id)]) enrollments = request.env['op.student.course'].sudo().search([('course_id', '=', course_id)])
if enrollments and not force: if enrollments and not force:
@@ -272,8 +342,12 @@ class LmsCoreController(http.Controller):
('student_id', '=', student.id) ('student_id', '=', student.id)
]) ])
course_ids = course_details.mapped('course_id') course_ids = course_details.mapped('course_id')
ids, is_super = _entity_scope()
items = [] items = []
for c in course_ids: for c in course_ids:
if not is_super:
if not c.entity_id or c.entity_id.id not in ids:
continue
data = _serialize_course(c) data = _serialize_course(c)
cd = course_details.filtered(lambda d: d.course_id.id == c.id) cd = course_details.filtered(lambda d: d.course_id.id == c.id)
data['batch_id'] = cd[0].batch_id.id if cd and cd[0].batch_id else None data['batch_id'] = cd[0].batch_id.id if cd and cd[0].batch_id else None
@@ -307,6 +381,9 @@ class LmsCoreController(http.Controller):
student = request.env['op.student'].sudo().browse(student_id) student = request.env['op.student'].sudo().browse(student_id)
if not student.exists(): if not student.exists():
return _error_response('Student not found', 404) return _error_response('Student not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not student.entity_id or student.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body() body = _get_json_body()
course_id = body.get('course_id') course_id = body.get('course_id')
course_ids = body.get('course_ids', []) course_ids = body.get('course_ids', [])
@@ -317,6 +394,13 @@ class LmsCoreController(http.Controller):
SC = request.env['op.student.course'].sudo() SC = request.env['op.student.course'].sudo()
for cid in course_ids: for cid in course_ids:
cid = int(cid) cid = int(cid)
course = request.env['op.course'].sudo().browse(cid)
if not course.exists():
continue
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
continue
if student.entity_id and course.entity_id and student.entity_id.id != course.entity_id.id:
continue
existing = SC.search([ existing = SC.search([
('student_id', '=', student.id), ('student_id', '=', student.id),
('course_id', '=', cid), ('course_id', '=', cid),
@@ -347,6 +431,9 @@ class LmsCoreController(http.Controller):
course = request.env['op.course'].sudo().browse(course_id) course = request.env['op.course'].sudo().browse(course_id)
if not course.exists(): if not course.exists():
return _error_response('Course not found', 404) return _error_response('Course not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body() body = _get_json_body()
student_ids = [int(sid) for sid in body.get('student_ids', [])] student_ids = [int(sid) for sid in body.get('student_ids', [])]
batch_id = body.get('batch_id') batch_id = body.get('batch_id')
@@ -358,6 +445,13 @@ class LmsCoreController(http.Controller):
SC = request.env['op.student.course'].sudo() SC = request.env['op.student.course'].sudo()
enrolled = [] enrolled = []
for sid in student_ids: for sid in student_ids:
stu = request.env['op.student'].sudo().browse(sid)
if not stu.exists():
continue
if not is_super and (not stu.entity_id or stu.entity_id.id not in ids):
continue
if stu.entity_id and course.entity_id and stu.entity_id.id != course.entity_id.id:
continue
existing = SC.search([ existing = SC.search([
('student_id', '=', sid), ('student_id', '=', sid),
('course_id', '=', course_id), ('course_id', '=', course_id),
@@ -385,11 +479,13 @@ class LmsCoreController(http.Controller):
def list_students(self, **kw): def list_students(self, **kw):
try: try:
Student = request.env['op.student'].sudo() Student = request.env['op.student'].sudo()
domain = [] domain = _scoped_entity_domain([])
if kw.get('search'): if kw.get('search'):
domain = [('partner_id.name', 'ilike', kw['search'])] domain.append(('partner_id.name', 'ilike', kw['search']))
if kw.get('batch_id'): if kw.get('batch_id'):
domain.append(('course_detail_ids.batch_id', '=', int(kw['batch_id']))) domain.append(('course_detail_ids.batch_id', '=', int(kw['batch_id'])))
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
offset, limit, page = _paginate(kw) offset, limit, page = _paginate(kw)
total = Student.search_count(domain) total = Student.search_count(domain)
records = Student.search(domain, offset=offset, limit=limit, order='id desc') records = Student.search(domain, offset=offset, limit=limit, order='id desc')
@@ -402,7 +498,7 @@ class LmsCoreController(http.Controller):
}) })
except Exception as e: except Exception as e:
_logger.exception('list_students failed') _logger.exception('list_students failed')
return _error_response(str(e), 500) return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['GET'], csrf=False) @http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required @jwt_required
@@ -411,6 +507,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.student'].sudo().browse(student_id) rec = request.env['op.student'].sudo().browse(student_id)
if not rec.exists(): if not rec.exists():
return _error_response('Not found', 404) return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
return _json_response({'data': _serialize_student(rec)}) return _json_response({'data': _serialize_student(rec)})
except Exception as e: except Exception as e:
_logger.exception('get_student failed') _logger.exception('get_student failed')
@@ -421,6 +520,11 @@ class LmsCoreController(http.Controller):
def create_student(self, **kw): def create_student(self, **kw):
try: try:
body = _get_json_body() body = _get_json_body()
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()
first = body.get('first_name', '') first = body.get('first_name', '')
last = body.get('last_name', '') last = body.get('last_name', '')
name = f"{first} {last}".strip() name = f"{first} {last}".strip()
@@ -434,6 +538,8 @@ class LmsCoreController(http.Controller):
'partner_id': partner.id, 'partner_id': partner.id,
'gender': body.get('gender', ''), 'gender': body.get('gender', ''),
} }
if entity_id:
student_vals['entity_id'] = entity_id
if body.get('birth_date'): if body.get('birth_date'):
student_vals['birth_date'] = body['birth_date'] student_vals['birth_date'] = body['birth_date']
student = request.env['op.student'].sudo().create(student_vals) student = request.env['op.student'].sudo().create(student_vals)
@@ -453,11 +559,13 @@ class LmsCoreController(http.Controller):
'password': body.get('password', 'student123'), 'password': body.get('password', 'student123'),
'partner_id': partner.id, 'partner_id': partner.id,
}) })
if entity_id and hasattr(user, 'entity_ids'):
user.write({'entity_ids': [(4, entity_id)]})
student.sudo().write({'user_id': user.id}) student.sudo().write({'user_id': user.id})
return _json_response({'data': _serialize_student(student)}) return _json_response({'data': _serialize_student(student)})
except Exception as e: except Exception as e:
_logger.exception('create_student failed') _logger.exception('create_student failed')
return _error_response(str(e), 500) return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) @http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required @jwt_required
@@ -466,6 +574,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.student'].sudo().browse(student_id) rec = request.env['op.student'].sudo().browse(student_id)
if not rec.exists(): if not rec.exists():
return _error_response('Not found', 404) return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body() body = _get_json_body()
partner_vals = {} partner_vals = {}
if 'first_name' in body or 'last_name' in body: if 'first_name' in body or 'last_name' in body:
@@ -481,12 +592,14 @@ class LmsCoreController(http.Controller):
student_vals = {} student_vals = {}
if 'gender' in body: if 'gender' in body:
student_vals['gender'] = body['gender'] student_vals['gender'] = body['gender']
if 'entity_id' in body:
student_vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
if student_vals: if student_vals:
rec.write(student_vals) rec.write(student_vals)
return _json_response({'data': _serialize_student(rec)}) return _json_response({'data': _serialize_student(rec)})
except Exception as e: except Exception as e:
_logger.exception('update_student failed') _logger.exception('update_student failed')
return _error_response(str(e), 500) return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['DELETE'], csrf=False) @http.route('/api/students/<int:student_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required @jwt_required
@@ -494,6 +607,9 @@ class LmsCoreController(http.Controller):
try: try:
rec = request.env['op.student'].sudo().browse(student_id) rec = request.env['op.student'].sudo().browse(student_id)
if rec.exists(): if rec.exists():
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
rec.unlink() rec.unlink()
return _json_response({'success': True}) return _json_response({'success': True})
except Exception as e: except Exception as e:
@@ -507,9 +623,11 @@ class LmsCoreController(http.Controller):
def list_teachers(self, **kw): def list_teachers(self, **kw):
try: try:
Faculty = request.env['op.faculty'].sudo() Faculty = request.env['op.faculty'].sudo()
domain = [] domain = _scoped_entity_domain([])
if kw.get('search'): if kw.get('search'):
domain = [('partner_id.name', 'ilike', kw['search'])] domain.append(('partner_id.name', 'ilike', kw['search']))
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
offset, limit, page = _paginate(kw) offset, limit, page = _paginate(kw)
total = Faculty.search_count(domain) total = Faculty.search_count(domain)
records = Faculty.search(domain, offset=offset, limit=limit, order='id desc') records = Faculty.search(domain, offset=offset, limit=limit, order='id desc')
@@ -522,13 +640,18 @@ class LmsCoreController(http.Controller):
}) })
except Exception as e: except Exception as e:
_logger.exception('list_teachers failed') _logger.exception('list_teachers failed')
return _error_response(str(e), 500) return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/teachers', type='http', auth='public', methods=['POST'], csrf=False) @http.route('/api/teachers', type='http', auth='public', methods=['POST'], csrf=False)
@jwt_required @jwt_required
def create_teacher(self, **kw): def create_teacher(self, **kw):
try: try:
body = _get_json_body() body = _get_json_body()
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()
first = body.get('first_name', '') first = body.get('first_name', '')
last = body.get('last_name', '') last = body.get('last_name', '')
name = f"{first} {last}".strip() name = f"{first} {last}".strip()
@@ -541,6 +664,8 @@ class LmsCoreController(http.Controller):
'partner_id': partner.id, 'partner_id': partner.id,
'gender': body.get('gender', ''), 'gender': body.get('gender', ''),
} }
if entity_id:
fac_vals['entity_id'] = entity_id
if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'): if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'):
fac_vals['department_id'] = int(body['department_id']) fac_vals['department_id'] = int(body['department_id'])
if body.get('birth_date'): if body.get('birth_date'):
@@ -549,14 +674,64 @@ class LmsCoreController(http.Controller):
return _json_response({'data': _serialize_teacher(faculty)}) return _json_response({'data': _serialize_teacher(faculty)})
except Exception as e: except Exception as e:
_logger.exception('create_teacher failed') _logger.exception('create_teacher failed')
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required
def get_teacher(self, teacher_id, **kw):
try:
rec = request.env['op.faculty'].sudo().browse(teacher_id)
if not rec.exists():
return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
return _json_response({'data': _serialize_teacher(rec)})
except Exception as e:
_logger.exception('get_teacher failed')
return _error_response(str(e), 500) return _error_response(str(e), 500)
@http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required
def update_teacher(self, teacher_id, **kw):
try:
rec = request.env['op.faculty'].sudo().browse(teacher_id)
if not rec.exists():
return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body()
pvals = {}
if 'name' in body:
pvals['name'] = body['name']
if 'email' in body:
pvals['email'] = body['email']
if 'phone' in body:
pvals['phone'] = body['phone']
if pvals:
rec.partner_id.sudo().write(pvals)
vals = {}
if 'gender' in body:
vals['gender'] = body['gender']
if 'entity_id' in body:
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
if vals:
rec.write(vals)
return _json_response({'data': _serialize_teacher(rec)})
except Exception as e:
_logger.exception('update_teacher failed')
return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['DELETE'], csrf=False) @http.route('/api/teachers/<int:teacher_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required @jwt_required
def delete_teacher(self, teacher_id, **kw): def delete_teacher(self, teacher_id, **kw):
try: try:
rec = request.env['op.faculty'].sudo().browse(teacher_id) rec = request.env['op.faculty'].sudo().browse(teacher_id)
if rec.exists(): if rec.exists():
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
rec.unlink() rec.unlink()
return _json_response({'success': True}) return _json_response({'success': True})
except Exception as e: except Exception as e:
@@ -570,7 +745,9 @@ class LmsCoreController(http.Controller):
def list_batches(self, **kw): def list_batches(self, **kw):
try: try:
Batch = request.env['op.batch'].sudo() Batch = request.env['op.batch'].sudo()
domain = [] domain = _scoped_entity_domain([])
if kw.get('entity_id'):
domain.append(('entity_id', '=', _ensure_entity_access(int(kw['entity_id']))))
offset, limit, page = _paginate(kw) offset, limit, page = _paginate(kw)
total = Batch.search_count(domain) total = Batch.search_count(domain)
records = Batch.search(domain, offset=offset, limit=limit, order='id desc') records = Batch.search(domain, offset=offset, limit=limit, order='id desc')
@@ -583,7 +760,7 @@ class LmsCoreController(http.Controller):
}) })
except Exception as e: except Exception as e:
_logger.exception('list_batches failed') _logger.exception('list_batches failed')
return _error_response(str(e), 500) return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['GET'], csrf=False) @http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['GET'], csrf=False)
@jwt_required @jwt_required
@@ -592,6 +769,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.batch'].sudo().browse(batch_id) rec = request.env['op.batch'].sudo().browse(batch_id)
if not rec.exists(): if not rec.exists():
return _error_response('Not found', 404) return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
return _json_response({'data': _serialize_batch(rec)}) return _json_response({'data': _serialize_batch(rec)})
except Exception as e: except Exception as e:
_logger.exception('get_batch failed') _logger.exception('get_batch failed')
@@ -602,11 +782,18 @@ class LmsCoreController(http.Controller):
def create_batch(self, **kw): def create_batch(self, **kw):
try: try:
body = _get_json_body() body = _get_json_body()
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': body.get('name', '')} vals = {'name': body.get('name', '')}
if body.get('code'): if body.get('code'):
vals['code'] = body['code'] vals['code'] = body['code']
if body.get('course_id'): if body.get('course_id'):
vals['course_id'] = int(body['course_id']) vals['course_id'] = int(body['course_id'])
if entity_id:
vals['entity_id'] = entity_id
if body.get('start_date'): if body.get('start_date'):
vals['start_date'] = body['start_date'] vals['start_date'] = body['start_date']
if body.get('end_date'): if body.get('end_date'):
@@ -615,7 +802,7 @@ class LmsCoreController(http.Controller):
return _json_response({'data': _serialize_batch(rec)}) return _json_response({'data': _serialize_batch(rec)})
except Exception as e: except Exception as e:
_logger.exception('create_batch failed') _logger.exception('create_batch failed')
return _error_response(str(e), 500) return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) @http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False)
@jwt_required @jwt_required
@@ -624,6 +811,9 @@ class LmsCoreController(http.Controller):
rec = request.env['op.batch'].sudo().browse(batch_id) rec = request.env['op.batch'].sudo().browse(batch_id)
if not rec.exists(): if not rec.exists():
return _error_response('Not found', 404) return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body() body = _get_json_body()
vals = {} vals = {}
for k in ('name', 'code', 'start_date', 'end_date'): for k in ('name', 'code', 'start_date', 'end_date'):
@@ -631,12 +821,14 @@ class LmsCoreController(http.Controller):
vals[k] = body[k] vals[k] = body[k]
if 'course_id' in body: if 'course_id' in body:
vals['course_id'] = int(body['course_id']) vals['course_id'] = int(body['course_id'])
if 'entity_id' in body:
vals['entity_id'] = _ensure_entity_access(int(body['entity_id'])) if body['entity_id'] else False
if vals: if vals:
rec.write(vals) rec.write(vals)
return _json_response({'data': _serialize_batch(rec)}) return _json_response({'data': _serialize_batch(rec)})
except Exception as e: except Exception as e:
_logger.exception('update_batch failed') _logger.exception('update_batch failed')
return _error_response(str(e), 500) return _error_response(str(e), 403 if isinstance(e, PermissionError) else 500)
@http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['DELETE'], csrf=False) @http.route('/api/batches/<int:batch_id>', type='http', auth='public', methods=['DELETE'], csrf=False)
@jwt_required @jwt_required
@@ -644,6 +836,9 @@ class LmsCoreController(http.Controller):
try: try:
rec = request.env['op.batch'].sudo().browse(batch_id) rec = request.env['op.batch'].sudo().browse(batch_id)
if rec.exists(): if rec.exists():
ids, is_super = _entity_scope()
if not is_super and (not rec.entity_id or rec.entity_id.id not in ids):
return _error_response('Forbidden', 403)
rec.unlink() rec.unlink()
return _json_response({'success': True}) return _json_response({'success': True})
except Exception as e: except Exception as e:
@@ -655,6 +850,12 @@ class LmsCoreController(http.Controller):
def list_batch_students(self, batch_id, **kw): def list_batch_students(self, batch_id, **kw):
try: try:
SC = request.env['op.student.course'].sudo() SC = request.env['op.student.course'].sudo()
batch = request.env['op.batch'].sudo().browse(batch_id)
if not batch.exists():
return _error_response('Batch not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not batch.entity_id or batch.entity_id.id not in ids):
return _error_response('Forbidden', 403)
recs = SC.search([('batch_id', '=', batch_id)]) recs = SC.search([('batch_id', '=', batch_id)])
students = [] students = []
for sc in recs: for sc in recs:
@@ -680,11 +881,19 @@ class LmsCoreController(http.Controller):
batch = request.env['op.batch'].sudo().browse(batch_id) batch = request.env['op.batch'].sudo().browse(batch_id)
if not batch.exists(): if not batch.exists():
return _error_response('Batch not found', 404) return _error_response('Batch not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not batch.entity_id or batch.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body() body = _get_json_body()
student_ids = [int(s) for s in body.get('student_ids', [])] student_ids = [int(s) for s in body.get('student_ids', [])]
SC = request.env['op.student.course'].sudo() SC = request.env['op.student.course'].sudo()
added = [] added = []
for sid in student_ids: for sid in student_ids:
stu = request.env['op.student'].sudo().browse(sid)
if not stu.exists():
continue
if batch.entity_id and stu.entity_id and batch.entity_id.id != stu.entity_id.id:
continue
existing = SC.search([ existing = SC.search([
('student_id', '=', sid), ('student_id', '=', sid),
('batch_id', '=', batch_id), ('batch_id', '=', batch_id),
@@ -719,6 +928,12 @@ class LmsCoreController(http.Controller):
def remove_students_from_batch(self, batch_id, **kw): def remove_students_from_batch(self, batch_id, **kw):
"""Remove students from a batch by clearing their batch_id.""" """Remove students from a batch by clearing their batch_id."""
try: try:
batch = request.env['op.batch'].sudo().browse(batch_id)
if not batch.exists():
return _error_response('Batch not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not batch.entity_id or batch.entity_id.id not in ids):
return _error_response('Forbidden', 403)
body = _get_json_body() body = _get_json_body()
student_ids = [int(s) for s in body.get('student_ids', [])] student_ids = [int(s) for s in body.get('student_ids', [])]
SC = request.env['op.student.course'].sudo() SC = request.env['op.student.course'].sudo()
@@ -751,6 +966,9 @@ class LmsCoreController(http.Controller):
course = request.env['op.course'].sudo().browse(course_id) course = request.env['op.course'].sudo().browse(course_id)
if not course.exists(): if not course.exists():
return _error_response('Not found', 404) return _error_response('Not found', 404)
ids, is_super = _entity_scope()
if not is_super and (not course.entity_id or course.entity_id.id not in ids):
return _error_response('Forbidden', 403)
subj = course.encoach_subject_id if hasattr(course, 'encoach_subject_id') else False subj = course.encoach_subject_id if hasattr(course, 'encoach_subject_id') else False
topics = course.encoach_topic_ids if hasattr(course, 'encoach_topic_ids') else course.env['encoach.topic'] topics = course.encoach_topic_ids if hasattr(course, 'encoach_topic_ids') else course.env['encoach.topic']
objectives = course.learning_objective_ids if hasattr(course, 'learning_objective_ids') else course.env['encoach.learning.objective'] objectives = course.learning_objective_ids if hasattr(course, 'learning_objective_ids') else course.env['encoach.learning.objective']
@@ -771,9 +989,9 @@ class LmsCoreController(http.Controller):
def subject_courses(self, subject_id, **kw): def subject_courses(self, subject_id, **kw):
"""Return all courses linked to a given taxonomy subject.""" """Return all courses linked to a given taxonomy subject."""
try: try:
courses = request.env['op.course'].sudo().search([ courses = request.env['op.course'].sudo().search(_scoped_entity_domain([
('encoach_subject_id', '=', subject_id) ('encoach_subject_id', '=', subject_id)
]) ]))
return _json_response({ return _json_response({
'items': [_serialize_course(c) for c in courses], 'items': [_serialize_course(c) for c in courses],
'total': len(courses), 'total': len(courses),

View File

@@ -63,17 +63,70 @@ def _attempt_completed_at(att):
return None 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): 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 = [] domain = []
if reportable: if reportable:
domain.append(('status', 'in', list(REPORTABLE_STATUSES))) 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: try:
domain.append(('entity_id', '=', int(entity_id))) requested_int = int(requested_entity)
except (TypeError, ValueError): 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') user_id = kw.get('user_id') or kw.get('student_id')
if user_id: if user_id:
try: try:

View File

@@ -1,17 +1,90 @@
import base64 import base64
import logging import logging
import mimetypes
import os
from odoo import http from odoo import http
from odoo.http import request from odoo.http import request
from odoo.addons.encoach_api.controllers.base import ( from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate, jwt_required, _json_response, _error_response, _get_json_body, _paginate,
validate_token,
) )
_logger = logging.getLogger(__name__) _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): def _ser_resource(r):
tags = r.tag_ids if r.tag_ids else r.env['encoach.resource.tag'] 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'] 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 { return {
'id': r.id, 'id': r.id,
'name': r.name or '', '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], 'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags],
'url': r.url or '', 'url': r.url or '',
'has_file': bool(r.file), '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 '', 'difficulty': r.difficulty or '',
'duration_minutes': r.duration_minutes or 0, 'duration_minutes': r.duration_minutes or 0,
'author_id': r.creator_id.id if r.creator_id else None, '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'): if params.get('cefr_level'):
vals['cefr_level'] = params['cefr_level'] vals['cefr_level'] = params['cefr_level']
if f: 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) rec = request.env['encoach.resource'].sudo().create(vals)
return _json_response({'data': _ser_resource(rec)}) return _json_response({'data': _ser_resource(rec)})
except Exception as e: except Exception as e:
@@ -195,21 +302,49 @@ class ResourcesController(http.Controller):
except Exception as e: except Exception as e:
return _error_response(str(e), 500) return _error_response(str(e), 500)
@http.route('/api/resources/<int:rid>/download', type='http', auth='public', methods=['GET'], csrf=False) # ``auth='none'`` + manual JWT validation lets us accept the token
@jwt_required # 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): def download_resource(self, rid, **kw):
try: 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) rec = request.env['encoach.resource'].sudo().browse(rid)
if not rec.exists() or not rec.file: if not rec.exists() or not rec.file:
return _error_response('No file', 404) 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) 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, [ return request.make_response(data, [
('Content-Type', mime), ('Content-Type', mime),
('Content-Disposition', f'attachment; filename="{rec.name}"'), ('Content-Disposition', f'{disposition_kind}; filename="{filename}"'),
('Content-Length', str(len(data))), ('Content-Length', str(len(data))),
('Cache-Control', 'private, max-age=3600'),
]) ])
except Exception as e: except Exception as e:
_logger.exception('download_resource') _logger.exception('download_resource')

View File

@@ -6,6 +6,13 @@ class OpCourseExt(models.Model):
description = fields.Text('Description') description = fields.Text('Description')
max_capacity = fields.Integer('Max Capacity', default=30) 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.',
)
encoach_subject_id = fields.Many2one( encoach_subject_id = fields.Many2one(
'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True, 'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True,
@@ -54,3 +61,39 @@ class OpCourseExt(models.Model):
('resource_id', '!=', False), ('resource_id', '!=', False),
]) ])
rec.resource_count = len(mats.mapped('resource_id')) 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.',
)
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.',
)
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.',
)

View File

@@ -12,6 +12,13 @@ class EncoachResource(models.Model):
('document', 'Document'), ('document', 'Document'),
('link', 'Link'), ('link', 'Link'),
('interactive', 'Interactive'), ('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([ review_status = fields.Selection([
('pending', 'Pending'), ('pending', 'Pending'),
@@ -30,6 +37,24 @@ class EncoachResource(models.Model):
'resource_id', 'tag_id', string='Tags', 'resource_id', 'tag_id', string='Tags',
) )
file = fields.Binary(attachment=True) 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() url = fields.Char()
difficulty = fields.Selection([ difficulty = fields.Selection([
('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced'), ('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced'),
@@ -67,6 +92,16 @@ class EncoachResource(models.Model):
def to_api_dict(self): def to_api_dict(self):
self.ensure_one() self.ensure_one()
creator = self.creator_id 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 { return {
'id': self.id, 'id': self.id,
'name': self.name, 'name': self.name,
@@ -82,6 +117,10 @@ class EncoachResource(models.Model):
'learning_objective_names': self.learning_objective_ids.mapped('name'), 'learning_objective_names': self.learning_objective_ids.mapped('name'),
'url': self.url or '', 'url': self.url or '',
'has_file': bool(self.file), '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 '', 'difficulty': self.difficulty or '',
'duration_minutes': self.duration_minutes, 'duration_minutes': self.duration_minutes,
'author_id': creator.id if creator else None, 'author_id': creator.id if creator else None,
@@ -98,5 +137,5 @@ class EncoachResource(models.Model):
'approved': self.approved, 'approved': self.approved,
'course_count': self.course_count, 'course_count': self.course_count,
'created_at': self.create_date.isoformat() if self.create_date else '', 'created_at': self.create_date.isoformat() if self.create_date else '',
'file_name': self.name, 'file_name': self.original_filename or self.name,
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,449 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
msgid "Academic Year"
msgstr "Año Académico"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Acción necesaria"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
msgid "Active"
msgstr "Activo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Actividades"
#. module: openeducat_activity
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
msgid "Activity"
msgstr "Actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Recuento de actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Decoración de excepción de actividad"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Vista de gráfico de actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
msgid "Activity Log"
msgstr "Registro de actividad"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
msgid "Activity Logs"
msgstr "Registros de actividad"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Pivote de actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Estado de actividad"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity_type
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
msgid "Activity Type"
msgstr "Tipo de actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Icono de tipo de actividad"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
msgid "Activity Types"
msgstr "Tipos de actividad"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "¡El tipo de actividad debe ser único!"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
msgid "Archived"
msgstr "Archivado"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Recuento de archivos adjuntos"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same Program!"
msgstr ""
"¡No se puede migrar porque los cursos seleccionados no comparten el mismo "
"programa!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same parent course!"
msgstr ""
"No se puede migrar porque los cursos seleccionados no comparten el mismo "
"curso principal."
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "No puedo migrar. Continúe con una nueva admisión."
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Cancelar"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "¿Curso completado?"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
msgid "Created by"
msgstr "Creado por"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
msgid "Created on"
msgstr "Creado el"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
msgid "Date"
msgstr "Fecha"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
msgid "Description"
msgstr "Descripción"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
msgid "Display Name"
msgstr "Nombre para mostrar"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Facultad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Seguidores"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Seguidores (socios)"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr "Icono de fuente impresionante, p. tareas fa"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Adelante"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Del curso"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "From Course must not be same as To Course!"
msgstr "¡Desde el curso no debe ser lo mismo que hacia el curso!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Tiene mensaje"
#. module: openeducat_activity
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
msgid "Helps you manage your institutes different-different users."
msgstr "Le ayuda a administrar los diferentes usuarios de sus institutos."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
msgid "ID"
msgstr "IDENTIFICACIÓN"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Icono"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Icono para indicar una actividad de excepción."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Si está marcado, los mensajes nuevos requieren su atención."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr "Si está marcado, algunos mensajes tienen un error de entrega."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "es seguidor"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
msgid "Last Updated by"
msgstr "Actualizado por última vez por"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
msgid "Last Updated on"
msgstr "Última actualización el"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Gerente"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Error de entrega de mensaje"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Mensajes"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Fecha límite de mi actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Nombre"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Fecha límite para la próxima actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Resumen de la próxima actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Siguiente tipo de actividad"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Número de acciones"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Número de errores"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Número de mensajes que requieren acción"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "Número de mensajes con error de entrega"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Privilegio de actividad de Openeducat"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Materias Optativas"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Usuario responsable"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Error de entrega de SMS"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"Estado basado en actividades.\n"
"Vencido: la fecha de vencimiento ya pasó\n"
"Hoy: la fecha de la actividad es hoy.\n"
"Planificado: Actividades futuras."
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_student
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
msgid "Student"
msgstr "Alumno"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Actividad estudiantil"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Dominio de identificación de estudiantes"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "Migración de estudiantes"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "Migración estudiantil"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "Estudiantes)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Términos"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Para lotear"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Al curso"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "Tipo de actividad de excepción registrada."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Usuario"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Válido para cursos"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Mensajes del sitio web"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Historial de comunicación del sitio web"

View File

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

View File

@@ -0,0 +1,450 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
msgid "Academic Year"
msgstr "Année académique"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Action nécessaire"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
msgid "Active"
msgstr "Actif"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Activités"
#. module: openeducat_activity
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
msgid "Activity"
msgstr "Activité"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Nombre d'activités"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Activité Exception Décoration"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Vue graphique d'activité"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
msgid "Activity Log"
msgstr "Journal d'activité"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
msgid "Activity Logs"
msgstr "Journaux d'activité"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Pivot d'activité"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "État d'activité"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity_type
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
msgid "Activity Type"
msgstr "Type d'activité"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Icône de type d'activité"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
msgid "Activity Types"
msgstr "Types d'activités"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "Le type d'activité doit être unique !"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
msgid "Archived"
msgstr "Archivé"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Nombre de pièces jointes"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same Program!"
msgstr ""
"Impossible de migrer, car les cours sélectionnés ne partagent pas le même "
"programme !"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same parent course!"
msgstr ""
"Impossible de migrer, car les cours sélectionnés ne partagent pas le même "
"cours parent !"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Impossible de migrer, procéder à une nouvelle admission"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Annuler"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Cours terminé ?"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
msgid "Created by"
msgstr "Créé par"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
msgid "Created on"
msgstr "Créé le"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
msgid "Date"
msgstr "Date"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
msgid "Description"
msgstr "Description"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
msgid "Display Name"
msgstr "Nom d'affichage"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Faculté"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Abonnés"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Abonnés (partenaires)"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr "Icône géniale de police, par exemple. tâches fat"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Avant"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Du cours"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "From Course must not be same as To Course!"
msgstr "Du cours ne doit pas être identique à To Course !"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "A un message"
#. module: openeducat_activity
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
msgid "Helps you manage your institutes different-different users."
msgstr "Vous aide à gérer les différents utilisateurs de votre institut."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
msgid "ID"
msgstr "IDENTIFIANT"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Icône"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Icône pour indiquer une activité d'exception."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr ""
"Si cette case est cochée, les nouveaux messages nécessitent votre attention."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr "Si coché, certains messages ont une erreur de livraison."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "Est un suiveur"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
msgid "Last Updated by"
msgstr "Dernière mise à jour par"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
msgid "Last Updated on"
msgstr "Dernière mise à jour le"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Directeur"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Erreur de livraison du message"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Messages"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Date limite de mon activité"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Nom"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Date limite de la prochaine activité"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Résumé de l'activité suivante"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Type d'activité suivant"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Nombre d'actions"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Nombre d'erreurs"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Nombre de messages nécessitant une action"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "Nombre de messages avec erreur de livraison"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Privilège dactivité Openeducat"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Sujets optionnels"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Utilisateur responsable"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Erreur de livraison SMS"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"Statut basé sur les activités\n"
"En retard : la date d'échéance est déjà dépassée\n"
"Aujourd'hui : la date de l'activité est aujourd'hui\n"
"Prévu : Activités futures."
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_student
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
msgid "Student"
msgstr "Étudiant"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Activité étudiante"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Domaine d'identification des étudiants"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "Étudiant migrer"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "Migration des étudiants"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "Étudiants)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Termes"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Vers un lot"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Cours"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "Type dactivité exceptionnelle enregistrée."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Utilisateur"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Valable pour les cours"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Messages du site Web"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Historique des communications du site Web"

View File

@@ -0,0 +1,450 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
msgid "Academic Year"
msgstr "Tahun Akademik"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Dibutuhkan Tindakan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
msgid "Active"
msgstr "Aktif"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Kegiatan"
#. module: openeducat_activity
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
msgid "Activity"
msgstr "Aktivitas"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Jumlah Aktivitas"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Dekorasi Pengecualian Aktivitas"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Tampilan Grafik Aktivitas"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
msgid "Activity Log"
msgstr "Catatan Aktivitas"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
msgid "Activity Logs"
msgstr "Log Aktivitas"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Poros Aktivitas"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Status Aktivitas"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity_type
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
msgid "Activity Type"
msgstr "Jenis Aktivitas"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Ikon Jenis Aktivitas"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
msgid "Activity Types"
msgstr "Jenis Aktivitas"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "Jenis aktivitas harus unik!"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
msgid "Archived"
msgstr "Diarsipkan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Jumlah Lampiran"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same Program!"
msgstr ""
"Tidak dapat bermigrasi, Karena kursus yang dipilih tidak berbagi Program "
"yang sama!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same parent course!"
msgstr ""
"Tidak dapat bermigrasi, Karena kursus yang dipilih tidak berbagi kursus "
"induk yang sama!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Tidak dapat bermigrasi, Lanjutkan untuk penerimaan baru"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Membatalkan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Kursus Selesai?"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
msgid "Created by"
msgstr "Dibuat oleh"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
msgid "Created on"
msgstr "Dibuat pada"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
msgid "Date"
msgstr "Tanggal"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
msgid "Description"
msgstr "Keterangan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
msgid "Display Name"
msgstr "Nama Tampilan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Fakultas"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Pengikut"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Pengikut (Mitra)"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr "Ikon font yang mengagumkan, mis. tugas fa"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Maju"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Dari Kursus"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "From Course must not be same as To Course!"
msgstr "From Course tidak boleh sama dengan To Course!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Memiliki Pesan"
#. module: openeducat_activity
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
msgid "Helps you manage your institutes different-different users."
msgstr ""
"Membantu Anda mengelola lembaga Anda dengan pengguna yang berbeda-beda."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
msgid "ID"
msgstr "PENGENAL"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Ikon"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Ikon untuk menunjukkan aktivitas pengecualian."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Jika dicentang, pesan baru memerlukan perhatian Anda."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr "Jika dicentang, beberapa pesan mengalami kesalahan pengiriman."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "Adalah Pengikut"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
msgid "Last Updated by"
msgstr "Terakhir Diperbarui oleh"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
msgid "Last Updated on"
msgstr "Terakhir Diperbarui pada"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Manajer"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Kesalahan Pengiriman Pesan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Pesan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Batas Waktu Aktivitas Saya"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Nama"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Batas Waktu Kegiatan Berikutnya"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Ringkasan Kegiatan Berikutnya"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Jenis Aktivitas Berikutnya"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Jumlah Tindakan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Jumlah kesalahan"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Jumlah pesan yang memerlukan tindakan"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "Jumlah pesan dengan kesalahan pengiriman"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Hak Istimewa Aktivitas Openeducat"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Mata Pelajaran Opsional"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Pengguna yang Bertanggung Jawab"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Kesalahan Pengiriman SMS"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"Status berdasarkan aktivitas\n"
"Terlambat: Tanggal jatuh tempo sudah lewat\n"
"Hari ini: Tanggal aktivitas adalah hari ini\n"
"Direncanakan: Kegiatan di masa depan."
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_student
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
msgid "Student"
msgstr "Murid"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Aktivitas Siswa"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Domain ID Siswa"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "Migrasi Siswa"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "Migrasi Mahasiswa"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "Siswa"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Ketentuan"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Untuk Batch"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Ke Kursus"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "Jenis aktivitas pengecualian yang tercatat."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Pengguna"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Berlaku Untuk Kursus"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Pesan Situs Web"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Riwayat komunikasi situs web"

View File

@@ -0,0 +1,449 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
msgid "Academic Year"
msgstr "Anno accademico"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Azione necessaria"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
msgid "Active"
msgstr "Attivo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Attività"
#. module: openeducat_activity
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
msgid "Activity"
msgstr "Attività"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Conteggio attività"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Decorazione delle eccezioni di attività"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Visualizzazione grafico delle attività"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
msgid "Activity Log"
msgstr "Registro delle attività"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
msgid "Activity Logs"
msgstr "Registri delle attività"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Perno di attività"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Stato attività"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity_type
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
msgid "Activity Type"
msgstr "Tipo di attività"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Icona del tipo di attività"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
msgid "Activity Types"
msgstr "Tipi di attività"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "Il tipo di attività deve essere unico!"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
msgid "Archived"
msgstr "Archiviato"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Conteggio allegati"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same Program!"
msgstr ""
"Impossibile eseguire la migrazione, poiché i corsi selezionati non "
"condividono lo stesso programma!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same parent course!"
msgstr ""
"Impossibile migrare, poiché i corsi selezionati non condividono lo stesso "
"corso principale!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Impossibile migrare, procedere alla nuova ammissione"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Cancellare"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Corso completato?"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
msgid "Created by"
msgstr "Creato da"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
msgid "Created on"
msgstr "Creato il"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
msgid "Date"
msgstr "Data"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
msgid "Description"
msgstr "Descrizione"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
msgid "Display Name"
msgstr "Nome da visualizzare"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Facoltà"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Seguaci"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Follower (partner)"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr "Icona fantastica del carattere, ad es. compiti fa"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Inoltrare"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Da Corso"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "From Course must not be same as To Course!"
msgstr "Dal corso non deve essere uguale a Al corso!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Ha un messaggio"
#. module: openeducat_activity
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
msgid "Helps you manage your institutes different-different users."
msgstr "Ti aiuta a gestire i diversi utenti dei tuoi istituti."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
msgid "ID"
msgstr "ID"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Icona"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Icona per indicare un'attività di eccezione."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Se selezionato, i nuovi messaggi richiedono la tua attenzione."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr "Se selezionato, alcuni messaggi presentano un errore di consegna."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "È seguace"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
msgid "Last Updated by"
msgstr "Ultimo aggiornamento di"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
msgid "Last Updated on"
msgstr "Ultimo aggiornamento il"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Manager"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Errore di consegna del messaggio"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Messaggi"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Scadenza della mia attività"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Nome"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Prossima scadenza delle attività"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Riepilogo attività successiva"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Tipo di attività successiva"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Numero di azioni"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Numero di errori"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Numero di messaggi che richiedono un'azione"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "Numero di messaggi con errore di consegna"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Privilegio di attività Openeducat"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Materie facoltative"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Utente responsabile"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Errore di consegna dell'SMS"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"Stato in base alle attività\n"
"In ritardo: la data di scadenza è già trascorsa\n"
"Oggi: la data dell'attività è oggi\n"
"Pianificato: attività future."
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_student
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
msgid "Student"
msgstr "Studente"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Attività degli studenti"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Dominio ID studente"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "Studenti Migrati"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "Migrazione degli studenti"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "Studente(i)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Termini"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "In batch"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Al corso"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "Tipo di attività di eccezione registrata."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Utente"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Valido per i corsi"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Messaggi del sito web"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Cronologia della comunicazione del sito web"

View File

@@ -0,0 +1,445 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
msgid "Academic Year"
msgstr "Akadēmiskais gads"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Nepieciešama darbība"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
msgid "Active"
msgstr "Aktīvs"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Darbības"
#. module: openeducat_activity
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
msgid "Activity"
msgstr "Aktivitāte"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Aktivitāšu skaits"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Darbības izņēmuma dekorēšana"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Aktivitāšu diagrammas skats"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
msgid "Activity Log"
msgstr "Darbību žurnāls"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
msgid "Activity Logs"
msgstr "Darbību žurnāli"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Aktivitāte Pivot"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Darbības stāvoklis"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity_type
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
msgid "Activity Type"
msgstr "Darbības veids"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Darbības veida ikona"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
msgid "Activity Types"
msgstr "Aktivitāšu veidi"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "Aktivitātes veidam ir jābūt unikālam!"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
msgid "Archived"
msgstr "Arhivēts"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Pielikumu skaits"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same Program!"
msgstr "Nevar migrēt, jo atlasītajiem kursiem nav kopīga programma!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same parent course!"
msgstr "Nevar migrēt, jo atlasītajiem kursiem nav kopīgs vecāku kurss!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Nevar migrēt. Turpiniet jaunu uzņemšanu"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Atcelt"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Kurss pabeigts?"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
msgid "Created by"
msgstr "Izveidoja"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
msgid "Created on"
msgstr "Izveidots"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
msgid "Date"
msgstr "Datums"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
msgid "Description"
msgstr "Apraksts"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
msgid "Display Name"
msgstr "Parādāmais nosaukums"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Fakultāte"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Sekotāji"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Sekotāji (partneri)"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr "Lieliska fonta ikona, piem. fa-uzdevumi"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Uz priekšu"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "No kursa"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "From Course must not be same as To Course!"
msgstr "No kursa nedrīkst būt tas pats, kas uz kursu!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Ir ziņa"
#. module: openeducat_activity
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
msgid "Helps you manage your institutes different-different users."
msgstr "Palīdz pārvaldīt savus institūtus dažādus lietotājus."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
msgid "ID"
msgstr "ID"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Ikona"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Ikona, kas norāda izņēmuma darbību."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Ja atzīmēta, jums jāpievērš uzmanība jauniem ziņojumiem."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr "Ja atzīmēta, dažos ziņojumos ir piegādes kļūda."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "Ir sekotājs"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
msgid "Last Updated by"
msgstr "Pēdējo reizi atjaunināja"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
msgid "Last Updated on"
msgstr "Pēdējo reizi atjaunināts"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Pārvaldnieks"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Ziņojuma piegādes kļūda"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Ziņojumi"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Manas aktivitātes termiņš"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Vārds"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Nākamās aktivitātes termiņš"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Nākamās aktivitātes kopsavilkums"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Nākamais darbības veids"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Darbību skaits"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Kļūdu skaits"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "To ziņojumu skaits, kuriem nepieciešama darbība"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "Ziņojumu skaits ar piegādes kļūdu"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Openeducat aktivitātes privilēģija"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Izvēles priekšmeti"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Atbildīgs lietotājs"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "SMS piegādes kļūda"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"Statuss, pamatojoties uz aktivitātēm\n"
"Nokavēts: izpildes termiņš jau ir pagājis\n"
"Šodien: aktivitātes datums ir šodien\n"
"Plānots: Nākotnes aktivitātes."
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_student
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
msgid "Student"
msgstr "Students"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Studentu darbība"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Studentu ID domēns"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "Studentu migrācija"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "Studentu migrācija"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "Students(-i)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Noteikumi"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Uz partiju"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Uz Kursu"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "Reģistrētās izņēmuma darbības veids."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Lietotājs"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Derīgs uz kursiem"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Vietnes ziņojumi"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Vietnes komunikācijas vēsture"

View File

@@ -0,0 +1,450 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
msgid "Academic Year"
msgstr "Academisch Jaar"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Actie nodig"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
msgid "Active"
msgstr "Actief"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Activiteiten"
#. module: openeducat_activity
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
msgid "Activity"
msgstr "Activiteit"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Aantal activiteiten"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Activiteit Uitzondering Decoratie"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Activiteitsgrafiekweergave"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
msgid "Activity Log"
msgstr "Activiteitenlogboek"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
msgid "Activity Logs"
msgstr "Activiteitenlogboeken"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Activiteitsdraaipunt"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Activiteitsstatus"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity_type
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
msgid "Activity Type"
msgstr "Activiteitstype"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Pictogram voor activiteitstype"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
msgid "Activity Types"
msgstr "Activiteitstypen"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "Activiteitstype moet uniek zijn!"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
msgid "Archived"
msgstr "Gearchiveerd"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Aantal bijlagen"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same Program!"
msgstr ""
"Kan niet migreren, omdat geselecteerde cursussen niet hetzelfde programma "
"delen!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same parent course!"
msgstr ""
"Kan niet migreren, omdat geselecteerde cursussen niet dezelfde bovenliggende"
" cursus delen!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Kan niet migreren. Ga verder voor nieuwe toelating"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Annuleren"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Cursus voltooid?"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
msgid "Created by"
msgstr "Gemaakt door"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
msgid "Created on"
msgstr "Gemaakt op"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
msgid "Date"
msgstr "Datum"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
msgid "Description"
msgstr "Beschrijving"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
msgid "Display Name"
msgstr "Weergavenaam"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Faculteit"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Volgers"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Volgers (partners)"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr "Lettertype geweldig pictogram, b.v. fa-taken"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Vooruit"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Van cursus"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "From Course must not be same as To Course!"
msgstr "Van koers mag niet hetzelfde zijn als Naar koers!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Heeft bericht"
#. module: openeducat_activity
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
msgid "Helps you manage your institutes different-different users."
msgstr ""
"Helpt u bij het beheren van uw instellingen met verschillende gebruikers."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
msgid "ID"
msgstr "Identiteitskaart"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Icon"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Pictogram om een uitzonderingsactiviteit aan te geven."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Indien aangevinkt, vereisen nieuwe berichten uw aandacht."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr "Indien aangevinkt, hebben sommige berichten een bezorgfout."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "Is volger"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
msgid "Last Updated by"
msgstr "Laatst bijgewerkt door"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
msgid "Last Updated on"
msgstr "Laatst bijgewerkt op"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Manager"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Fout bij bezorging van bericht"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Berichten"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Mijn activiteitsdeadline"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Naam"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Deadline volgende activiteit"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Samenvatting van volgende activiteit"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Volgende activiteitstype"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Aantal acties"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Aantal fouten"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Aantal berichten waarvoor actie vereist is"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "Aantal berichten met bezorgfout"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Openeducat-activiteitenprivilege"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Optionele onderwerpen"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Verantwoordelijke gebruiker"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "SMS-bezorgfout"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"Status op basis van activiteiten\n"
"Te laat: De vervaldatum is al verstreken\n"
"Vandaag: de activiteitsdatum is vandaag\n"
"Gepland: toekomstige activiteiten."
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_student
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
msgid "Student"
msgstr "Student"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Studentenactiviteit"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Domein Student-ID's"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "Studenten migreren"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "Migratie van studenten"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "Student(en)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Voorwaarden"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Naar batch"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Naar cursus"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "Type van de geregistreerde uitzonderingsactiviteit."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Gebruiker"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Geldig voor cursussen"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Websiteberichten"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Communicatiegeschiedenis van websites"

View File

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

View File

@@ -0,0 +1,450 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
msgid "Academic Year"
msgstr "Ano Letivo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Ação necessária"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
msgid "Active"
msgstr "Ativo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Atividades"
#. module: openeducat_activity
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
msgid "Activity"
msgstr "Atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Contagem de atividades"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Decoração de exceção de atividade"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Visualização do gráfico de atividades"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
msgid "Activity Log"
msgstr "Registro de atividades"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
msgid "Activity Logs"
msgstr "Registros de atividades"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Pivô de atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Estado da atividade"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity_type
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
msgid "Activity Type"
msgstr "Tipo de atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Ícone de tipo de atividade"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
msgid "Activity Types"
msgstr "Tipos de atividades"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "O tipo de atividade deve ser único!"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
msgid "Archived"
msgstr "Arquivado"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Contagem de anexos"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same Program!"
msgstr ""
"Não é possível migrar, pois os cursos selecionados não compartilham o mesmo "
"programa!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same parent course!"
msgstr ""
"Não é possível migrar, pois os cursos selecionados não compartilham o mesmo "
"curso pai!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Não é possível migrar. Prossiga para nova admissão"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Cancelar"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Curso concluído?"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
msgid "Created by"
msgstr "Criado por"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
msgid "Created on"
msgstr "Criado em"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
msgid "Date"
msgstr "Data"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
msgid "Description"
msgstr "Descrição"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
msgid "Display Name"
msgstr "Nome de exibição"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Faculdade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Seguidores"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Seguidores (parceiros)"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr "Ícone incrível da fonte, por exemplo. tarefas fa"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Avançar"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Do curso"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "From Course must not be same as To Course!"
msgstr "From Course não deve ser igual a To Course!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Tem mensagem"
#. module: openeducat_activity
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
msgid "Helps you manage your institutes different-different users."
msgstr ""
"Ajuda você a gerenciar usuários diferentes e diferentes de seus institutos."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
msgid "ID"
msgstr "EU IA"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Ícone"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Ícone para indicar uma atividade de exceção."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Se marcada, novas mensagens requerem sua atenção."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr "Se marcada, algumas mensagens apresentam um erro de entrega."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "É seguidor"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
msgid "Last Updated by"
msgstr "Última atualização por"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
msgid "Last Updated on"
msgstr "Última atualização em"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Gerente"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Erro na entrega de mensagens"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Mensagens"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Prazo da minha atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Nome"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Prazo da próxima atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Resumo da próxima atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Próximo tipo de atividade"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Número de ações"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Número de erros"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Número de mensagens que exigem ação"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "Número de mensagens com erro de entrega"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Privilégio de atividade Openeducat"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Disciplinas Opcionais"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Usuário Responsável"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Erro de entrega de SMS"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"Status baseado em atividades\n"
"Atrasado: a data de vencimento já passou\n"
"Hoje: a data da atividade é hoje\n"
"Planejado: Atividades futuras."
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_student
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
msgid "Student"
msgstr "Estudante"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Atividade do Aluno"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Domínio de IDs de Estudante"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "Migração de Alunos"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "Migração de Estudantes"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "Aluno(s)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Termos"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Para lote"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Para o curso"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "Tipo de atividade de exceção registrada."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Usuário"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Válido para cursos"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Mensagens do site"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Histórico de comunicação do site"

View File

@@ -0,0 +1,450 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
msgid "Academic Year"
msgstr "Учебный год"
#. 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 "Значок шрифта потрясающий, например. fa-задачи"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Вперед"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Из курса"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "From Course must not be same as To Course!"
msgstr "From Course не должен совпадать с To Course!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Имеет сообщение"
#. module: openeducat_activity
#: model:ir.module.category,description:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,description:openeducat_activity.module_category_openeducat_activity
msgid "Helps you manage your institutes different-different users."
msgstr "Помогает вам управлять разными пользователями ваших институтов."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__id
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__id
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__id
msgid "ID"
msgstr "ИДЕНТИФИКАТОР"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Икона"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Значок, обозначающий активность исключения."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Если этот флажок установлен, новые сообщения требуют вашего внимания."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr ""
"Если этот флажок установлен, некоторые сообщения имеют ошибку доставки."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "является последователем"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_uid
msgid "Last Updated by"
msgstr "Последнее обновление:"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__write_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__write_date
msgid "Last Updated on"
msgstr "Последнее обновление:"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Менеджер"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Ошибка доставки сообщения"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Сообщения"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Срок моей активности"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Имя"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Срок следующего действия"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Сводка следующего действия"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Следующий тип активности"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Количество действий"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Количество ошибок"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Количество сообщений, требующих действий"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "Количество сообщений с ошибкой доставки"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Привилегии активности Openeducat"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Дополнительные предметы"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Ответственный пользователь"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Ошибка доставки СМС"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"Статус на основе деятельности\n"
"Просрочено: срок сдачи уже прошел.\n"
"Сегодня: дата активности сегодня.\n"
"Планируется: будущие мероприятия."
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_student
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
msgid "Student"
msgstr "Студент"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Студенческая деятельность"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Домен студенческих идентификаторов"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "Студенческая миграция"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "Студенческая миграция"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "Студент(ы)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Условия"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "В пакетную обработку"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Курс"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "Тип зарегистрированной исключительной активности."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Пользователь"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Действительно для курсов"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Сообщения веб-сайта"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "История общения на сайте"

View File

@@ -0,0 +1,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 "ไอคอนตัวอักษรที่ยอดเยี่ยมเช่น fa-งาน"
#. 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 "ข้อผิดพลาดในการส่ง 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 ""
"สถานะตามกิจกรรม\n"
"เกินกำหนด: เลยวันครบกำหนดไปแล้ว\n"
"วันนี้: วันที่กิจกรรมคือวันนี้\n"
"แผนงาน: กิจกรรมในอนาคต"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_student
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__student_id
msgid "Student"
msgstr "นักเรียน"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "กิจกรรมนักศึกษา"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "โดเมนรหัสนักศึกษา"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "นักเรียนโยกย้าย"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "การย้ายถิ่นฐานของนักศึกษา"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "นักเรียน)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "เงื่อนไข"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "ถึงแบทช์"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "ไปที่หลักสูตร"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "ประเภทของกิจกรรมข้อยกเว้นในเรกคอร์ด"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "ผู้ใช้"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "ใช้ได้กับหลักสูตร"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "ข้อความเว็บไซต์"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "ประวัติการสื่อสารเว็บไซต์"

View File

@@ -0,0 +1,421 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-29 10:20+0000\n"
"PO-Revision-Date: 2025-04-29 10:20+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_op_activity__message_needaction
msgid "Action Needed"
msgstr "Hành động cần thiết"
#. 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 "Tích cực"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Các hoạt động"
#. 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 "Hoạt động"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Số lượng hoạt động"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Hoạt động trang trí ngoại lệ"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Chế độ xem biểu đồ hoạt động"
#. 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 "Nhật ký hoạt động"
#. 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 "Nhật ký hoạt động"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Xoay vòng hoạt động"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Trạng thái hoạt động"
#. 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 "Loại hoạt động"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Biểu tượng loại hoạt động"
#. 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 "Loại hoạt động"
#. 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 "Lưu trữ"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Số lượng đính kèm"
#. 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 ""
"Không thể di chuyển, vì các khóa học được chọn không chia sẻ cùng một khóa "
"học phụ huynh!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Không thể di chuyển, tiến hành nhập học mới"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Hủy bỏ"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Khóa học đã hoàn thành?"
#. 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 "Được tạo bởi"
#. 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 "Được tạo ra trên"
#. 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 "Ngày"
#. 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 "Sự miêu tả"
#. 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_student_migrate__display_name
msgid "Display Name"
msgstr "Tên hiển thị"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Giảng viên"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Người theo dõi"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Người theo dõi (Đối tác)"
#. 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 "Biểu tượng tuyệt vời, ví dụ: Nhiệm vụ FA"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Phía trước"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Từ khóa học"
#. 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 "Từ khóa học không được giống như khóa học!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Có tin nhắn"
#. 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 "Giúp bạn quản lý người dùng khác nhau của Viện."
#. 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_student_migrate__id
msgid "ID"
msgstr "NHẬN DẠNG"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Biểu tượng"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Biểu tượng để chỉ ra một hoạt động ngoại lệ."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Nếu được kiểm tra, tin nhắn mới đòi hỏi sự chú ý của bạ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 "Nếu được kiểm tra, một số tin nhắn có lỗi giao hàng."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "Là người theo dõi"
#. 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 "Cập nhật lần cuối bởi"
#. 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 "Cập nhật lần cuối"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Giám đốc"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Lỗi gửi tin nhắn"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Tin nhắn"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Migration From {} to Completed Course"
msgstr "Di chuyển từ {} sang khóa học đã hoàn thành"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Migration from {} to {}"
msgstr "Di chuyển từ {} sang {}"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Hạn chót hoạt động của tôi"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Tên"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Hạn chót hoạt động tiếp theo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Tóm tắt hoạt động tiếp theo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Loại hoạt động tiếp theo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Số lượng hành động"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Số lỗi"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Số lượng tin nhắn yêu cầu hành động"
#. 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 "Số lượng tin nhắn có lỗi gửi"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Chủ đề tùy chọn"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Người dùng có trách nhiệm"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Lỗi phân phối 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 ""
"Tình trạng dựa trên các hoạt động\n"
"Quá hạn: Ngày đến hạn đã được thông qua\n"
"Hôm nay: Ngày hoạt động hôm nay\n"
"Kế hoạch: Các hoạt động trong tương lai."
#. 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 "Học sinh"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Hoạt động của sinh viên"
#. 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 "Sinh viên di cư"
#. 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 "Sinh viên di cư"
#. 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 "(Các) sinh viên"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Đến lô"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Để khóa học"
#. 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 "Loại hoạt động ngoại lệ trong hồ sơ."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "Người dùng"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Tin nhắn trang web"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Lịch sử giao tiếp trang web"

View File

@@ -0,0 +1,449 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
msgid "Academic Year"
msgstr "Năm học"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "Hành động cần thiết"
#. 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 "Tích cực"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "Các hoạt động"
#. 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 "Hoạt động"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "Số lượng hoạt động"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "Trang trí ngoại lệ hoạt động"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "Chế độ xem biểu đồ hoạt động"
#. 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 "Nhật ký hoạt động"
#. 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 "Nhật ký hoạt động"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "Xoay vòng hoạt động"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "Trạng thái hoạt động"
#. 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 "Loại hoạt động"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "Biểu tượng loại hoạt động"
#. 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 "Loại hoạt động"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "Loại hoạt động phải là duy nhất!"
#. 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 "Đã lưu trữ"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "Số lượng tệp đính kèm"
#. 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 ""
"Không thể di chuyển vì các khóa học đã chọn không chia sẻ cùng một Chương "
"trình!"
#. 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 ""
"Không thể di chuyển, vì các khóa học đã chọn không chia sẻ cùng khóa học "
"gốc!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "Không thể di chuyển, Tiếp tục nhập học mới"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "Hủy bỏ"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "Khóa học đã hoàn thành?"
#. 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 "Tạo bởi"
#. 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 "Được tạo vào"
#. 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 "Ngày"
#. 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 "Sự miêu tả"
#. 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 "Tên hiển thị"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "Khoa"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "Người theo dõi"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "Người theo dõi (Đối tác)"
#. 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 "Phông chữ biểu tượng tuyệt vời, ví dụ: nhiệm vụ fa"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Forward"
msgstr "Phía trước"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_from_id
msgid "From Course"
msgstr "Từ khóa học"
#. 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 "Từ Khóa học không được giống với Đến Khóa học!"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__has_message
msgid "Has Message"
msgstr "Có tin nhắn"
#. 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 "Giúp bạn quản lý viện của mình với những người dùng khác nhau."
#. 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 "NHẬN DẠNG"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon"
msgstr "Biểu tượng"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Biểu tượng để biểu thị một hoạt động ngoại lệ."
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction
msgid "If checked, new messages require your attention."
msgstr "Nếu được chọn, các tin nhắn mới cần bạn chú ý."
#. 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 "Nếu được chọn, một số tin nhắn có lỗi gửi."
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_is_follower
msgid "Is Follower"
msgstr "là người theo dõi"
#. 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 "Cập nhật lần cuối bởi"
#. 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 "Cập nhật lần cuối vào"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_manager
msgid "Manager"
msgstr "Giám đốc"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error
msgid "Message Delivery error"
msgstr "Lỗi gửi tin nhắn"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_ids
msgid "Messages"
msgstr "Tin nhắn"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Hạn chót hoạt động của tôi"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__name
msgid "Name"
msgstr "Tên"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Hạn chót hoạt động tiếp theo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_summary
msgid "Next Activity Summary"
msgstr "Tóm tắt hoạt động tiếp theo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_id
msgid "Next Activity Type"
msgstr "Loại hoạt động tiếp theo"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of Actions"
msgstr "Số lượng hành động"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_error_counter
msgid "Number of errors"
msgstr "Số lỗi"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__message_needaction_counter
msgid "Number of messages requiring action"
msgstr "Số lượng tin nhắn yêu cầu hành động"
#. 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 "Số lượng tin nhắn có lỗi gửi"
#. module: openeducat_activity
#: model:res.groups.privilege,name:openeducat_activity.module_activity_privilege
msgid "Openeducat Activity Privilege"
msgstr "Đặc quyền hoạt động Openeducat"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__optional_sub
msgid "Optional Subjects"
msgstr "Môn học tùy chọn"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_user_id
msgid "Responsible User"
msgstr "Người dùng có trách nhiệm"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Lỗi gửi 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 ""
"Trạng thái dựa trên hoạt động\n"
"Quá hạn: Đã qua ngày đáo hạn\n"
"Hôm nay: Ngày hoạt động là hôm nay\n"
"Dự kiến: Hoạt động sắp tới."
#. 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 "Học sinh"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity
msgid "Student Activity"
msgstr "Hoạt động của sinh viên"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids_domain
msgid "Student Ids Domain"
msgstr "Tên miền ID sinh viên"
#. 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 "Sinh viên di cư"
#. 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 "Di cư của sinh viên"
#. 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 "(Các) sinh viên"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "Điều khoản"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "Để hàng loạt"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "Đến khóa học"
#. 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 "Loại hoạt động ngoại lệ được ghi lại."
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "người dùng"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "Hợp lệ cho các khóa học"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "Tin nhắn trang web"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "Lịch sử giao tiếp trang web"

View File

@@ -0,0 +1,445 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 19.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-31 05:01+0000\n"
"PO-Revision-Date: 2025-10-31 05:01+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__year_id
msgid "Academic Year"
msgstr "学年"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_needaction
msgid "Action Needed"
msgstr "需要采取行动"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__active
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__active
msgid "Active"
msgstr "积极的"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_ids
msgid "Activities"
msgstr "活动"
#. module: openeducat_activity
#: model:ir.module.category,name:openeducat_activity.module_category_all_op_activity
#: model:ir.module.category,name:openeducat_activity.module_category_openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.activity_smart_button
msgid "Activity"
msgstr "活动"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_count
msgid "Activity Count"
msgstr "活动计数"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr "活动异常装饰"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_graph
msgid "Activity Graph View"
msgstr "活动图视图"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__activity_log
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_tree
msgid "Activity Log"
msgstr "活动日志"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_sub
msgid "Activity Logs"
msgstr "活动日志"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_pivot
msgid "Activity Pivot"
msgstr "活动枢轴"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_state
msgid "Activity State"
msgstr "活动状态"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_op_activity_type
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__type_id
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_tree
msgid "Activity Type"
msgstr "活动类型"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__activity_type_icon
msgid "Activity Type Icon"
msgstr "活动类型图标"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_op_activity_type_view
#: model:ir.ui.menu,name:openeducat_activity.menu_op_activity_type_sub
msgid "Activity Types"
msgstr "活动类型"
#. module: openeducat_activity
#: model:ir.model.constraint,message:openeducat_activity.constraint_op_activity_type_unique_name
msgid "Activity type must be unique!"
msgstr "活动类型必须是唯一的!"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_search
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_form
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_type_search
msgid "Archived"
msgstr "已存档"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_attachment_count
msgid "Attachment Count"
msgstr "附件计数"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same Program!"
msgstr "无法迁移,因为所选课程不共享相同的程序!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, As selected courses don't share same parent course!"
msgstr "无法迁移,因为所选课程不共享同一父课程!"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Can't migrate, Proceed for new admission"
msgstr "无法迁移,继续新入学"
#. module: openeducat_activity
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Cancel"
msgstr "取消"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_completed
msgid "Course Completed?"
msgstr "课程完成了吗?"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_uid
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_uid
msgid "Created by"
msgstr "创建者:"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__create_date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__create_date
msgid "Created on"
msgstr "创建于"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__date
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__date
msgid "Date"
msgstr "日期"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__description
#: model_terms:ir.ui.view,arch_db:openeducat_activity.view_op_activity_form
msgid "Description"
msgstr "描述"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity_type__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_op_student__display_name
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__display_name
msgid "Display Name"
msgstr "显示名称"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__faculty_id
msgid "Faculty"
msgstr "学院"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_follower_ids
msgid "Followers"
msgstr "追随者"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__message_partner_ids
msgid "Followers (Partners)"
msgstr "关注者(合作伙伴)"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr "字体很棒的图标例如fa 任务"
#. 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 "ID"
#. 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 "学生 ID 域"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "学生移民"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "学生移民"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "学生)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "条款"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "至批次"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "前往课程"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "记录的异常活动的类型。"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "用户"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "适用于课程"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "网站留言"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "网站通讯历史"

View File

@@ -0,0 +1,419 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * openeducat_activity
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-29 10:20+0000\n"
"PO-Revision-Date: 2025-04-29 10:20+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_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_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 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_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 "字體很棒的圖標例如FA任務"
#. 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_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 "圖示"
#. 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
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Migration From {} to Completed Course"
msgstr "從{}遷移到完整的課程"
#. module: openeducat_activity
#. odoo-python
#: code:addons/openeducat_activity/wizard/student_migrate_wizard.py:0
msgid "Migration from {} to {}"
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: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 "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 ""
"基於活動的狀態\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,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__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_op_activity__website_message_ids
msgid "Website Messages"
msgstr "網站消息"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "網站通信歷史"

View File

@@ -0,0 +1,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 "字體很棒的圖標例如fa 任務"
#. 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 "ID"
#. 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 "學生 ID 域"
#. module: openeducat_activity
#: model:ir.model,name:openeducat_activity.model_student_migrate
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student Migrate"
msgstr "學生移民"
#. module: openeducat_activity
#: model:ir.actions.act_window,name:openeducat_activity.act_open_student_migrate_view
#: model:ir.ui.menu,name:openeducat_activity.menu_student_migrate
msgid "Student Migration"
msgstr "學生移民"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__student_ids
#: model_terms:ir.ui.view,arch_db:openeducat_activity.student_migrate_form
msgid "Student(s)"
msgstr "學生)"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__term_id
msgid "Terms"
msgstr "條款"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__batch_id
msgid "To Batch"
msgstr "至批次"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__course_to_id
msgid "To Course"
msgstr "前往課程"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "記錄的異常活動的類型。"
#. module: openeducat_activity
#: model:res.groups,name:openeducat_activity.group_activity_user
msgid "User"
msgstr "用戶"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_student_migrate__valid_to_course_ids
msgid "Valid To Courses"
msgstr "適用於課程"
#. module: openeducat_activity
#: model:ir.model.fields,field_description:openeducat_activity.field_op_activity__website_message_ids
msgid "Website Messages"
msgstr "網站留言"
#. module: openeducat_activity
#: model:ir.model.fields,help:openeducat_activity.field_op_activity__website_message_ids
msgid "Website communication history"
msgstr "網站通訊歷史"

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<menuitem id="menu_op_activity_sub"
name="Activity Logs"
parent="openeducat_core.menu_op_general_student"
sequence="10"
action="act_open_op_activity_view"
groups="openeducat_activity.group_activity_manager,openeducat_activity.group_activity_user"/>
<menuitem id="menu_op_activity_type_sub"
name="Activity Types"
parent="openeducat_core.menu_op_student_config"
sequence="10"
action="act_open_op_activity_type_view"
groups="openeducat_activity.group_activity_manager"/>
<menuitem id="menu_student_migrate"
name="Student Migration"
parent="openeducat_core.menu_op_general_main"
sequence="30"
action="act_open_student_migrate_view"
groups="openeducat_activity.group_activity_manager,openeducat_activity.group_activity_user"/>
</odoo>

View File

@@ -0,0 +1,23 @@
###############################################################################
#
# 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 activity
from . import activity_type
from . import student

View File

@@ -0,0 +1,41 @@
###############################################################################
#
# 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 odoo import fields, models
class OpActivity(models.Model):
_name = "op.activity"
_description = "Student Activity"
_rec_name = "student_id"
_inherit = ["mail.thread", "mail.activity.mixin"]
def _default_faculty(self):
return self.env['op.faculty'].search([
('user_id', '=', self.env.uid)
], limit=1) or False
student_id = fields.Many2one('op.student', 'Student', required=True)
faculty_id = fields.Many2one('op.faculty', string='Faculty',
default=lambda self: self._default_faculty())
type_id = fields.Many2one('op.activity.type', 'Activity Type')
description = fields.Text('Description')
date = fields.Date('Date', default=fields.Date.today())
active = fields.Boolean(default=True)

View File

@@ -0,0 +1,32 @@
###############################################################################
#
# 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 odoo import fields, models
class OpActivityType(models.Model):
_name = "op.activity.type"
_description = "Activity Type"
name = fields.Char('Name', size=128, required=True)
active = fields.Boolean(default=True)
_unique_name = models.Constraint(
'unique(name)',
'Activity type must be unique!')

View File

@@ -0,0 +1,40 @@
###############################################################################
#
# 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 odoo import fields, models
class OpStudent(models.Model):
_inherit = "op.student"
activity_log = fields.One2many('op.activity', 'student_id',
string='Activity Log')
activity_count = fields.Integer(compute='_compute_count')
def get_activity(self):
action = self.env.ref('openeducat_activity.'
'act_open_op_activity_view').sudo().read()[0]
action['domain'] = [('student_id', 'in', self.ids)]
return action
def _compute_count(self):
for record in self:
record.activity_count = self.env['op.activity'].search_count(
[('student_id', 'in', self.ids)])

View File

@@ -0,0 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_op_activity_student,name_op_activity_student,model_op_activity,openeducat_activity.group_activity_manager,1,1,1,1
access_op_activity_op_faculty,name_op_activity_op_faculty,model_op_activity,openeducat_activity.group_activity_user,1,1,0,0
access_op_activity_type_op_faculty,name_op_activity_type_op_faculty,model_op_activity_type,openeducat_activity.group_activity_user,1,0,0,0
access_op_activity_type_back_office_admin,name_op_activity_type_back_office_admin,model_op_activity_type,openeducat_activity.group_activity_manager,1,1,1,1
access_student_migrate_user,name_student_migrate_user,model_student_migrate,openeducat_activity.group_activity_user,1,1,1,0
access_student_migrate,name_student_migrate,model_student_migrate,openeducat_activity.group_activity_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_op_activity_student name_op_activity_student model_op_activity openeducat_activity.group_activity_manager 1 1 1 1
3 access_op_activity_op_faculty name_op_activity_op_faculty model_op_activity openeducat_activity.group_activity_user 1 1 0 0
4 access_op_activity_type_op_faculty name_op_activity_type_op_faculty model_op_activity_type openeducat_activity.group_activity_user 1 0 0 0
5 access_op_activity_type_back_office_admin name_op_activity_type_back_office_admin model_op_activity_type openeducat_activity.group_activity_manager 1 1 1 1
6 access_student_migrate_user name_student_migrate_user model_student_migrate openeducat_activity.group_activity_user 1 1 1 0
7 access_student_migrate name_student_migrate model_student_migrate openeducat_activity.group_activity_manager 1 1 1 1

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record model="ir.module.category" id="module_category_all_op_activity">
<field name="name">Activity</field>
<field name="description">Helps you manage your institutes different-different users.</field>
<field name="sequence">101</field>
<field name="parent_id" eval="False"/>
</record>
<record id="module_category_openeducat_activity" model="ir.module.category">
<field name="name">Activity</field>
<field name="description">Helps you manage your institutes different-different users.</field>
<field name="parent_id" ref="openeducat_activity.module_category_all_op_activity"/>
<field name="sequence">1</field>
</record>
<record id="module_activity_privilege" model="res.groups.privilege">
<field name="name">Openeducat Activity Privilege</field>
<field name="category_id" ref="module_category_openeducat_activity"/>
</record>
<record id="group_activity_user" model="res.groups">
<field name="name">User</field>
<field name="privilege_id" ref="module_activity_privilege"/>
<field name="user_ids" eval="[(4, ref('base.user_admin'))]"/>
<field name="implied_ids"
eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="group_activity_manager" model="res.groups">
<field name="name">Manager</field>
<field name="privilege_id" ref="module_activity_privilege"/>
<field name="user_ids" eval="[(4, ref('base.user_admin'))]"/>
<field name="implied_ids"
eval="[(4, ref('openeducat_activity.group_activity_user'))]"/>
</record>
<record model="ir.rule" id="student_activity_log_rule">
<field name="name">Student Activity Logs</field>
<field name="model_id" ref="model_op_activity"/>
<field name="groups"
eval="[(4, ref('openeducat_activity.group_activity_manager'))]"/>
<field name="domain_force">['|', ('student_id.user_id','=',user.id), ('student_id.user_id','in',user.child_ids.ids)]
</field>
</record>
<record model="ir.rule" id="faculty_activity_log_rule">
<field name="name">Faculty Activity Logs</field>
<field name="model_id" ref="model_op_activity"/>
<field name="groups"
eval="[(4, ref('openeducat_activity.group_activity_user'))]"/>
<field name="domain_force">['|', ('faculty_id.user_id','=',user.id), ('faculty_id.user_id','in',user.child_ids.ids)]
</field>
</record>
<record model="ir.rule" id="back_office_activity_log_rule">
<field name="name">Back Office Activity Logs</field>
<field name="model_id" ref="model_op_activity"/>
<field name="groups"
eval="[(4, ref('openeducat_activity.group_activity_manager'))]"/>
<field name="domain_force">[(1,'=',1)]</field>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
]>
<svg version="1.1" id="Layer_3" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.9 122.9"
style="enable-background:new 0 0 122.9 122.9;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill:#FBB130;}
.st2{fill:url(#SVGID_1_);fill-opacity:0.2;}
.st3{fill:#00A079;}
.st4{fill:url(#SVGID_2_);fill-opacity:0.2;}
.st5{fill:#FFFFFF;}
</style>
<metadata id="CorelCorpID_0Corel-Layer">
<sfw xmlns="&ns_sfw;">
<slices></slices>
<sliceSourceBounds bottomLeftOrigin="true" height="122.9" width="122.9" x="0" y="0"></sliceSourceBounds>
</sfw>
</metadata>
<rect y="0" class="st0" width="122.9" height="122.9"/>
<g id="Hotel_1_">
<path class="st1" d="M103.4,107h-28l-7-21l7-28h22c3.3,0,6,2.7,6,6V107z"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="68.45" y1="41.3898" x2="103.4903" y2="41.3898" gradientTransform="matrix(1 0 0 -1 0 123.8898)">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st2" d="M103.4,107h-28l-7-21l7-28h22c3.3,0,6,2.7,6,6V107z"/>
<path class="st3" d="M75.4,21.9V107h-21l-7-14l-7,14h-21V21.9c0-3.3,2.7-6,6-6h44.1C72.8,15.9,75.4,18.6,75.4,21.9z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="19.3678" y1="62.4398" x2="75.4323" y2="62.4398" gradientTransform="matrix(1 0 0 -1 0 123.8898)">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st4" d="M75.4,21.9V107h-21l-7-14l-7,14h-21V21.9c0-3.3,2.7-6,6-6h44.1C72.8,15.9,75.4,18.6,75.4,21.9z"/>
<g>
<path class="st5" d="M40.4,93h14v14h-14V93z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
]>
<svg version="1.1"
id="Layer_1" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;" xmlns:xodm="http://www.corel.com/coreldraw/odm/2003"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.9 122.9"
style="enable-background:new 0 0 122.9 122.9;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_1_);}
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_2_);fill-opacity:0.2;}
.st3{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_3_);}
.st4{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_4_);fill-opacity:0.2;}
.st5{fill-rule:evenodd;clip-rule:evenodd;fill:#102E7A;}
.st6{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_5_);fill-opacity:0.2;}
.st7{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
</style>
<metadata id="CorelCorpID_0Corel-Layer">
<sfw xmlns="&ns_sfw;">
<slices></slices>
<sliceSourceBounds bottomLeftOrigin="true" height="122.9" width="122.9" x="-2.9" y="-10.2"></sliceSourceBounds>
</sfw>
</metadata>
<g id="Layer_x0020_1">
<rect class="st0" width="122.9" height="122.9"/>
<g id="_2032626019552">
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="42171.3828" y1="-48657.0313" x2="41325.3242" y2="-48566.3828" gradientTransform="matrix(2.845684e-02 0 0 -2.845684e-02 -1161.0911 -1322.0814)">
<stop offset="0" style="stop-color:#22B8F9"/>
<stop offset="1" style="stop-color:#45C4FA"/>
</linearGradient>
<path class="st1" d="M29,4.6l4.1,52.8L29,118.3c-4.4,0-8.1-3.6-8.1-8.1V12.7C20.8,8.3,24.5,4.6,29,4.6L29,4.6z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="42171.3828" y1="-48657.0313" x2="41325.3242" y2="-48566.3828" gradientTransform="matrix(2.845684e-02 0 0 -2.845684e-02 -1161.0911 -1322.0814)">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st2" d="M29,4.6l4.1,52.8L29,118.3c-4.4,0-8.1-3.6-8.1-8.1V12.7C20.8,8.3,24.5,4.6,29,4.6L29,4.6z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="44880.3164" y1="-49655.5664" x2="41403.4648" y2="-47544.6367" gradientTransform="matrix(2.845684e-02 0 0 -2.845684e-02 -1161.0911 -1322.0814)">
<stop offset="0" style="stop-color:#22B8F9"/>
<stop offset="1" style="stop-color:#45C4FA"/>
</linearGradient>
<path class="st3" d="M102,29v83.5c0,3.2-2.6,5.7-5.7,5.7H37.1L33,57.4l4.1-52.8h59.2c3.2,0,5.7,2.6,5.7,5.7v2.3
C102,12.7,102,29,102,29z"/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="44880.3164" y1="-49655.5664" x2="41403.4648" y2="-47544.6367" gradientTransform="matrix(2.845684e-02 0 0 -2.845684e-02 -1161.0911 -1322.0814)">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st4" d="M102,29v83.5c0,3.2-2.6,5.7-5.7,5.7H37.1L33,57.4l4.1-52.8h59.2c3.2,0,5.7,2.6,5.7,5.7v2.3
C102,12.7,102,29,102,29z"/>
<polygon class="st5" points="29,4.6 37.1,4.6 37.1,118.3 29,118.3 "/>
<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="28.979" y1="61.45" x2="37.0529" y2="61.45">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<polygon class="st6" points="29,4.6 37.1,4.6 37.1,118.3 29,118.3 "/>
<g>
<polygon class="st7" points="45.1,77.7 93.9,77.7 93.9,85.7 45.1,85.7 "/>
<polygon class="st7" points="45.1,93.9 93.9,93.9 93.9,102 45.1,102 "/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
]>
<svg version="1.1" id="Layer_2_1_" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.9 122.9"
style="enable-background:new 0 0 122.9 122.9;" xml:space="preserve">
<style type="text/css">
.st0{fill:#1BB6F9;}
.st1{fill:url(#SVGID_1_);fill-opacity:0.2;}
.st2{fill:url(#SVGID_2_);fill-opacity:0.2;}
.st3{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
</style>
<metadata id="CorelCorpID_0Corel-Layer">
<sfw xmlns="&ns_sfw;">
<slices></slices>
<sliceSourceBounds bottomLeftOrigin="true" height="114.8" width="87.7" x="17.6" y="4"></sliceSourceBounds>
</sfw>
</metadata>
<path class="st0" d="M91.9,34.5c0,16.8-13.6,30.4-30.4,30.4S31.1,51.3,31.1,34.5S44.7,4.1,61.5,4.1S91.9,17.7,91.9,34.5z"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="31.1" y1="34.5" x2="91.9" y2="34.5">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st1" d="M91.9,34.5c0,16.8-13.6,30.4-30.4,30.4S31.1,51.3,31.1,34.5S44.7,4.1,61.5,4.1S91.9,17.7,91.9,34.5z"/>
<path class="st0" d="M17.6,78.4h47.2c22.4,0,40.5,18.1,40.5,40.5H58.1C35.8,118.9,17.6,100.7,17.6,78.4z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="17.6" y1="98.65" x2="105.3" y2="98.65">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st2" d="M17.6,78.4h47.2c22.4,0,40.5,18.1,40.5,40.5H58.1C35.8,118.9,17.6,100.7,17.6,78.4z"/>
<polygon class="st3" points="56.3,78.4 66.1,78.4 66.1,104.6 56.3,104.6 "/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
]>
<svg version="1.1" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.9 122.9"
style="enable-background:new 0 0 122.9 122.9;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill:#00A079;}
.st2{fill:url(#SVGID_1_);fill-opacity:0.2;}
.st3{fill:#FBB130;}
.st4{fill:url(#SVGID_2_);fill-opacity:0.2;}
.st5{fill:#006158;}
.st6{fill:url(#SVGID_3_);fill-opacity:0.2;}
.st7{display:none;fill:#FFFFFF;}
</style>
<metadata id="CorelCorpID_0Corel-Layer">
<sfw xmlns="&ns_sfw;">
<slices></slices>
<sliceSourceBounds bottomLeftOrigin="true" height="122.9" width="122.9" x="0" y="0"></sliceSourceBounds>
</sfw>
</metadata>
<g id="Layer_3">
<rect y="0" class="st0" width="122.9" height="122.9"/>
<g id="Layer_2_1_">
<path class="st1" d="M3.3,31.4h116.5V92c0,5.1-4.2,9.3-9.3,9.3H3.3V31.4z"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-0.4914" y1="758.4995" x2="123.6299" y2="810.2166" gradientTransform="matrix(1 0 0 1 0 -718)">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st2" d="M3.3,31.4h116.5V92c0,5.1-4.2,9.3-9.3,9.3H3.3V31.4z"/>
<path class="st3" d="M12.6,52.3c-5.1,0-9.3-4.2-9.3-9.3V31.3c0-5.1,4.2-9.3,9.3-9.3h107.2v21c0,5.1-4.2,9.3-9.3,9.3H12.6z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="25.0528" y1="717.3729" x2="100.743" y2="790.1522" gradientTransform="matrix(1 0 0 1 0 -718)">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st4" d="M12.6,52.3c-5.1,0-9.3-4.2-9.3-9.3V31.3c0-5.1,4.2-9.3,9.3-9.3h107.2v21c0,5.1-4.2,9.3-9.3,9.3H12.6z"/>
<path class="st5" d="M3.3,40.7h116.5V50c0,5.1-4.2,9.3-9.3,9.3H12.6c-5.1,0-9.3-4.2-9.3-9.3C3.3,50,3.3,40.7,3.3,40.7z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="41.9011" y1="733.9953" x2="84.2298" y2="800.1337" gradientTransform="matrix(1 0 0 1 0 -718)">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st6" d="M3.3,40.7h116.5V50c0,5.1-4.2,9.3-9.3,9.3H12.6c-5.1,0-9.3-4.2-9.3-9.3C3.3,50,3.3,40.7,3.3,40.7z"/>
<path class="st7" d="M98.8,59.3c0,3.9-3.1,7-7,7s-7-3.1-7-7s3.1-7,7-7C95.7,52.3,98.8,55.5,98.8,59.3z"/>
<path class="st7" d="M80.2,71h11.7c6.4,0,11.7,5.2,11.7,11.7H91.8C85.4,82.6,80.2,77.4,80.2,71z"/>
</g>
</g>
<g id="Layer_2">
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 171 KiB

View File

@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
]>
<svg version="1.1"
id="Layer_1" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;" xmlns:xodm="http://www.corel.com/coreldraw/odm/2003"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.9 122.9"
style="enable-background:new 0 0 122.9 122.9;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#1BB6F9;}
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_1_);fill-opacity:0.2;}
.st3{fill-rule:evenodd;clip-rule:evenodd;fill:#0382F3;}
.st4{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_2_);fill-opacity:0.2;}
.st5{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_3_);fill-opacity:0.2;}
.st6{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_4_);fill-opacity:0.2;}
.st7{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_5_);fill-opacity:0.2;}
.st8{fill-rule:evenodd;clip-rule:evenodd;fill:#102E7A;}
.st9{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_6_);fill-opacity:0.2;}
.st10{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_7_);fill-opacity:0.2;}
.st11{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_8_);fill-opacity:0.2;}
.st12{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_9_);fill-opacity:0.2;}
.st13{fill:#FFFFFF;}
.st14{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
</style>
<metadata id="CorelCorpID_0Corel-Layer">
<sfw xmlns="&ns_sfw;">
<slices></slices>
<sliceSourceBounds bottomLeftOrigin="true" height="122.9" width="122.9" x="0" y="0"></sliceSourceBounds>
</sfw>
</metadata>
<g id="Layer_x0020_1">
<rect y="0" class="st0" width="122.9" height="122.9"/>
<g id="_2888754740800">
<path class="st1" d="M97.3,119.9h-73c-1.6,0-3-1.3-3-3V14.6c0-6.6,5.4-12,12-12h73c1.7,0,3,1.3,3,3v102.3
C109.3,114.5,103.9,119.9,97.3,119.9z"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="21.3" y1="61.25" x2="109.3" y2="61.25">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st2" d="M97.3,119.9h-73c-1.6,0-3-1.3-3-3V14.6c0-6.6,5.4-12,12-12h73c1.7,0,3,1.3,3,3v102.3
C109.3,114.5,103.9,119.9,97.3,119.9z"/>
<path class="st3" d="M21.3,31.9h-3.7c-2,0-3.7-1.6-3.7-3.7l0,0c0-2,1.6-3.7,3.7-3.7h3.7l7.3,3.7L21.3,31.9z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="13.9" y1="28.2" x2="28.6" y2="28.2">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st4" d="M21.3,31.9h-3.7c-2,0-3.7-1.6-3.7-3.7l0,0c0-2,1.6-3.7,3.7-3.7h3.7l7.3,3.7L21.3,31.9z"/>
<path class="st3" d="M21.3,53.9h-3.7c-2,0-3.7-1.6-3.7-3.7l0,0c0-2,1.6-3.7,3.7-3.7h3.7l7.3,3.7L21.3,53.9z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="13.9" y1="50.2" x2="28.6" y2="50.2">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st5" d="M21.3,53.9h-3.7c-2,0-3.7-1.6-3.7-3.7l0,0c0-2,1.6-3.7,3.7-3.7h3.7l7.3,3.7L21.3,53.9z"/>
<path class="st3" d="M21.3,75.9h-3.7c-2,0-3.7-1.6-3.7-3.7l0,0c0-2,1.6-3.7,3.7-3.7h3.7l7.3,3.7L21.3,75.9z"/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="13.9" y1="72.2" x2="28.6" y2="72.2">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st6" d="M21.3,75.9h-3.7c-2,0-3.7-1.6-3.7-3.7l0,0c0-2,1.6-3.7,3.7-3.7h3.7l7.3,3.7L21.3,75.9z"/>
<path class="st3" d="M21.3,97.9h-3.7c-2,0-3.7-1.6-3.7-3.7l0,0c0-2,1.6-3.7,3.7-3.7h3.7l7.3,3.7L21.3,97.9z"/>
<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="13.9" y1="94.2" x2="28.6" y2="94.2">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st7" d="M21.3,97.9h-3.7c-2,0-3.7-1.6-3.7-3.7l0,0c0-2,1.6-3.7,3.7-3.7h3.7l7.3,3.7L21.3,97.9z"/>
<path class="st8" d="M21.3,24.6H25c2,0,3.6,1.6,3.6,3.6l0,0c0,2-1.6,3.6-3.6,3.6h-3.7V24.6z"/>
<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="21.3" y1="28.2" x2="28.6" y2="28.2">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st9" d="M21.3,24.6H25c2,0,3.6,1.6,3.6,3.6l0,0c0,2-1.6,3.6-3.6,3.6h-3.7V24.6z"/>
<path class="st8" d="M21.3,46.6H25c2,0,3.6,1.6,3.6,3.6l0,0c0,2-1.6,3.6-3.6,3.6h-3.7V46.6z"/>
<linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="21.3" y1="50.2" x2="28.6" y2="50.2">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st10" d="M21.3,46.6H25c2,0,3.6,1.6,3.6,3.6l0,0c0,2-1.6,3.6-3.6,3.6h-3.7V46.6z"/>
<path class="st8" d="M21.3,68.6H25c2,0,3.6,1.6,3.6,3.6l0,0c0,2-1.6,3.6-3.6,3.6h-3.7V68.6z"/>
<linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="21.3" y1="72.2" x2="28.6" y2="72.2">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st11" d="M21.3,68.6H25c2,0,3.6,1.6,3.6,3.6l0,0c0,2-1.6,3.6-3.6,3.6h-3.7V68.6z"/>
<path class="st8" d="M21.3,90.6H25c2,0,3.6,1.6,3.6,3.6l0,0c0,2-1.6,3.6-3.6,3.6h-3.7V90.6z"/>
<linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="21.3" y1="94.2" x2="28.6" y2="94.2">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</linearGradient>
<path class="st12" d="M21.3,90.6H25c2,0,3.6,1.6,3.6,3.6l0,0c0,2-1.6,3.6-3.6,3.6h-3.7V90.6z"/>
<g>
<path class="st13" d="M57.6,52.6c-6.1,0-11,4.9-11,11v18.3h7.3v-7.3h7.3v7.3h7.3V63.6C68.6,57.5,63.6,52.6,57.6,52.6L57.6,52.6z
M61.2,67.2h-7.3v-3.7c0-2.1,1.6-3.7,3.7-3.7s3.7,1.6,3.7,3.7v3.7H61.2z"/>
<polygon class="st14" points="90.6,45.2 83.2,45.2 83.2,37.9 75.9,37.9 75.9,45.2 68.6,45.2 68.6,52.6 75.9,52.6 75.9,59.9
83.2,59.9 83.2,52.6 90.6,52.6 "/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -0,0 +1,754 @@
<section class="oe_container">
<div class="container">
<div class="mw-100 py-5 px-lg-5 px-md-5 px-0 bg-100 rounded-4 mt32">
<div class="row mx-0">
<div class="col-md-6 col-12 pb-2 pb-md-0 pr-md-2 mb16">
<div class="oe_padded h-100 ml-md-4" style="padding-left:20px">
<img class="float-left mb-2"
src="students-icon.svg" style="width:15%;"/>
<h3 class="pl-4"
style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:700; font-size:24px; line-height:28.8px; color:#000000">
Student Information Management
</h3>
<div style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:400; font-size:16px; line-height:22px; color:#475569">
A centralized system for managing student data, including profiles, activities, health
information, and parental details, to facilitate informed decision-making.
</div>
</div>
</div>
<div class="col-md-6 col-12 pb-2 pb-md-0 pr-md-2 mb16">
<div class="oe_padded h-100 ml-md-4" style="padding-left:20px">
<img class="float-left mb-2"
src="faculties-icon.svg" style="width:15%;"/>
<h3 class="pl-4"
style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:700; font-size:24px; line-height:28.8px; color:#000000">
Faculty Management
</h3>
<div style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:400; font-size:16px; line-height:22px; color:#475569">
Streamlines faculty information, skills, degrees, and employment history, while integrating with
HR management and payroll systems.
</div>
</div>
</div>
<div class="col-md-6 col-12 pb-2 pb-md-0 pr-md-2 mb16">
<div class="oe_padded h-100 ml-md-4" style="padding-left:20px">
<img class="float-left mb-2"
src="courses-icon 1.svg" style="width:15%;">
<h3 class="pl-4"
style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:700; font-size:24px; line-height:28.8px; color:#000000">
Course Management
</h3>
<div style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:400; font-size:16px; line-height:22px; color:#475569">
Efficiently organizes courses, subjects, and sessions, including curriculum, lesson plans, and
timetables.
</div>
</div>
</div>
<div class="col-md-6 col-12 pb-2 pb-md-0 pr-md-2 mb16">
<div class="oe_padded h-100 ml-md-4" style="padding-left:20px">
<img class="float-left mb-2"
src="openeducat_admission.svg" style="width:15%;"/>
<h3 class="pl-4"
style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:700; font-size:24px; line-height:28.8px; color:#000000">
Admission Management
</h3>
<div style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:400; font-size:16px; line-height:22px; color:#475569">
Simplifies the enrollment process with online applications, planned admissions, and integrated
communication tools.
</div>
</div>
</div>
<div class="col-md-6 col-12 pb-2 pb-md-0 pr-md-2 mb16">
<div class="oe_padded h-100 ml-md-4" style="padding-left:20px">
<img class="float-left mb-2"
src="openeducat_exam.svg" style="width:15%;"/>
<h3 class="pl-4"
style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:700; font-size:24px; line-height:28.8px; color:#000000">
Examination Management
</h3>
<div style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:400; font-size:16px; line-height:22px; color:#475569">
Facilitates exam scheduling using various evaluation methods, supports online exams, and
generates automated report cards.
</div>
</div>
</div>
<div class="col-md-6 col-12 pb-2 pb-md-0 pr-md-2 mb16">
<div class="oe_padded h-100 ml-md-4" style="padding-left:20px">
<img class="float-left mb-2"
src="openeducat_account_financial_reports.svg" style="width:15%;"/>
<h3 class="pl-4"
style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:700; font-size:24px; line-height:28.8px; color:#000000">
Financial Management
</h3>
<div style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:400; font-size:16px; line-height:22px; color:#475569">
Manages financial activities with flexible payment systems, automated fee reminders, and
detailed financial reports.
</div>
</div>
</div>
<div class="col-md-6 col-12 pb-2 pb-md-0 pr-md-2 mb16">
<div class="oe_padded h-100 ml-md-4" style="padding-left:20px">
<img class="float-left mb-2"
src="openeducat_attendance.svg" style="width:15%;"/>
<h3 class="pl-4"
style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:700; font-size:24px; line-height:28.8px; color:#000000">
Attendance Management
</h3>
<div style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:400; font-size:16px; line-height:22px; color:#475569">
Tracks attendance and manages class schedules efficiently to ensure smooth academic operations.
</div>
</div>
</div>
<div class="col-md-6 col-12 pb-2 pb-md-0 pr-md-2 mb16">
<div class="oe_padded h-100 ml-md-4" style="padding-left:20px">
<img class="float-left mb-2"
src="openeducat_library.svg" style="width:15%;"/>
<h3 class="pl-4"
style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:700; font-size:24px; line-height:28.8px; color:#000000">
Library Management
</h3>
<div style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:400; font-size:16px; line-height:22px; color:#475569">
Handles book lending, cataloging, and member management to support educational resources.
</div>
</div>
</div>
<div class="col-md-6 col-12 pb-2 pb-md-0 pr-md-2 mb16">
<div class="oe_padded h-100 ml-md-4" style="padding-left:20px">
<img class="float-left mb-2"
src="lms-icon.svg" style="width:15%;"/>
<h3 class="pl-4"
style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:700; font-size:24px; line-height:28.8px; color:#000000">
Learning Management System
</h3>
<div style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:400; font-size:16px; line-height:22px; color:#475569">
Enhances collaboration with integrated messaging and notifications to keep stakeholders
informed.
</div>
</div>
</div>
<div class="col-md-6 col-12 pb-2 pb-md-0 pr-md-2 mb16">
<div class="oe_padded h-100 ml-md-4" style="padding-left:20px">
<img class="float-left mb-2"
src="openeducat_classroom.svg" style="width:15%;"/>
<h3 class="pl-4"
style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:700; font-size:24px; line-height:28.8px; color:#000000">
Classroom Management
</h3>
<div style="font-family:'Open Sans', sans-serif; font-style:normal; font-weight:400; font-size:16px; line-height:22px; color:#475569">
Generates insightful reports for data-driven decision-making, helping institutions optimize
their operations.
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="oe_container pt64">
<div class="container">
<div class="mw-100 py-5 px-lg-5 px-md-5 px-0 bg-100 rounded-4">
<div class="oe_padded ml-md-4" style="padding-left:20px">
<h3 class="pl-4"
style="font-family:'Open Sans', sans-serif; font-weight:700; font-size:26px; line-height:31px; color:#000000">
Highlights
</h3>
</div>
<div class="d-flex row">
<div class="col-md-6 col-lg-4 col-sm-12 p-0" style="border:none">
<div class="ms-3 position-relative">
<img class="mb-2 me-2" src="openeducat_assignment.svg" alt="Icon" style="width:48px;"/>
<span style="font-family:'Open Sans', sans-serif; font-style:normal; color:black; font-size:16px; font-weight:400; line-height:24px; letter-spacing:0em; text-align:left">
Assignment Allocation
</span>
</div>
</div>
<div class="col-md-6 col-lg-4 col-sm-12 p-0 mt-2" style="border:none">
<div class="ms-3 position-relative">
<img class="mb-2 me-2" src="openeducat_exam.svg" alt="Icon" style="width:48px;"/>
<span style="font-family:'Open Sans', sans-serif; font-style:normal; color:black; font-size:16px; font-weight:400; line-height:24px; letter-spacing:0em; text-align:left">
Exam Management
</span>
</div>
</div>
<div class="col-md-6 col-lg-4 col-sm-12 p-0 mt-2" style="border:none">
<div class="ms-3 position-relative">
<img class="mb-2 me-2" src="fees.svg" alt="Icon" style="width:48px;"/>
<span style="font-family:'Open Sans', sans-serif; font-style:normal; color:black; font-size:16px; font-weight:400; line-height:24px; letter-spacing:0em; text-align:left">
Fees Management
</span>
</div>
</div>
<div class="col-md-6 col-lg-4 col-sm-12 p-0 mt-2" style="border:none">
<div class="ms-3 position-relative">
<img class="float-left mb-2" src="inventory.svg" style="width:15%;">
<span style="font-family:'Open Sans', sans-serif; font-style:normal; color:black; font-size:16px; font-weight:400; line-height:24px; letter-spacing:0em; text-align:left">
Inventory Management
</span>
</div>
</div>
<div class="col-md-6 col-lg-4 col-sm-12 p-0 mt-2" style="border:none">
<div class="ms-3 position-relative">
<img class="mb-2 me-2" src="campus-icon.svg" alt="Icon" style="width:48px;"/>
<span style="font-family:'Open Sans', sans-serif; font-style:normal; color:black; font-size:16px; font-weight:400; line-height:24px; letter-spacing:0em; text-align:left">
Campus Management
</span>
</div>
</div>
<div class="col-md-6 col-lg-4 col-sm-12 p-0 mt-2" style="border:none">
<div class="ms-3 position-relative">
<img class="mb-2 me-2" src="notice-board-icon.svg" alt="Icon" style="width:48px;"/>
<span style="font-family:'Open Sans', sans-serif; font-style:normal; color:black; font-size:16px; font-weight:400; line-height:24px; letter-spacing:0em; text-align:left">
Notice Board
</span>
<span class="badge badge-secondary bg-success text-white position-absolute p-1">Enterprise</span>
</div>
</div>
<div class="col-md-6 col-lg-4 col-sm-12 p-0" style="border:none">
<div class="ms-3 position-relative">
<img class="mb-2 me-2" src="grade-book-icon.svg" alt="Icon" style="width:48px;"/>
<span style="font-family:'Open Sans', sans-serif; font-style:normal; color:black; font-size:16px; font-weight:400; line-height:24px; letter-spacing:0em; text-align:left">
Gradebook Management
</span>
<span class="badge badge-secondary bg-success text-white position-absolute p-1">Enterprise</span>
</div>
</div>
<div class="col-md-6 col-lg-4 col-sm-12 p-0 mt-2" style="border:none">
<div class="ms-3 position-relative">
<img class="mb-2 me-2" src="assignment-annotation-icon.png" alt="Icon" style="width:48px;"/>
<span style="font-family:'Open Sans', sans-serif; font-style:normal; color:black; font-size:16px; font-weight:400; line-height:24px; letter-spacing:0em; text-align:left">
Assignment Annotation
</span>
<span class="badge badge-secondary bg-success text-white position-absolute p-1">Enterprise</span>
</div>
</div>
<div class="col-md-6 col-lg-4 col-sm-12 p-0 mt-2" style="border:none">
<div class="ms-3 position-relative">
<img class="mb-2 me-2" src="openeducat_alumni_enterprise.svg" alt="Icon" style="width:48px;"/>
<span style="font-family:'Open Sans', sans-serif; font-style:normal; color:black; font-size:16px; font-weight:400; line-height:24px; letter-spacing:0em; text-align:left">
Alumni Management
</span>
<span class="badge badge-secondary bg-success text-white position-absolute p-1">Enterprise</span>
</div>
</div>
<div class="col-md-6 col-lg-4 col-sm-12 p-0 mt-2" style="border:none">
<div class="ms-3 position-relative">
<img class="mb-2 me-2" src="transportation-icon.svg" alt="Icon" style="width:48px;"/>
<span style="font-family:'Open Sans', sans-serif; font-style:normal; color:black; font-size:16px; font-weight:400; line-height:24px; letter-spacing:0em; text-align:left">
Transpotation Management
</span>
<span class="badge badge-secondary bg-success text-white position-absolute p-1">Enterprise</span>
</div>
</div>
<div class="col-md-6 col-lg-4 col-sm-12 p-0 mt-2" style="border:none">
<div class="ms-3 position-relative">
<img class="mb-2 me-2" src="kpi-dashboard-icon.svg" alt="Icon" style="width:48px;"/>
<span style="font-family:'Open Sans', sans-serif; font-style:normal; color:black; font-size:16px; font-weight:400; line-height:24px; letter-spacing:0em; text-align:left">
KPI Dashboards
</span>
<span class="badge badge-secondary bg-success text-white position-absolute p-1">Enterprise</span>
</div>
</div>
<div class="col-md-6 col-lg-4 col-sm-12 p-0 mt-2" style="border:none">
<div class="ms-3 position-relative">
<img class="float-left mb-2" src="news.svg" style="width:15%;">
<span style="font-family:'Open Sans', sans-serif; font-style:normal; color:black; font-size:16px; font-weight:400; line-height:24px; letter-spacing:0em; text-align:left">
News Portal
</span>
<span class="badge badge-secondary bg-success text-white position-absolute p-1">Enterprise</span>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="oe_container pt64">
<div class="container">
<div class="row no-gutters mw-100 mx-0">
<section class="" id="inner_page_banner_section">
<div class="container pt32 pb32">
<div class="row align-items-center">
<div class="col-12 col-lg-12 col-md-12 col-sm-12 col-xs-12 mb32">
<h1 class="mt0 font-weight-normal page-title text-center">OpenEduCat vs other odoo
educational modules
and applications </h1>
<p class="text-center">Below is a concise side-by-side comparison table highlighting why
OpenEduCat stands out
from other Odoo-based education management modules: </p>
<div class="erp_compare_table my-5 text-dark table-responsive">
<!-- <table class="table table-bordered table-striped">-->
<table class="table table-bordered table-striped">
<thead class="bg-black text-white">
<tr class="">
<th>Criteria</th>
<th>OpenEduCat</th>
<th>Other Odoo Educational Modules</th>
</tr>
</thead>
<tbody>
<tr>
<td>1. History &amp; Expertise</td>
<td>- First specialized solution in the Odoo ecosystem
- Deep expertise in both ERP and education
- Decades of combined industry knowledge
</td>
<td>- Often smaller scale or a byproduct of broader Odoo offerings
- Typically less domain-focused and less proven
</td>
</tr>
<tr>
<td>2. Track Record</td>
<td>- 3M+ users and 50,000+ institutes worldwide
- Global community of educators and administrators
</td>
<td>- Limited user base and references
- Less recognized in global education circles
</td>
</tr>
<tr>
<td>3. Core Product Focus</td>
<td>- Maintained as a flagship product at OpenEduCat, Inc.
- Continuous investments in enhancements and new features
</td>
<td>- Generally one of the many modules a developer might offer, leading to
divided attention
</td>
</tr>
<tr>
<td>4. Reviews &amp; Reputation</td>
<td>- Highly rated on G2 and SourceForge
- Consistent positive customer feedback and testimonials
</td>
<td>- Fewer or less visible third-party reviews</td>
</tr>
<tr>
<td>5. Active Development</td>
<td>- Frequent releases and timely updates
- Dedicated roadmap for education-related improvements
</td>
<td>- Irregular or slow updates
- May not keep pace with evolving education technology
</td>
</tr>
<tr>
<td>6. Open Source &amp; Enterprise</td>
<td>- Fully open source with an optional Enterprise Edition
- Flexible, cost-effective solutions for institutions of all sizes
</td>
<td>- Some may offer only limited versions or features, requiring more
customization
</td>
</tr>
<tr>
<td>7. Customer Support &amp; Community</td>
<td>- Extensive documentation, tutorials, and an active community
- Professional support from experts in both ERP and education
</td>
<td>- Limited or ad-hoc support
- Smaller community and less specialized resources
</td>
</tr>
<tr>
<td>8. Scalability &amp; Reliability</td>
<td>- Proven performance in large-scale deployments
- Designed to handle complex workflows and multiple campuses
</td>
<td>- Often suitable only for smaller environments</td>
</tr>
<tr>
<td>9. Integration &amp; Customization</td>
<td>- Native Odoo integration plus easy compatibility with third-party tools
- Customizable for unique academic workflows
</td>
<td>- May require extra effort or third-party help to integrate</td>
</tr>
<tr>
<td>10. Overall Value &amp; ROI</td>
<td>- High-quality features and ongoing innovation
- A trusted, long-term solution backed by a large user base and active
development
</td>
<td>- Uncertain support or updates, risking higher long-term costs</td>
</tr>
</tbody>
</table>
</div>
<div class="mt48"> <h3 class="pl-4" style="font-family:'Open Sans', sans-serif; font-weight:700; font-size:26px; line-height:31px; color:#000000">Why OpenEduCat Matters</h3>
<!-- <h5 class="mb24">Why OpenEduCat Matters 🎓 </h5>-->
<ul class="mb32 list-unstyled">
<li class="mb-3 text-black">
Institutional Longevity :
OpenEduCat's unwavering commitment to education ensures a future-proof solution,
providing your institution with long-term stability and reliability.
</li>
<li class="mb-3 text-black">
Cost-Effective Investment
Leveraging a robust open-source foundation coupled with optional enterprise
support, OpenEduCat minimizes your total cost of ownership by eliminating hidden
maintenance and upgrade expenses.
</li>
<li class="mb-3 text-black">
Proven Industry Expertise
Our extensive experience in both ERP systems and the education sector guarantees
a mature, high-performance solution capable of addressing complex institutional
requirements.
</li>
<li class="mb-3 text-black">
Scalable Solutions for All
"Choosing OpenEduCat isn't merely implementing another module—it's an investment
in a global leader in education management, purpose-built for the Odoo platform.
From small institutions to large universities, OpenEduCat seamlessly scales to
meet your evolving needs."
</li>
<li class="mb-3 text-black">
Tailored for Education
As a specialized solution for the education sector, OpenEduCat offers features
and functionalities specifically designed to enhance academic operations and
student management.
</li>
<li class="mb-3 text-black">
Data Security and Compliance
With increasing focus on data protection in education, OpenEduCat prioritizes
security measures and compliance with educational data regulations.
</li>
</ul>
<blockquote class="text-black border-0" style="font-size: 22px;"> When you choose <b
class="text-o-color-1">OpenEduCat</b> you are not just installing another
module you are investing in a <b class="text-o-color-1">global leader </b> in
education management built specifically for the Odoo platform. Whether you are a
small institution or a large university OpenEduCat scales seamlessly to meet your
needs
</blockquote>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</section>
<section class="oe_container pt64">
<div class="container">
<div class="mw-100 py-5 px-5 bg-100 rounded-4">
<div class="oe_padded ml-md-4" style="padding-left:20px">
<h3 class="pl-4"
style="font-family:'Open Sans', sans-serif; font-weight:700; font-size:26px; line-height:31px; color:#000000">
Badges &amp; Awards
</h3>
</div>
<div class="d-flex row">
<img class="img img-fluid" src="g2-img.svg"/>
</div>
<div class="d-flex row">
<img class="img img-fluid" src="sourceforge-img.svg"/>
</div>
</div>
</div>
</section>
<section class="oe_container pt32">
<div class="container">
<!-- <div class="mt64 oec_feature row mx-0 align-items-center"-->
<!-- style="border: 1px solid #e3eaf2;border-radius: 12px;padding: 40px 20px;background: #f6fafe;">-->
<!-- <div class="col-12 col-sm-12 col-md-12 col-lg-7"><h5>Get in touch for a personalized-->
<!-- demo or to discuss how we can tailor OpenEduCat to your institutions unique-->
<!-- requirements. </h5></div>-->
<!-- <div class="col-12 col-sm-12 col-md-12 col-lg-5 text-center">-->
<!-- <div class="banner_btn"><a href="https://openeducat.org/demo"-->
<!-- title="Try For Free"-->
<!-- class="mr-2 mr-lg-2 mr-md-2 my-1 text-center btn btn-primary banner-btn-fill">-->
<!-- Try For Free </a> <a href="https://openeducat.org/booking"-->
<!-- title="Talk to Expert"-->
<!-- class="mr-2 mr-lg-2 mr-md-2 my-1 text-center btn btn-primary banner-btn-light">-->
<!-- Talk to Expert </a></div>-->
<!-- </div>-->
<!-- </div>-->
<div class="row no-gutters mw-100 mx-0 py-5 px-lg-5 px-md-5 rounded-4 mt32 mb32 bg-100">
<div class="mb16">
<h1 class="text-center">Need Any Help?</h1>
</div>
<div class="text-left col-12">
<ul class="inlineBlock list-unstyled row">
<li class="col-12 p-2 col-lg-6"><div style="text-align: center;border: 1px solid #ddd;padding: 10px;"><h4 style="line-height: 1.5;">Website : <br/> openeducat.org</h4></div></li>
<li class="col-12 p-2 col-lg-6"><div style="text-align: center;border: 1px solid #ddd;padding: 10px;"><h4 style="line-height: 1.5;">Contact Us : <br/> openeducat.org/contactus </h4></div></li>
<li class="col-12 p-2 col-lg-6"><div style="text-align: center;border: 1px solid #ddd;padding: 10px;"><h4 style="line-height: 1.5;">Try for Free : <br/> openeducat.org/demo </h4></div></li>
<li class="col-12 p-2 col-lg-6"><div style="text-align: center;border: 1px solid #ddd;padding: 10px;"><h4 style="line-height: 1.5;">Talk to Expert : <br/> openeducat.org/booking </h4></div></li>
</ul>
</div>
<!-- <div class="text-left col-6">-->
<!-- <img class="img img-fluid" src="help.jpg"/>-->
<!-- </div>-->
</div>
<div class="row no-gutters mw-100 mx-0 py-5 px-lg-5 px-md-5 rounded-4 mt32 mb32 bg-100">
<div class="mb16">
<h3 style="font-size: 13px;">
Data Collection Notice:
</h3>
<p style="font-size: 13px;margin-bottom: 0;">
This module collects and transmits certain non-personal technical data to OpenEduCat Inc. servers,
including: instance URL, Odoo version, module version, database UUID, and number of active users.
This data is used solely for license validation, usage analytics, and providing proactive support
and update notifications. No personal student records, grades, or sensitive educational data is
transmitted. Data is processed in accordance with our Privacy Policy at
https://openeducat.org/privacy/. By installing this module, you consent to this data collection. You
may contact privacy@openeducat.org for questions or to request data deletion.
</p>
</div>
</div>
</div>
</section>
<!--<section class="oe_container">-->
<!-- <div class="oe_mt16 oe_mb16">-->
<!-- <h2 class="oe_slogan">Need Any Help?</h2>-->
<!-- </div>-->
<!-- <div class="oe_slogan">-->
<!-- <a class="btn btn-primary btn-lg mt8" style="color: #FFFFFF !important;" href="mailto:support@openeducat.org"><i-->
<!-- class="fa fa-envelope"></i> Email </a>-->
<!-- <a class="btn btn-primary btn-lg mt8" style="color: #FFFFFF !important;"-->
<!-- href="https://www.openeducat.org/page/contactus"><i class="fa fa-phone"></i> Contact Us </a>-->
<!-- <a class="btn btn-primary btn-lg mt8" style="color: #FFFFFF !important;"-->
<!-- href="https://www.openeducat.org/page/contactus"><i class="fa fa-check-square"></i> Request Customization-->
<!-- </a>-->
<!-- <a class="mt8 btn btn-primary btn-lg" style="color: #FFFFFF !important;margin-right: 7px;"-->
<!-- href="https://www.openeducat.org/page/return-and-refund-policy"><i class="fa fa-share-square" style=""></i>-->
<!-- Returns and Refunds Policy</a>-->
<!-- </div>-->
<!--</section>-->
<!--old-->
<!--<section class="oe_container">-->
<!-- <div class="oe_row oe_spaced">-->
<!-- <div class="oe_span12">-->
<!-- <h2 class="oe_slogan">OpenEduCat Core</h2>-->
<!-- <h3 class="oe_slogan">Efficient management of students,-->
<!-- faculties, courses and batches with a collaborative platform</h3>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <div class="oe_demo oe_picture oe_screenshot">-->
<!-- <a href="https://www.openeducat.org/demo"> <img-->
<!-- src="openeducat_logo.png">-->
<!-- </a>-->
<!-- <div class="oe_demo_footer oe_centeralign">Online Demo</div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <p class="oe_mt32">Based on best of class enterprise level-->
<!-- architecture, OpenEduCat is ready to be used from local-->
<!-- infrastructure to a highly scalable cloud environment.</p>-->
<!-- <div class="oe_centeralign oe_websiteonly">-->
<!-- <a href="https://www.openeducat.org/contactus/"-->
<!-- class="oe_button oe_big oe_tacky">Contact Us</a>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!--</section>-->
<!--<section class="oe_container oe_dark">-->
<!-- <div class="oe_row oe_spaced">-->
<!-- <h2 class="oe_slogan">Student Information At Your Click</h2>-->
<!-- <div class="oe_span6">-->
<!-- <p class="oe_mt32">A complete student management system which-->
<!-- enables you to store all information related to students such as-->
<!-- address, blood group, achievements and much more.</p>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <div class="oe_row_img oe_centered">-->
<!-- <img class="oe_picture oe_screenshot" src="student.png">-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!--</section>-->
<!--<section class="oe_container">-->
<!-- <div class="oe_row oe_spaced">-->
<!-- <h2 class="oe_slogan">Easier Faculty Management</h2>-->
<!-- <div class="oe_span6">-->
<!-- <div class="oe_row_img oe_centered">-->
<!-- <img class="oe_picture" src="faculty.png">-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <p class="oe_mt32">Faculty management system with integrated HR-->
<!-- management system gives full control over information related to-->
<!-- faculty like educational degree, skills and payroll.</p>-->
<!-- </div>-->
<!-- </div>-->
<!--</section>-->
<!--<section class="oe_container oe_dark">-->
<!-- <div class="oe_row oe_spaced">-->
<!-- <h2 class="oe_slogan">Subject Management</h2>-->
<!-- <div class="oe_span6">-->
<!-- <p class="oe_mt32">Subject management system to manage-->
<!-- details of subject like name, code and type such as theory, practical-->
<!-- or both.</p>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <div class="oe_row_img oe_centered">-->
<!-- <img class="oe_picture" src="subjects.png">-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!--</section>-->
<!--<section class="oe_container">-->
<!-- <div class="oe_row oe_spaced">-->
<!-- <h2 class="oe_slogan">Course Management</h2>-->
<!-- <div class="oe_span6">-->
<!-- <div class="oe_row_img oe_centered">-->
<!-- <img class="oe_picture oe_screenshot" src="courses.png">-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <p class="oe_mt32">A Course management system provides detailed-->
<!-- information of course and subjects related to that course. You-->
<!-- can manage parent-child relationship between courses.</p>-->
<!-- </div>-->
<!-- </div>-->
<!--</section>-->
<!--<section class="oe_container oe_dark">-->
<!-- <div class="oe_row oe_spaced">-->
<!-- <h2 class="oe_slogan">Batch Management</h2>-->
<!-- <div class="oe_span6">-->
<!-- <p class="oe_mt32">Batch management system to manage batches of-->
<!-- students from different courses.</p>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <div class="oe_row_img oe_centered">-->
<!-- <img class="oe_picture oe_screenshot" src="batch.png">-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!--</section>-->
<!--<section class="oe_container">-->
<!-- <div class="oe_row oe_spaced">-->
<!-- <div class="oe_span12">-->
<!-- <h2 class="oe_slogan">OpenEduCat Core</h2>-->
<!-- <h3 class="oe_slogan">Efficient management of students,-->
<!-- faculties, courses and batches with a collaborative platform</h3>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <div class="oe_demo oe_picture oe_screenshot">-->
<!-- <a href="https://www.openeducat.org/demo"> <img-->
<!-- src="openeducat_logo.png">-->
<!-- </a>-->
<!-- <div class="oe_demo_footer oe_centeralign">Online Demo</div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <p class="oe_mt32">Based on best of class enterprise level-->
<!-- architecture, OpenEduCat is ready to be used from local-->
<!-- infrastructure to a highly scalable cloud environment.</p>-->
<!-- <div class="oe_centeralign oe_websiteonly">-->
<!-- <a href="https://www.openeducat.org/contactus/"-->
<!-- class="oe_button oe_big oe_tacky">Contact Us</a>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!--</section>-->
<!--<section class="oe_container oe_dark">-->
<!-- <div class="oe_row oe_spaced">-->
<!-- <h2 class="oe_slogan">Student Information At Your Click</h2>-->
<!-- <div class="oe_span6">-->
<!-- <p class="oe_mt32">A complete student management system which -->
<!-- enables you to store all information related to students such as-->
<!-- address, blood group, achievements and much more.</p>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <div class="oe_row_img oe_centered">-->
<!-- <img class="oe_picture oe_screenshot" src="student.png">-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!--</section>-->
<!--<section class="oe_container">-->
<!-- <div class="oe_row oe_spaced">-->
<!-- <h2 class="oe_slogan">Easier Faculty Management</h2>-->
<!-- <div class="oe_span6">-->
<!-- <div class="oe_row_img oe_centered">-->
<!-- <img class="oe_picture" src="faculty.png">-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <p class="oe_mt32">Faculty management system with integrated HR-->
<!-- management system gives full control over information related to-->
<!-- faculty like educational degree, skills and payroll.</p>-->
<!-- </div>-->
<!-- </div>-->
<!--</section>-->
<!--<section class="oe_container oe_dark">-->
<!-- <div class="oe_row oe_spaced">-->
<!-- <h2 class="oe_slogan">Subject Management</h2>-->
<!-- <div class="oe_span6">-->
<!-- <p class="oe_mt32">Subject management system to manage -->
<!-- details of subject like name, code and type such as theory, practical-->
<!-- or both.</p>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <div class="oe_row_img oe_centered">-->
<!-- <img class="oe_picture" src="subjects.png">-->
<!-- </div>-->
<!-- </div>-->
<!-- -->
<!-- </div>-->
<!--</section>-->
<!--<section class="oe_container">-->
<!-- <div class="oe_row oe_spaced">-->
<!-- <h2 class="oe_slogan">Course Management</h2>-->
<!-- <div class="oe_span6">-->
<!-- <div class="oe_row_img oe_centered">-->
<!-- <img class="oe_picture oe_screenshot" src="courses.png">-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <p class="oe_mt32">A Course management system provides detailed-->
<!-- information of course and subjects related to that course. You-->
<!-- can manage parent-child relationship between courses.</p>-->
<!-- </div>-->
<!-- </div>-->
<!--</section>-->
<!--<section class="oe_container oe_dark">-->
<!-- <div class="oe_row oe_spaced">-->
<!-- <h2 class="oe_slogan">Batch Management</h2>-->
<!-- <div class="oe_span6">-->
<!-- <p class="oe_mt32">Batch management system to manage batches of-->
<!-- students from different courses.</p>-->
<!-- </div>-->
<!-- <div class="oe_span6">-->
<!-- <div class="oe_row_img oe_centered">-->
<!-- <img class="oe_picture oe_screenshot" src="batch.png">-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!--</section>-->
<!--<section class="oe_container">-->
<!-- <div class="oe_mt16 oe_mb16">-->
<!-- <h2 class="oe_slogan">Need Any Help?</h2>-->
<!-- </div>-->
<!-- <div class="oe_slogan">-->
<!-- <a class="btn btn-primary btn-lg mt8" style="color: #FFFFFF !important;" href="mailto:support@openeducat.org"><i class="fa fa-envelope"></i> Email </a>-->
<!-- <a class="btn btn-primary btn-lg mt8" style="color: #FFFFFF !important;" href="https://www.openeducat.org/page/contactus"><i class="fa fa-phone"></i> Contact Us </a>-->
<!-- <a class="btn btn-primary btn-lg mt8" style="color: #FFFFFF !important;" href="https://www.openeducat.org/page/contactus"><i class="fa fa-check-square"></i> Request Customization </a>-->
<!-- <a class="mt8 btn btn-primary btn-lg" style="color: #FFFFFF !important;margin-right: 7px;" href="https://www.openeducat.org/page/return-and-refund-policy"><i class="fa fa-share-square" style=""></i> Returns and Refunds Policy</a>-->
<!-- </div>-->
<!--</section>-->

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