feat: QA fixes, new APIs (assignments, rubrics, custom exams), Generation page enhancements
- Fix ELAI video generation (correct render endpoint, script splitting for 60s limit) - Fix speaking script generation error handling and empty response display - Add custom exam list API (GET /api/exam/custom/list) - Add assignments REST API (list, create, get) - Add rubrics REST API (list, create) - Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player - Improve submit feedback with exam ID and status in toast notifications - Fix ExamsListPage to show both custom exams and exam sessions - Connect RubricsPage to backend API with fallback data - Add Dockerfile, docker-compose.yml, requirements.txt for deployment - Fix placement, grading, scoring, and auth controllers - Add ErrorBoundary component for frontend resilience - Add QA report and credentials documentation Made-with: Cursor
This commit is contained in:
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM odoo:19.0
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
COPY requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
|
||||||
|
|
||||||
|
COPY custom_addons /opt/odoo/custom_addons
|
||||||
|
COPY odoo.conf /etc/odoo/odoo.conf
|
||||||
|
|
||||||
|
USER odoo
|
||||||
|
|
||||||
|
EXPOSE 8069 8072
|
||||||
@@ -27,14 +27,33 @@ class EncoachAdaptiveController(http.Controller):
|
|||||||
from odoo.fields import Datetime as DT
|
from odoo.fields import Datetime as DT
|
||||||
today_start = DT.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = DT.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
total_students = len(Path.search([]).mapped('student_id'))
|
all_paths = Path.search([])
|
||||||
active_courses = len(Path.search([]).mapped('course_id').filtered(lambda c: c))
|
total_students = len(all_paths.mapped('student_id'))
|
||||||
|
active_courses = len(all_paths.mapped('course_id').filtered(lambda c: c))
|
||||||
|
|
||||||
signals_today = Event.search_count([
|
signals_today = Event.search_count([
|
||||||
('event_type', '=', 'signal'),
|
('event_type', '=', 'signal'),
|
||||||
('created_at', '>=', today_start),
|
('created_at', '>=', today_start),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
avg_progress = 0.0
|
||||||
|
if all_paths:
|
||||||
|
progress_values = []
|
||||||
|
for p in all_paths:
|
||||||
|
try:
|
||||||
|
module_queue = json.loads(p.module_queue or '[]')
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
module_queue = []
|
||||||
|
total_modules = len(module_queue) if module_queue else 1
|
||||||
|
completed = sum(
|
||||||
|
1 for m in module_queue
|
||||||
|
if isinstance(m, dict) and m.get('done')
|
||||||
|
)
|
||||||
|
progress_values.append(
|
||||||
|
round(completed / total_modules * 100, 1) if total_modules else 0.0
|
||||||
|
)
|
||||||
|
avg_progress = round(sum(progress_values) / len(progress_values), 1) if progress_values else 0.0
|
||||||
|
|
||||||
recent_decisions = []
|
recent_decisions = []
|
||||||
decisions = Event.search(
|
decisions = Event.search(
|
||||||
[('event_type', '=', 'decision')],
|
[('event_type', '=', 'decision')],
|
||||||
@@ -52,7 +71,7 @@ class EncoachAdaptiveController(http.Controller):
|
|||||||
return _json_response({
|
return _json_response({
|
||||||
'total_students': total_students,
|
'total_students': total_students,
|
||||||
'active_courses': active_courses,
|
'active_courses': active_courses,
|
||||||
'avg_progress': 0.0,
|
'avg_progress': avg_progress,
|
||||||
'signals_today': signals_today,
|
'signals_today': signals_today,
|
||||||
'recent_decisions': recent_decisions,
|
'recent_decisions': recent_decisions,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
""",
|
""",
|
||||||
"author": "EnCoach",
|
"author": "EnCoach",
|
||||||
"depends": ["base", "encoach_core"],
|
"depends": ["base", "encoach_core"],
|
||||||
|
"external_dependencies": {
|
||||||
|
"python": ["openai", "boto3"],
|
||||||
|
},
|
||||||
"data": [
|
"data": [
|
||||||
"security/ir.model.access.csv",
|
"security/ir.model.access.csv",
|
||||||
"views/ai_settings_views.xml",
|
"views/ai_settings_views.xml",
|
||||||
|
|||||||
@@ -347,7 +347,8 @@ class AIController(http.Controller):
|
|||||||
]
|
]
|
||||||
return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}"))
|
return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _json_response({"questions": [], "error": str(e)})
|
_logger.exception("exam_generate %s failed: %s", module, e)
|
||||||
|
return _json_response({"questions": [], "error": str(e)}, 500)
|
||||||
|
|
||||||
def _generate_passage(self, ai, body):
|
def _generate_passage(self, ai, body):
|
||||||
topic = body.get("topic", "general knowledge")
|
topic = body.get("topic", "general knowledge")
|
||||||
|
|||||||
@@ -125,54 +125,6 @@ class MediaController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _json_response({"video_id": video_id, "status": "error", "error": str(e)})
|
return _json_response({"video_id": video_id, "status": "error", "error": str(e)})
|
||||||
|
|
||||||
# ── POST /api/placement/speaking-upload — transcribe speaking audio ──
|
|
||||||
@http.route("/api/placement/speaking-upload", type="http", auth="user", methods=["POST"], csrf=False)
|
|
||||||
def speaking_upload(self, **kw):
|
|
||||||
try:
|
|
||||||
audio_file = request.httprequest.files.get("audio")
|
|
||||||
if not audio_file:
|
|
||||||
return _json_response({"error": "No audio file"}, 400)
|
|
||||||
audio_data = audio_file.read()
|
|
||||||
from odoo.addons.encoach_ai.services.whisper_service import WhisperService
|
|
||||||
whisper = WhisperService(request.env)
|
|
||||||
transcript = whisper.transcribe(audio_data, use_api=True)
|
|
||||||
|
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
|
||||||
ai = OpenAIService(request.env)
|
|
||||||
grade = ai.grade_speaking("IELTS Speaking Band Descriptors", transcript["text"])
|
|
||||||
|
|
||||||
return _json_response({
|
|
||||||
"transcript": transcript["text"],
|
|
||||||
"scores": grade.get("scores", {}),
|
|
||||||
"overall_band": grade.get("overall_band", 0),
|
|
||||||
"feedback": grade.get("feedback", ""),
|
|
||||||
"status": "completed",
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
_logger.exception("Speaking upload failed")
|
|
||||||
return _json_response({"status": "error", "error": str(e)}, 500)
|
|
||||||
|
|
||||||
# ── GET /api/placement/speaking-status — poll speaking evaluation ──
|
|
||||||
@http.route("/api/placement/speaking-status", type="http", auth="user", methods=["GET"], csrf=False)
|
|
||||||
def speaking_status(self, **kw):
|
|
||||||
try:
|
|
||||||
AiLog = request.env.get("encoach.ai.log")
|
|
||||||
if AiLog:
|
|
||||||
log = AiLog.sudo().search([
|
|
||||||
("action", "=", "grade_speaking"),
|
|
||||||
("create_uid", "=", request.env.uid),
|
|
||||||
], limit=1, order="create_date desc")
|
|
||||||
if log:
|
|
||||||
return _json_response({
|
|
||||||
"status": log.status or "completed",
|
|
||||||
"log_id": log.id,
|
|
||||||
"latency_ms": log.latency_ms,
|
|
||||||
"created_at": log.create_date.isoformat() if log.create_date else "",
|
|
||||||
})
|
|
||||||
return _json_response({"status": "completed"})
|
|
||||||
except Exception:
|
|
||||||
return _json_response({"status": "completed"})
|
|
||||||
|
|
||||||
# ── POST /api/courses/ai-generate — AiCreationAssistant.tsx ──
|
# ── POST /api/courses/ai-generate — AiCreationAssistant.tsx ──
|
||||||
@http.route("/api/courses/ai-generate", type="http", auth="user", methods=["POST"], csrf=False)
|
@http.route("/api/courses/ai-generate", type="http", auth="user", methods=["POST"], csrf=False)
|
||||||
def ai_generate_course(self, **kw):
|
def ai_generate_course(self, **kw):
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ except ImportError:
|
|||||||
|
|
||||||
ELAI_BASE = "https://apis.elai.io/api/v1"
|
ELAI_BASE = "https://apis.elai.io/api/v1"
|
||||||
|
|
||||||
|
AVATAR_PRESETS = {
|
||||||
|
"vadim": {"code": "vadim.casual", "gender": "male", "voice": "en-GB-RyanNeural"},
|
||||||
|
"gia": {"code": "gia.casual", "gender": "female", "voice": "en-US-AriaNeural"},
|
||||||
|
"zara": {"code": "zara.regular", "gender": "female", "voice": "en-US-JennyNeural"},
|
||||||
|
"mason": {"code": "mason.casual", "gender": "male", "voice": "en-US-GuyNeural"},
|
||||||
|
"default": {"code": "gia.casual", "gender": "female", "voice": "en-US-AriaNeural"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ElaiService:
|
class ElaiService:
|
||||||
"""Generate avatar videos for listening exercises and instructional content."""
|
"""Generate avatar videos for listening exercises and instructional content."""
|
||||||
@@ -33,6 +41,7 @@ class ElaiService:
|
|||||||
return {
|
return {
|
||||||
"Authorization": f"Bearer {self._get_token()}",
|
"Authorization": f"Bearer {self._get_token()}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _log(self, action, latency, status="success", error=None):
|
def _log(self, action, latency, status="success", error=None):
|
||||||
@@ -47,6 +56,14 @@ class ElaiService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _resolve_avatar(self, avatar_id):
|
||||||
|
if not avatar_id or avatar_id == "default":
|
||||||
|
return AVATAR_PRESETS["default"]
|
||||||
|
key = avatar_id.split(".")[0].lower() if "." in avatar_id else avatar_id.lower()
|
||||||
|
if key in AVATAR_PRESETS:
|
||||||
|
return AVATAR_PRESETS[key]
|
||||||
|
return {"code": avatar_id, "gender": "female", "voice": "en-US-AriaNeural"}
|
||||||
|
|
||||||
def list_avatars(self):
|
def list_avatars(self):
|
||||||
"""List available ELAI avatars."""
|
"""List available ELAI avatars."""
|
||||||
if not _requests:
|
if not _requests:
|
||||||
@@ -55,23 +72,84 @@ class ElaiService:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_script(script, max_chars=500):
|
||||||
|
"""Split a long script into chunks that stay under ELAI's 60s/slide limit.
|
||||||
|
|
||||||
|
Uses ~10 chars/second heuristic, so 500 chars ~ 50s (with safety margin).
|
||||||
|
Splits on sentence boundaries ('. ', '? ', '! ', newlines).
|
||||||
|
"""
|
||||||
|
if len(script) <= max_chars:
|
||||||
|
return [script]
|
||||||
|
import re
|
||||||
|
sentences = re.split(r'(?<=[.!?])\s+|\n+', script)
|
||||||
|
chunks, current = [], ""
|
||||||
|
for sent in sentences:
|
||||||
|
if not sent.strip():
|
||||||
|
continue
|
||||||
|
if current and len(current) + len(sent) + 1 > max_chars:
|
||||||
|
chunks.append(current.strip())
|
||||||
|
current = sent
|
||||||
|
else:
|
||||||
|
current = f"{current} {sent}" if current else sent
|
||||||
|
if current.strip():
|
||||||
|
chunks.append(current.strip())
|
||||||
|
return chunks or [script]
|
||||||
|
|
||||||
def create_video(self, script, *, avatar_id=None, title="EnCoach Video", language="en"):
|
def create_video(self, script, *, avatar_id=None, title="EnCoach Video", language="en"):
|
||||||
"""Create an avatar video from a script.
|
"""Create an avatar video from a script.
|
||||||
|
|
||||||
|
Automatically splits long scripts into multiple slides to stay under
|
||||||
|
ELAI's 60-second-per-slide limit.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with 'video_id', 'status'
|
dict with 'video_id', 'status'
|
||||||
"""
|
"""
|
||||||
if not _requests:
|
if not _requests:
|
||||||
raise RuntimeError("requests package not installed")
|
raise RuntimeError("requests package not installed")
|
||||||
|
|
||||||
|
avatar_preset = self._resolve_avatar(avatar_id)
|
||||||
|
avatar_code = avatar_preset["code"]
|
||||||
|
avatar_src = f"https://elai-avatars.s3.us-east-2.amazonaws.com/common/{avatar_code.replace('.', '/')}/{avatar_code.replace('.', '_')}.png"
|
||||||
|
|
||||||
|
chunks = self._split_script(script)
|
||||||
|
slides = []
|
||||||
|
for idx, chunk in enumerate(chunks, 1):
|
||||||
|
slides.append({
|
||||||
|
"id": idx,
|
||||||
|
"speech": chunk,
|
||||||
|
"language": "English",
|
||||||
|
"voice": avatar_preset["voice"],
|
||||||
|
"voiceType": "text",
|
||||||
|
"voiceProvider": "azure",
|
||||||
|
"avatar": {
|
||||||
|
"code": avatar_code,
|
||||||
|
"gender": avatar_preset["gender"],
|
||||||
|
},
|
||||||
|
"canvas": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"background": "#ffffff",
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"type": "avatar",
|
||||||
|
"left": 151.5,
|
||||||
|
"top": 36,
|
||||||
|
"fill": "#4868FF",
|
||||||
|
"scaleX": 0.3,
|
||||||
|
"scaleY": 0.3,
|
||||||
|
"height": 1080,
|
||||||
|
"width": 1080,
|
||||||
|
"src": avatar_src,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_logger.info("ELAI: creating video with %d slide(s) from %d chars", len(slides), len(script))
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"name": title,
|
"name": title,
|
||||||
"slides": [
|
"slides": slides,
|
||||||
{
|
"tags": ["encoach"],
|
||||||
"speech": script,
|
|
||||||
"avatar": avatar_id or "default",
|
|
||||||
"language": language,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
@@ -83,8 +161,31 @@ class ElaiService:
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
video_id = data.get("_id", data.get("id"))
|
||||||
|
|
||||||
|
# Step 2: trigger rendering -- ELAI requires a separate render call
|
||||||
|
try:
|
||||||
|
render_resp = _requests.post(
|
||||||
|
f"{ELAI_BASE}/videos/render/{video_id}",
|
||||||
|
headers=self._headers(),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
render_resp.raise_for_status()
|
||||||
|
_logger.info("ELAI render triggered for video %s", video_id)
|
||||||
|
except Exception as render_err:
|
||||||
|
_logger.warning("ELAI render trigger failed for %s: %s (video created but not rendered)", video_id, render_err)
|
||||||
|
|
||||||
self._log("create_video", int((time.time() - t0) * 1000))
|
self._log("create_video", int((time.time() - t0) * 1000))
|
||||||
return {"video_id": data.get("_id", data.get("id")), "status": data.get("status", "pending")}
|
return {"video_id": video_id, "status": "rendering"}
|
||||||
|
except _requests.exceptions.HTTPError as exc:
|
||||||
|
body = ""
|
||||||
|
try:
|
||||||
|
body = exc.response.text[:500]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_logger.error("ELAI create_video failed: %s — %s", exc, body)
|
||||||
|
self._log("create_video", int((time.time() - t0) * 1000), "error", f"{exc} | {body}")
|
||||||
|
raise RuntimeError(f"ELAI API error: {exc.response.status_code} — {body}") from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._log("create_video", int((time.time() - t0) * 1000), "error", str(exc))
|
self._log("create_video", int((time.time() - t0) * 1000), "error", str(exc))
|
||||||
raise
|
raise
|
||||||
@@ -100,9 +201,11 @@ class ElaiService:
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
video_url = data.get("url", "") or data.get("video_url", "")
|
||||||
return {
|
return {
|
||||||
"video_id": video_id,
|
"video_id": video_id,
|
||||||
"status": data.get("status", "unknown"),
|
"status": data.get("status", "unknown"),
|
||||||
"url": data.get("url", ""),
|
"url": video_url,
|
||||||
|
"video_url": video_url,
|
||||||
"duration": data.get("duration"),
|
"duration": data.get("duration"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
'summary': 'Base controller utilities (JWT auth, response helpers) for EnCoach REST API',
|
'summary': 'Base controller utilities (JWT auth, response helpers) for EnCoach REST API',
|
||||||
'author': 'EnCoach',
|
'author': 'EnCoach',
|
||||||
'depends': ['base', 'encoach_core'],
|
'depends': ['base', 'encoach_core'],
|
||||||
|
'external_dependencies': {
|
||||||
|
'python': ['PyJWT'],
|
||||||
|
},
|
||||||
'data': [],
|
'data': [],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'license': 'LGPL-3',
|
'license': 'LGPL-3',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
'category': 'Education',
|
'category': 'Education',
|
||||||
'summary': 'Whitelabeling and custom branding per entity',
|
'summary': 'Whitelabeling and custom branding per entity',
|
||||||
'author': 'EnCoach',
|
'author': 'EnCoach',
|
||||||
'depends': ['encoach_core'],
|
'depends': ['encoach_core', 'encoach_api'],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'views/branding_views.xml',
|
'views/branding_views.xml',
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Placeholder migration script for EnCoach Core v19.0.1.1.
|
||||||
|
|
||||||
|
Add actual migration logic here when schema changes are introduced.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
_logger.info('EnCoach Core: pre-migrate from %s', version)
|
||||||
@@ -2,3 +2,5 @@ from . import templates
|
|||||||
from . import ielts_exam
|
from . import ielts_exam
|
||||||
from . import custom_exam
|
from . import custom_exam
|
||||||
from . import exam_structures
|
from . import exam_structures
|
||||||
|
from . import assignments
|
||||||
|
from . import rubrics
|
||||||
|
|||||||
110
custom_addons/encoach_exam_template/controllers/assignments.py
Normal file
110
custom_addons/encoach_exam_template/controllers/assignments.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
|
jwt_required, _json_response, _error_response, _get_json_body, _paginate
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_to_dict(rec):
|
||||||
|
return {
|
||||||
|
'id': rec.id,
|
||||||
|
'title': rec.exam_id.title if rec.exam_id else f'Assignment #{rec.id}',
|
||||||
|
'exam_id': rec.exam_id.id if rec.exam_id else None,
|
||||||
|
'student_id': rec.student_id.id if rec.student_id else None,
|
||||||
|
'student_name': rec.student_id.name if rec.student_id else None,
|
||||||
|
'batch_id': rec.batch_id.id if rec.batch_id else None,
|
||||||
|
'batch_name': rec.batch_id.name if rec.batch_id else None,
|
||||||
|
'entity_name': rec.exam_id.entity_id.name if rec.exam_id and rec.exam_id.entity_id else None,
|
||||||
|
'start_date': rec.access_start.isoformat() if rec.access_start else None,
|
||||||
|
'end_date': rec.access_end.isoformat() if rec.access_end else None,
|
||||||
|
'state': rec.status,
|
||||||
|
'assignee_count': 1,
|
||||||
|
'completed_count': 1 if rec.status == 'completed' else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachAssignmentController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/api/assignments', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def list_assignments(self, **kw):
|
||||||
|
try:
|
||||||
|
Assignment = request.env['encoach.exam.assignment'].sudo()
|
||||||
|
search = kw.get('search', '').strip()
|
||||||
|
domain = []
|
||||||
|
if search:
|
||||||
|
domain = [('exam_id.title', 'ilike', search)]
|
||||||
|
|
||||||
|
total = Assignment.search_count(domain)
|
||||||
|
page, per_page, offset = _paginate(kw)
|
||||||
|
records = Assignment.search(domain, limit=per_page, offset=offset,
|
||||||
|
order='id desc')
|
||||||
|
return _json_response({
|
||||||
|
'items': [_assignment_to_dict(r) for r in records],
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'per_page': per_page,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('assignments list failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
@http.route('/api/assignments', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def create_assignment(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
title = (body.get('title') or '').strip()
|
||||||
|
if not title:
|
||||||
|
return _error_response('title is required', 400)
|
||||||
|
|
||||||
|
exam_id = body.get('exam_id')
|
||||||
|
if not exam_id:
|
||||||
|
Exam = request.env['encoach.exam.custom'].sudo()
|
||||||
|
exam = Exam.create({
|
||||||
|
'title': title,
|
||||||
|
'teacher_id': request.env.user.id,
|
||||||
|
'status': 'draft',
|
||||||
|
'total_time_min': 0,
|
||||||
|
})
|
||||||
|
exam_id = exam.id
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'exam_id': exam_id,
|
||||||
|
'student_id': body.get('student_id') or False,
|
||||||
|
'batch_id': body.get('batch_id') or False,
|
||||||
|
'status': 'assigned',
|
||||||
|
}
|
||||||
|
|
||||||
|
start_date = body.get('start_date')
|
||||||
|
end_date = body.get('end_date')
|
||||||
|
if start_date:
|
||||||
|
vals['access_start'] = start_date
|
||||||
|
if end_date:
|
||||||
|
vals['access_end'] = end_date
|
||||||
|
|
||||||
|
rec = request.env['encoach.exam.assignment'].sudo().create(vals)
|
||||||
|
return _json_response(_assignment_to_dict(rec), 201)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('assignment create failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
@http.route('/api/assignments/<int:assignment_id>', type='http',
|
||||||
|
auth='none', methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def get_assignment(self, assignment_id, **kw):
|
||||||
|
try:
|
||||||
|
rec = request.env['encoach.exam.assignment'].sudo().browse(assignment_id)
|
||||||
|
if not rec.exists():
|
||||||
|
return _error_response('Assignment not found', 404)
|
||||||
|
return _json_response(_assignment_to_dict(rec))
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('assignment get failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
@@ -43,6 +43,37 @@ def _exam_to_dict(exam):
|
|||||||
|
|
||||||
class EncoachCustomExamController(http.Controller):
|
class EncoachCustomExamController(http.Controller):
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/exam/custom/list
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/exam/custom/list', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def list_exams(self, **kw):
|
||||||
|
try:
|
||||||
|
Exam = request.env['encoach.exam.custom'].sudo()
|
||||||
|
search = kw.get('search', '').strip()
|
||||||
|
domain = []
|
||||||
|
if search:
|
||||||
|
domain = [('title', 'ilike', search)]
|
||||||
|
status = kw.get('status', '').strip()
|
||||||
|
if status:
|
||||||
|
domain.append(('status', '=', status))
|
||||||
|
|
||||||
|
total = Exam.search_count(domain)
|
||||||
|
page, per_page, offset = _paginate(kw)
|
||||||
|
exams = Exam.search(domain, limit=per_page, offset=offset,
|
||||||
|
order='id desc')
|
||||||
|
return _json_response({
|
||||||
|
'items': [_exam_to_dict(e) for e in exams],
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'per_page': per_page,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('custom exam list failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# POST /api/exam/custom/create
|
# POST /api/exam/custom/create
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
80
custom_addons/encoach_exam_template/controllers/rubrics.py
Normal file
80
custom_addons/encoach_exam_template/controllers/rubrics.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_response(data, status=200):
|
||||||
|
return request.make_json_response(data, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
def _rubric_to_dict(rec):
|
||||||
|
criteria_text = rec.criteria or ''
|
||||||
|
criteria_count = 0
|
||||||
|
if criteria_text:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(criteria_text)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
criteria_count = len(parsed)
|
||||||
|
elif isinstance(parsed, dict):
|
||||||
|
criteria_count = len(parsed)
|
||||||
|
else:
|
||||||
|
criteria_count = len([l for l in criteria_text.split('\n') if l.strip()])
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
criteria_count = len([l for l in criteria_text.split('\n') if l.strip()])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': rec.id,
|
||||||
|
'name': rec.name,
|
||||||
|
'skill': rec.skill or '',
|
||||||
|
'exam_type': rec.exam_type or '',
|
||||||
|
'criteria': criteria_count or 1,
|
||||||
|
'criteria_text': criteria_text,
|
||||||
|
'levels': ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'],
|
||||||
|
'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachRubricController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/api/rubrics', type='http', auth='user',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
def list_rubrics(self, **kw):
|
||||||
|
try:
|
||||||
|
Rubric = request.env['encoach.rubric'].sudo()
|
||||||
|
limit = int(kw.get('limit', 50))
|
||||||
|
offset = int(kw.get('offset', 0))
|
||||||
|
records = Rubric.search([], limit=limit, offset=offset,
|
||||||
|
order='create_date desc')
|
||||||
|
total = Rubric.search_count([])
|
||||||
|
return _json_response({
|
||||||
|
'items': [_rubric_to_dict(r) for r in records],
|
||||||
|
'total': total,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('rubrics list failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/rubrics', type='http', auth='user',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
def create_rubric(self, **kw):
|
||||||
|
try:
|
||||||
|
body = json.loads(request.httprequest.data or '{}')
|
||||||
|
name = body.get('name', '').strip()
|
||||||
|
if not name:
|
||||||
|
return _json_response({'error': 'name is required'}, 400)
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'name': name,
|
||||||
|
'skill': body.get('skill', 'writing'),
|
||||||
|
'criteria': body.get('criteria', ''),
|
||||||
|
'exam_type': body.get('exam_type', 'academic'),
|
||||||
|
}
|
||||||
|
rec = Rubric = request.env['encoach.rubric'].sudo().create(vals)
|
||||||
|
return _json_response(_rubric_to_dict(rec), 201)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('rubric create failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
@@ -71,9 +71,9 @@ class PdfGenerator:
|
|||||||
score_header = ['Skill', 'Band Score']
|
score_header = ['Skill', 'Band Score']
|
||||||
score_rows = [score_header]
|
score_rows = [score_header]
|
||||||
for score in scores:
|
for score in scores:
|
||||||
skill_name = getattr(score, 'skill_name', '') or getattr(score, 'name', 'N/A')
|
skill_name = score.skill or 'N/A'
|
||||||
band = getattr(score, 'band_score', '') or getattr(score, 'score', 'N/A')
|
band = score.band_score if score.band_score else 'N/A'
|
||||||
score_rows.append([str(skill_name), str(band)])
|
score_rows.append([str(skill_name).capitalize(), str(band)])
|
||||||
|
|
||||||
if len(score_rows) > 1:
|
if len(score_rows) > 1:
|
||||||
score_table = Table(score_rows, colWidths=[8 * cm, 8 * cm])
|
score_table = Table(score_rows, colWidths=[8 * cm, 8 * cm])
|
||||||
|
|||||||
@@ -303,18 +303,49 @@ class EncoachPlacementController(http.Controller):
|
|||||||
@jwt_required
|
@jwt_required
|
||||||
def speaking_status(self, **kw):
|
def speaking_status(self, **kw):
|
||||||
try:
|
try:
|
||||||
|
session_id = kw.get('session_id')
|
||||||
upload_id = kw.get('upload_id')
|
upload_id = kw.get('upload_id')
|
||||||
if not upload_id:
|
|
||||||
return _error_response('upload_id is required', 400)
|
|
||||||
|
|
||||||
attachment = request.env['ir.attachment'].sudo().browse(int(upload_id))
|
if session_id:
|
||||||
if not attachment.exists():
|
session = request.env['encoach.cat.session'].sudo().browse(int(session_id))
|
||||||
return _error_response('Upload not found', 404)
|
if not session.exists():
|
||||||
|
return _error_response('Session not found', 404)
|
||||||
|
|
||||||
return _json_response({
|
try:
|
||||||
'status': 'completed',
|
from odoo.addons.encoach_ai.services.whisper_service import WhisperService
|
||||||
'transcription': None,
|
whisper = WhisperService(request.env)
|
||||||
})
|
|
||||||
|
attachments = request.env['ir.attachment'].sudo().search([
|
||||||
|
('res_model', '=', 'encoach.cat.session'),
|
||||||
|
('create_uid', '=', request.env.uid),
|
||||||
|
], limit=1, order='create_date desc')
|
||||||
|
|
||||||
|
if attachments and attachments.datas:
|
||||||
|
audio_data = base64.b64decode(attachments.datas)
|
||||||
|
transcript = whisper.transcribe(audio_data, use_api=True)
|
||||||
|
return _json_response({
|
||||||
|
'status': 'scored',
|
||||||
|
'transcription': transcript.get('text', ''),
|
||||||
|
})
|
||||||
|
except Exception as ai_err:
|
||||||
|
_logger.warning('Whisper transcription not available: %s', ai_err)
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'status': 'processing',
|
||||||
|
'transcription': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
if upload_id:
|
||||||
|
attachment = request.env['ir.attachment'].sudo().browse(int(upload_id))
|
||||||
|
if not attachment.exists():
|
||||||
|
return _error_response('Upload not found', 404)
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'status': 'processing',
|
||||||
|
'transcription': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _error_response('session_id or upload_id is required', 400)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception('speaking status failed')
|
_logger.exception('speaking status failed')
|
||||||
|
|||||||
@@ -369,6 +369,41 @@ class EncoachExamSessionController(http.Controller):
|
|||||||
_logger.exception('submit failed')
|
_logger.exception('submit failed')
|
||||||
return _error_response(str(e), 500)
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/exam/<int:exam_id>/status
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/exam/<int:exam_id>/status', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def get_status(self, exam_id, **kw):
|
||||||
|
try:
|
||||||
|
uid = request.env.user.id
|
||||||
|
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||||
|
attempt = Attempt.search([
|
||||||
|
('student_id', '=', uid),
|
||||||
|
('exam_id', '=', int(exam_id)),
|
||||||
|
], limit=1, order='id desc')
|
||||||
|
|
||||||
|
if not attempt:
|
||||||
|
return _json_response({
|
||||||
|
'status': 'not_started',
|
||||||
|
'scores_available': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
scores_available = attempt.status in ('released', 'scored')
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'status': attempt.status,
|
||||||
|
'scores_available': scores_available,
|
||||||
|
'attempt_id': attempt.id,
|
||||||
|
'completed_at': attempt.completed_at,
|
||||||
|
'released_at': attempt.released_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('get_status failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# GET /api/exam/<int:exam_id>/results
|
# GET /api/exam/<int:exam_id>/results
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ def _band_to_cefr(band):
|
|||||||
|
|
||||||
|
|
||||||
def _recompute_bands(attempt):
|
def _recompute_bands(attempt):
|
||||||
"""Recompute skill and overall bands after grading updates."""
|
"""Recompute skill and overall bands after grading updates, incorporating rubric sub-scores."""
|
||||||
Answer = request.env['encoach.student.answer'].sudo()
|
Answer = request.env['encoach.student.answer'].sudo()
|
||||||
Score = request.env['encoach.score'].sudo()
|
Score = request.env['encoach.score'].sudo()
|
||||||
|
Feedback = request.env['encoach.feedback'].sudo()
|
||||||
|
|
||||||
skill_totals = {}
|
skill_totals = {}
|
||||||
skill_max = {}
|
skill_max = {}
|
||||||
@@ -36,6 +37,24 @@ def _recompute_bands(attempt):
|
|||||||
skill = q.skill or 'general'
|
skill = q.skill or 'general'
|
||||||
skill_totals.setdefault(skill, 0.0)
|
skill_totals.setdefault(skill, 0.0)
|
||||||
skill_max.setdefault(skill, 0.0)
|
skill_max.setdefault(skill, 0.0)
|
||||||
|
|
||||||
|
fb = Feedback.search([
|
||||||
|
('attempt_id', '=', attempt.id),
|
||||||
|
('question_id', '=', q.id),
|
||||||
|
], limit=1, order='id desc')
|
||||||
|
|
||||||
|
if fb and fb.rubric_scores:
|
||||||
|
try:
|
||||||
|
rubric_data = json.loads(fb.rubric_scores)
|
||||||
|
if isinstance(rubric_data, dict) and rubric_data:
|
||||||
|
rubric_avg = sum(float(v) for v in rubric_data.values()) / len(rubric_data)
|
||||||
|
rubric_score = (rubric_avg / 9.0) * (q.marks or 1.0)
|
||||||
|
skill_totals[skill] += rubric_score
|
||||||
|
skill_max[skill] += q.marks or 1.0
|
||||||
|
continue
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
skill_totals[skill] += ans.score or 0.0
|
skill_totals[skill] += ans.score or 0.0
|
||||||
skill_max[skill] += q.marks or 1.0
|
skill_max[skill] += q.marks or 1.0
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,24 @@ class EncoachSignupController(http.Controller):
|
|||||||
|
|
||||||
_logger.info('Registration OTP for %s: %s', email, otp_code)
|
_logger.info('Registration OTP for %s: %s', email, otp_code)
|
||||||
|
|
||||||
|
try:
|
||||||
|
template = request.env.ref('encoach_signup.email_template_otp', raise_if_not_found=False)
|
||||||
|
if template:
|
||||||
|
template.sudo().with_context(otp_code=otp_code).send_mail(user.id, force_send=True)
|
||||||
|
else:
|
||||||
|
mail_values = {
|
||||||
|
'subject': 'EnCoach - Verify Your Email',
|
||||||
|
'email_from': request.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'mail.catchall.email', 'noreply@encoach.com'),
|
||||||
|
'email_to': email,
|
||||||
|
'body_html': f'<p>Welcome to EnCoach!</p>'
|
||||||
|
f'<p>Your verification code is: <strong>{otp_code}</strong></p>'
|
||||||
|
f'<p>This code expires in 15 minutes.</p>',
|
||||||
|
}
|
||||||
|
request.env['mail.mail'].sudo().create(mail_values).send()
|
||||||
|
except Exception as mail_err:
|
||||||
|
_logger.warning('OTP email failed for %s: %s', email, mail_err)
|
||||||
|
|
||||||
return _json_response({
|
return _json_response({
|
||||||
'message': 'Registration successful. Please verify your email.',
|
'message': 'Registration successful. Please verify your email.',
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
@@ -205,12 +223,116 @@ class EncoachSignupController(http.Controller):
|
|||||||
|
|
||||||
_logger.info('Resend OTP for %s: %s', email, otp_code)
|
_logger.info('Resend OTP for %s: %s', email, otp_code)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = request.env['res.users'].sudo().search(
|
||||||
|
[('login', '=', email)], limit=1)
|
||||||
|
if user:
|
||||||
|
mail_values = {
|
||||||
|
'subject': 'EnCoach - Your New Verification Code',
|
||||||
|
'email_from': request.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'mail.catchall.email', 'noreply@encoach.com'),
|
||||||
|
'email_to': email,
|
||||||
|
'body_html': f'<p>Your new verification code is: <strong>{otp_code}</strong></p>'
|
||||||
|
f'<p>This code expires in 15 minutes.</p>',
|
||||||
|
}
|
||||||
|
request.env['mail.mail'].sudo().create(mail_values).send()
|
||||||
|
except Exception as mail_err:
|
||||||
|
_logger.warning('Resend OTP email failed for %s: %s', email, mail_err)
|
||||||
|
|
||||||
return _json_response({'message': 'OTP resent'})
|
return _json_response({'message': 'OTP resent'})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception('resend_otp failed')
|
_logger.exception('resend_otp failed')
|
||||||
return _error_response(str(e), 500)
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/reset/sendVerification
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/reset/sendVerification', type='http', auth='public',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
def reset_send_verification(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
email = (body.get('email') or '').strip().lower()
|
||||||
|
if not email:
|
||||||
|
return _error_response('email is required', 400)
|
||||||
|
|
||||||
|
user = request.env['res.users'].sudo().search(
|
||||||
|
[('login', '=', email)], limit=1)
|
||||||
|
if not user:
|
||||||
|
return _json_response({'message': 'If the email exists, a reset code has been sent.'})
|
||||||
|
|
||||||
|
otp_code = ''.join(random.choices(string.digits, k=6))
|
||||||
|
otp_hash = hashlib.sha256(otp_code.encode()).hexdigest()
|
||||||
|
expires_at = fields.Datetime.now() + timedelta(minutes=15)
|
||||||
|
|
||||||
|
request.env['encoach.otp'].sudo().create({
|
||||||
|
'email': email,
|
||||||
|
'otp_hash': otp_hash,
|
||||||
|
'expires_at': expires_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
template = request.env.ref('encoach_signup.email_template_password_reset', raise_if_not_found=False)
|
||||||
|
if template:
|
||||||
|
template.sudo().with_context(otp_code=otp_code).send_mail(user.id, force_send=True)
|
||||||
|
else:
|
||||||
|
mail_values = {
|
||||||
|
'subject': 'EnCoach Password Reset Code',
|
||||||
|
'email_from': request.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'mail.catchall.email', 'noreply@encoach.com'),
|
||||||
|
'email_to': email,
|
||||||
|
'body_html': f'<p>Your password reset code is: <strong>{otp_code}</strong></p>'
|
||||||
|
f'<p>This code expires in 15 minutes.</p>',
|
||||||
|
}
|
||||||
|
request.env['mail.mail'].sudo().create(mail_values).send()
|
||||||
|
except Exception as mail_err:
|
||||||
|
_logger.warning('Password reset email failed for %s: %s', email, mail_err)
|
||||||
|
|
||||||
|
_logger.info('Password reset OTP for %s: %s', email, otp_code)
|
||||||
|
return _json_response({'message': 'If the email exists, a reset code has been sent.'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('reset_send_verification failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/auth/invite/set-password
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/auth/invite/set-password', type='http', auth='public',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
def invite_set_password(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
token = body.get('token', '').strip()
|
||||||
|
password = body.get('password', '')
|
||||||
|
|
||||||
|
if not token or not password:
|
||||||
|
return _error_response('token and password are required', 400)
|
||||||
|
|
||||||
|
Invite = request.env['encoach.invite'].sudo()
|
||||||
|
invite = Invite.search([('token', '=', token), ('used', '=', False)], limit=1)
|
||||||
|
|
||||||
|
if not invite:
|
||||||
|
return _error_response('Invalid or expired invitation token', 400)
|
||||||
|
|
||||||
|
user = invite.user_id
|
||||||
|
if not user:
|
||||||
|
return _error_response('No user associated with this invitation', 400)
|
||||||
|
|
||||||
|
user.sudo().write({'password': password})
|
||||||
|
invite.write({'used': True})
|
||||||
|
user.sudo().write({
|
||||||
|
'account_status': 'activated',
|
||||||
|
'first_login': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({'message': 'Password set successfully'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('invite_set_password failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# GET /api/onboarding/goals
|
# GET /api/onboarding/goals
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -342,3 +464,32 @@ class EncoachSignupController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception('captcha_config failed')
|
_logger.exception('captcha_config failed')
|
||||||
return _error_response(str(e), 500)
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/subscription/trial
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/subscription/trial', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def start_trial(self, **kw):
|
||||||
|
try:
|
||||||
|
user = request.env.user
|
||||||
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
trial_days = int(ICP.get_param('encoach.trial_duration_days', '14'))
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
trial_end = fields.Datetime.now() + timedelta(days=trial_days)
|
||||||
|
|
||||||
|
user.sudo().write({
|
||||||
|
'account_status': 'trial',
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'message': f'Trial activated for {trial_days} days',
|
||||||
|
'trial_end': str(trial_end),
|
||||||
|
'status': 'trial',
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('start_trial failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|||||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: odoo
|
||||||
|
POSTGRES_PASSWORD: odoo
|
||||||
|
POSTGRES_DB: encoach
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
odoo:
|
||||||
|
build: .
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
ports:
|
||||||
|
- "8069:8069"
|
||||||
|
volumes:
|
||||||
|
- odoo-data:/var/lib/odoo
|
||||||
|
- ./custom_addons:/opt/odoo/custom_addons
|
||||||
|
environment:
|
||||||
|
- HOST=db
|
||||||
|
- USER=odoo
|
||||||
|
- PASSWORD=odoo
|
||||||
|
command: ["odoo", "--config=/etc/odoo/odoo.conf"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
odoo-data:
|
||||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
PyJWT>=2.8.0
|
||||||
|
openai>=1.12.0
|
||||||
|
boto3>=1.34.0
|
||||||
|
requests>=2.31.0
|
||||||
|
qrcode>=7.4.2
|
||||||
|
reportlab>=4.0.0
|
||||||
|
Pillow>=10.0.0
|
||||||
|
pgvector>=0.2.0
|
||||||
|
psycopg2-binary>=2.9.0
|
||||||
Reference in New Issue
Block a user