commit 982d4bca30144d5fd716f862cfadaba0c05a2e04 Author: Yamen Ahmad Date: Sat Apr 11 15:44:20 2026 +0400 feat: initial backend codebase — EnCoach v3 Complete Odoo 19 backend with 25 custom addons: - encoach_core: user/entity/role management - encoach_api: REST API + JWT auth - encoach_ai: OpenAI integration, AI settings, generation - encoach_ai_course: AI-powered English & IELTS course generation - encoach_exam_template/session: exam creation, structures, sessions - encoach_scoring: AI auto-grading + manual approval - encoach_vector: pgvector RAG integration - encoach_adaptive: adaptive learning engine - encoach_placement: placement testing - encoach_taxonomy/resources: content taxonomy & resource management - Plus 14 more modules for courses, branding, portal, etc. Includes docs: user guide, generation report, developer workflow. Made-with: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..dc5805fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.env +*.egg-info/ +.vscode/ +.idea/ +*.swp +*.swo diff --git a/custom_addons/clarity_backend_theme_bits/__init__.py b/custom_addons/clarity_backend_theme_bits/__init__.py new file mode 100755 index 00000000..72f4562d --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/__init__.py @@ -0,0 +1 @@ +from . import controller \ No newline at end of file diff --git a/custom_addons/clarity_backend_theme_bits/__manifest__.py b/custom_addons/clarity_backend_theme_bits/__manifest__.py new file mode 100755 index 00000000..772a7922 --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/__manifest__.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +################################################################################# +# Author : Terabits Technolab () +# Copyright(c): 2021-25 +# All Rights Reserved. +# +# This module is copyright property of the author mentioned above. +# You can`t redistribute it and/or modify it. +# +################################################################################# + +{ + "name": "Clarity Backend Theme for community", + "version": "19.0.1.0.2", + 'author': "Terabits Technolab", + 'summary': """ + Clarity backend theme + Odoo Backend Theme, Odoo Community Backend Theme, Web backend Theme, Web Responsive Odoo Theme, New theme design, New design, Multi Level Menu, + Web Responsive , Odoo Theme, Odoo Modern Theme, Odoo Modern Backend Theme Odoo, Advance Theme Backend Advanced, Left sidebar menu, + All in one, New advanced Odoo Menu, Sidebar apps, Web menu, Odoo backend menu, Web Responsive menu, Sidebar dark, + Advance Menu Odoo App Menu Apps, Advanced Apps Menu, Elegant Menu, Web App Menu Backend, Menu Odoo Backend, Collapse Menu, Light Sidebar, + Expand Menu, Collapsed Menu, Expanded Menu, New Style Menus, Advanced Sidebar Menu, Advance Sidebar Menu, Responsive Menu Sidebar, Sidebar Theme, + Responsive Sidebar, Hide menu, Show Menu, Hide Sidebar, Show Sidebar, Toggle Menu, Toggle Sidebar, Menu Theme, + Quick Backend Menu, Dropdown Menu, Parent Menus, Shortcut Menus, Menu Icons, Collapsible menu Odoo, + Menu Dynamic Sidebar, Advanced Menu, Backend Odoo Web, Elegant Theme Simple, Advance List View Manager, + Arc Backend Theme, Fully Functional Theme, Flexible Backend Theme, Fast Backend Theme, Advance Material Backend Theme, Customizable Backend Theme, + Attractive Theme for Backend, Elegant Backend Theme, Responsive Web Client, Backend UI, Mobile Responsive for Odoo Community, + Flexible Enterprise Theme, Enterprise Backend Theme + """, + 'description': """ + Clarity backend theme + Odoo Backend Theme, Odoo Community Backend Theme, Web backend Theme, Web Responsive Odoo Theme, New theme design, New design, Multi Level Menu, + Web Responsive , Odoo Theme, Odoo Modern Theme, Odoo Modern Backend Theme Odoo, Advance Theme Backend Advanced, Left sidebar menu, + All in one, New advanced Odoo Menu, Sidebar apps, Web menu, Odoo backend menu, Web Responsive menu, Sidebar dark, + Advance Menu Odoo App Menu Apps, Advanced Apps Menu, Elegant Menu, Web App Menu Backend, Menu Odoo Backend, Collapse Menu, Light Sidebar, + Expand Menu, Collapsed Menu, Expanded Menu, New Style Menus, Advanced Sidebar Menu, Advance Sidebar Menu, Responsive Menu Sidebar, Sidebar Theme, + Responsive Sidebar, Hide menu, Show Menu, Hide Sidebar, Show Sidebar, Toggle Menu, Toggle Sidebar, Menu Theme, + Quick Backend Menu, Dropdown Menu, Parent Menus, Shortcut Menus, Menu Icons, Collapsible menu Odoo, + Menu Dynamic Sidebar, Advanced Menu, Backend Odoo Web, Elegant Theme Simple, Advance List View Manager, + Arc Backend Theme, Fully Functional Theme, Flexible Backend Theme, Fast Backend Theme, Advance Material Backend Theme, Customizable Backend Theme, + Attractive Theme for Backend, Elegant Backend Theme, Responsive Web Client, Backend UI, Mobile Responsive for Odoo Community, + Flexible Enterprise Theme, Enterprise Backend Theme + """, + "sequence": 7, + "license": "OPL-1", + "category": "Themes/Backend", + "website": "https://www.terabits.xyz/apps/19.0/clarity_backend_theme_bits", + "depends": ["web"], + "data": [ + 'views/res_config_setting.xml', + 'views/res_users.xml', + 'views/webclient_templates.xml' + ], + "assets": { + "web.assets_frontend": [ + 'clarity_backend_theme_bits/static/src/scss/login.scss' + ], + "web.assets_backend": [ + 'clarity_backend_theme_bits/static/src/xml/WebClient.xml', + 'clarity_backend_theme_bits/static/src/xml/navbar/sidebar.xml', + 'clarity_backend_theme_bits/static/src/xml/systray_items/user_menu.xml', + 'clarity_backend_theme_bits/static/src/js/SidebarBottom.js', + 'clarity_backend_theme_bits/static/src/js/WebClient.js', + 'clarity_backend_theme_bits/static/src/scss/layout.scss', + 'clarity_backend_theme_bits/static/src/scss/navbar.scss', + 'clarity_backend_theme_bits/static/src/js/navbar.js', + ], + }, + 'installable': True, + 'application': True, + 'auto_install': False, + 'images': [ + 'static/description/logo.gif', + 'static/description/theme_screenshot.gif', + ], +} diff --git a/custom_addons/clarity_backend_theme_bits/controller/__init__.py b/custom_addons/clarity_backend_theme_bits/controller/__init__.py new file mode 100755 index 00000000..deec4a8b --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/controller/__init__.py @@ -0,0 +1 @@ +from . import main \ No newline at end of file diff --git a/custom_addons/clarity_backend_theme_bits/controller/main.py b/custom_addons/clarity_backend_theme_bits/controller/main.py new file mode 100755 index 00000000..0acb95a6 --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/controller/main.py @@ -0,0 +1,17 @@ +import datetime +import pytz +from odoo import http, models, fields, api, tools +from odoo.http import request + +class BackThemeBits(http.Controller): + @http.route(['/get/menu_data'], type='jsonrpc', auth='public') + def get_irmenu_icondata(self, **kw): + menuobj = request.env['ir.ui.menu'] + menu_recs = request.env['ir.ui.menu'].sudo().search( + [('id', 'in', kw.get('menu_ids'))]) + + app_menu_dict = {} + for menu in menu_recs: + menu_dict = menu.read(set(menuobj._fields)) + app_menu_dict[menu.id] = menu_dict + return app_menu_dict \ No newline at end of file diff --git a/custom_addons/clarity_backend_theme_bits/static/description/icon.png b/custom_addons/clarity_backend_theme_bits/static/description/icon.png new file mode 100755 index 00000000..873c613f Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/icon.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/1.png b/custom_addons/clarity_backend_theme_bits/static/description/images/1.png new file mode 100755 index 00000000..14725d09 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/1.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/2.png b/custom_addons/clarity_backend_theme_bits/static/description/images/2.png new file mode 100755 index 00000000..b2774c6c Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/2.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/3.png b/custom_addons/clarity_backend_theme_bits/static/description/images/3.png new file mode 100755 index 00000000..c0c1ce1e Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/3.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/4.png b/custom_addons/clarity_backend_theme_bits/static/description/images/4.png new file mode 100755 index 00000000..8b22c4d9 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/4.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/5.png b/custom_addons/clarity_backend_theme_bits/static/description/images/5.png new file mode 100755 index 00000000..b7288748 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/5.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/6.png b/custom_addons/clarity_backend_theme_bits/static/description/images/6.png new file mode 100755 index 00000000..07b4536b Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/6.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/7.png b/custom_addons/clarity_backend_theme_bits/static/description/images/7.png new file mode 100755 index 00000000..9ce68515 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/7.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/8.png b/custom_addons/clarity_backend_theme_bits/static/description/images/8.png new file mode 100755 index 00000000..c71be246 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/8.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/9.png b/custom_addons/clarity_backend_theme_bits/static/description/images/9.png new file mode 100755 index 00000000..deeb181a Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/9.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/analytix_banner.png b/custom_addons/clarity_backend_theme_bits/static/description/images/analytix_banner.png new file mode 100755 index 00000000..ecf24122 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/analytix_banner.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/banner.png b/custom_addons/clarity_backend_theme_bits/static/description/images/banner.png new file mode 100755 index 00000000..a37266e5 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/banner.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/blog_feat_bg_02.png b/custom_addons/clarity_backend_theme_bits/static/description/images/blog_feat_bg_02.png new file mode 100755 index 00000000..972b822b Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/blog_feat_bg_02.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/blog_post_bg_01.png b/custom_addons/clarity_backend_theme_bits/static/description/images/blog_post_bg_01.png new file mode 100755 index 00000000..1dbb707a Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/blog_post_bg_01.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/blog_ss_bg_03.png b/custom_addons/clarity_backend_theme_bits/static/description/images/blog_ss_bg_03.png new file mode 100755 index 00000000..a91b9677 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/blog_ss_bg_03.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/blogpost_header_img.png b/custom_addons/clarity_backend_theme_bits/static/description/images/blogpost_header_img.png new file mode 100755 index 00000000..e7d21621 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/blogpost_header_img.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/clarity_pro_banner.png b/custom_addons/clarity_backend_theme_bits/static/description/images/clarity_pro_banner.png new file mode 100755 index 00000000..d6330b1f Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/clarity_pro_banner.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/faqs_icon.png b/custom_addons/clarity_backend_theme_bits/static/description/images/faqs_icon.png new file mode 100644 index 00000000..c4538179 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/faqs_icon.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/icon_img.png b/custom_addons/clarity_backend_theme_bits/static/description/images/icon_img.png new file mode 100755 index 00000000..12db9a72 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/icon_img.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/main-b.png b/custom_addons/clarity_backend_theme_bits/static/description/images/main-b.png new file mode 100755 index 00000000..a988f5d2 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/main-b.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/new-sam-banner.gif b/custom_addons/clarity_backend_theme_bits/static/description/images/new-sam-banner.gif new file mode 100755 index 00000000..4feda0a7 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/new-sam-banner.gif differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/release_date_bg.png b/custom_addons/clarity_backend_theme_bits/static/description/images/release_date_bg.png new file mode 100644 index 00000000..45df6938 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/release_date_bg.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/release_icon.png b/custom_addons/clarity_backend_theme_bits/static/description/images/release_icon.png new file mode 100644 index 00000000..6cd33e2e Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/release_icon.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/simplify_banner_bg.png b/custom_addons/clarity_backend_theme_bits/static/description/images/simplify_banner_bg.png new file mode 100755 index 00000000..1b35c619 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/simplify_banner_bg.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/images/suggest-app-bg.png b/custom_addons/clarity_backend_theme_bits/static/description/images/suggest-app-bg.png new file mode 100644 index 00000000..04590264 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/images/suggest-app-bg.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/index.html b/custom_addons/clarity_backend_theme_bits/static/description/index.html new file mode 100755 index 00000000..aa2d45e5 --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/static/description/index.html @@ -0,0 +1,757 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + +
+
+ + +
+
+
+ +
+ +
+

+ Clarity Backend Theme +

+

+ Odoo community backend theme +

+

+ The clarity backend theme have amazing appearance on any device and is easy to
+ switch between all screen sizes. Clicking on any menu is made simple with an
+ contemporary and modern sidebar. You can browse across different apps using
+ elegant sidebar menu.The theme is designed, keeping in mind the delivery of
+ great user experience and user-friendly interface to your users. +

+
+ +
+
+
+
+

+ App Drawer design for Community Addition +

+
+
+ +
+
+

+ Login & Signup Redesign +

+
+
+ +
+
+

+ Customized Discuss view design +

+
+
+ +
+
+

+ Show brand's logo in menu +

+
+
+ +
+
+

+ Customzed List, Form, Kanban view design +

+
+
+ +
+
+

+ Responsive sidebar design +

+
+
+ + +
+
+ +
+ +
+
+
+
+
+ + + + + +
+
+ + +
+
+ +
+
+
+ +
+
+

+ Custom discuss view +

+
+
+ +
+ +
+
+ +
+
+
+ +
+
+

+ Interactive and clean design +

+
+
+ +
+ +
+
+ +
+
+
+ +
+
+

+ Custom list view design +

+
+
+ +
+ +
+
+ +
+
+
+ +
+
+

+ Custom kanban view design +

+
+
+ +
+ +
+
+ +
+
+
+ +
+
+

+ Custom form view design +

+
+
+ +
+ +
+
+ +
+
+
+ +
+
+

+ Custom sign up design +

+
(The login/signup design will appear only the backend login/signup view. + It will not appear in the website login/signup view.) +
+
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+

+ Custom login design +

+
+
+ +
+ +
+
+ +
+
+
+ +
+
+

+ Custom reset form design +

+
+
+ +
+ +
+
+ +
+
+
+ +
+
+

+ Set company logo +

+
+
+ +
+ +
+
+ +
+
+
+
+ + + + + + + + + + + +
+
+ + +
+ +
+ +
+

+ Clarity Pro Backend Theme

+

+ The clarity backend theme have amazing appearance on any device and is easy to switch between all screen sizes. + The theme is designed, keeping in mind the delivery of great user experience and user-friendly interface to your users. +

+ +
+ + Main menu for community + + + Sidebar menu/top menu + + + Multi tabs management +
+ +
+ + Global search + + + Global search in record + + + Global search in menu +
+ +
+ + Multi font style + + + Multi icon style + + + Custom color schemes +
+ +
+ + Full-screen switch + + + Bookmark management + + + Attractive dark mode +
+ +
+ + Kanban & list view style + + + Checkbox & radio style + + + RTL support +
+ + +
+ +
+ + + +
+
+ +
+
+
+ + + + + + + + + + +
+
+ + +
+
+
+

Recommended Apps

+
+ +
+
+ + + + + + + + + + + + +
+
+
+
+
+
+ + + + +
+
+
+
+
+
+ +
+
+

+ Need any help for this module? +

+

+ Contact us + info@terabits.xyz + for your queries +

+
+
+
+
+ + + +
+
diff --git a/custom_addons/clarity_backend_theme_bits/static/description/logo.gif b/custom_addons/clarity_backend_theme_bits/static/description/logo.gif new file mode 100755 index 00000000..33625610 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/logo.gif differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/access-group.png b/custom_addons/clarity_backend_theme_bits/static/description/modules/access-group.png new file mode 100644 index 00000000..665d6167 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/access-group.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/app-1.png b/custom_addons/clarity_backend_theme_bits/static/description/modules/app-1.png new file mode 100644 index 00000000..c3e7a454 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/app-1.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/app-2.png b/custom_addons/clarity_backend_theme_bits/static/description/modules/app-2.png new file mode 100644 index 00000000..bcc93b87 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/app-2.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/app-3.png b/custom_addons/clarity_backend_theme_bits/static/description/modules/app-3.png new file mode 100644 index 00000000..0086b5c2 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/app-3.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/app-4.png b/custom_addons/clarity_backend_theme_bits/static/description/modules/app-4.png new file mode 100644 index 00000000..4e5547cc Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/app-4.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/app-5.png b/custom_addons/clarity_backend_theme_bits/static/description/modules/app-5.png new file mode 100644 index 00000000..a4f66967 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/app-5.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/app-6.png b/custom_addons/clarity_backend_theme_bits/static/description/modules/app-6.png new file mode 100644 index 00000000..426afeeb Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/app-6.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/import-export.png b/custom_addons/clarity_backend_theme_bits/static/description/modules/import-export.png new file mode 100644 index 00000000..6a27df98 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/import-export.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/law-firm.gif b/custom_addons/clarity_backend_theme_bits/static/description/modules/law-firm.gif new file mode 100644 index 00000000..fef50198 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/law-firm.gif differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/login-withany-user.png b/custom_addons/clarity_backend_theme_bits/static/description/modules/login-withany-user.png new file mode 100644 index 00000000..5aed9edf Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/login-withany-user.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/login_user_with_user_audit.png b/custom_addons/clarity_backend_theme_bits/static/description/modules/login_user_with_user_audit.png new file mode 100644 index 00000000..a365c566 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/login_user_with_user_audit.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/new-sam-banner.gif b/custom_addons/clarity_backend_theme_bits/static/description/modules/new-sam-banner.gif new file mode 100644 index 00000000..4feda0a7 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/new-sam-banner.gif differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/real-estate.gif b/custom_addons/clarity_backend_theme_bits/static/description/modules/real-estate.gif new file mode 100644 index 00000000..593c44f5 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/real-estate.gif differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/sam.gif b/custom_addons/clarity_backend_theme_bits/static/description/modules/sam.gif new file mode 100644 index 00000000..883f004e Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/sam.gif differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/soft-restrict.png b/custom_addons/clarity_backend_theme_bits/static/description/modules/soft-restrict.png new file mode 100644 index 00000000..89f2a9e3 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/soft-restrict.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/user-audit.gif b/custom_addons/clarity_backend_theme_bits/static/description/modules/user-audit.gif new file mode 100644 index 00000000..c0e2f678 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/user-audit.gif differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/modules/wp-connector.png b/custom_addons/clarity_backend_theme_bits/static/description/modules/wp-connector.png new file mode 100644 index 00000000..38527433 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/modules/wp-connector.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/new-sam-banner.gif b/custom_addons/clarity_backend_theme_bits/static/description/new-sam-banner.gif new file mode 100755 index 00000000..4feda0a7 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/new-sam-banner.gif differ diff --git a/custom_addons/clarity_backend_theme_bits/static/description/theme_screenshot.gif b/custom_addons/clarity_backend_theme_bits/static/description/theme_screenshot.gif new file mode 100755 index 00000000..cf48b8fa Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/description/theme_screenshot.gif differ diff --git a/custom_addons/clarity_backend_theme_bits/static/img/logo.png b/custom_addons/clarity_backend_theme_bits/static/img/logo.png new file mode 100755 index 00000000..0bb577d9 Binary files /dev/null and b/custom_addons/clarity_backend_theme_bits/static/img/logo.png differ diff --git a/custom_addons/clarity_backend_theme_bits/static/src/js/Headerbar.js b/custom_addons/clarity_backend_theme_bits/static/src/js/Headerbar.js new file mode 100755 index 00000000..541c44cf --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/static/src/js/Headerbar.js @@ -0,0 +1,193 @@ +/** @odoo-module **/ + +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { useService } from "@web/core/utils/hooks"; +import { registry } from "@web/core/registry"; +import { debounce } from "@web/core/utils/timing"; +import { ErrorHandler } from "@web/core/utils/components"; +import { NavBarDropdownItem,MenuDropdown } from '@web/static/src/webclient/navbar/navbar'; +import { + Component, + onWillDestroy, + useExternalListener, + useEffect, + useRef, + onWillUnmount, +} from "@odoo/owl"; +const systrayRegistry = registry.category("systray"); + +const getBoundingClientRect = Element.prototype.getBoundingClientRect; + +export class HeaderBar extends NavBar { + setup() { + this.currentAppSectionsExtra = []; + this.actionService = useService("action"); + this.menuService = useService("menu"); + this.root = useRef("root"); + this.appSubMenus = useRef("appSubMenus"); + const debouncedAdapt = debounce(this.adapt.bind(this), 250); + onWillDestroy(() => debouncedAdapt.cancel()); + useExternalListener(window, "resize", debouncedAdapt); + + let adaptCounter = 0; + const renderAndAdapt = () => { + adaptCounter++; + this.render(); + }; + + systrayRegistry.addEventListener("UPDATE", renderAndAdapt); + this.env.bus.addEventListener("MENUS:APP-CHANGED", renderAndAdapt); + + onWillUnmount(() => { + systrayRegistry.removeEventListener("UPDATE", renderAndAdapt); + this.env.bus.removeEventListener("MENUS:APP-CHANGED", renderAndAdapt); + }); + + // We don't want to adapt every time we are patched + // rather, we adapt only when menus or systrays have changed. + useEffect( + () => { + this.adapt(); + }, + () => [adaptCounter] + ); + } + + handleItemError(error, item) { + // remove the faulty component + item.isDisplayed = () => false; + Promise.resolve().then(() => { + throw error; + }); + } + + get currentApp() { + return this.menuService.getCurrentApp(); + } + + get currentAppSections() { + return ( + (this.currentApp && this.menuService.getMenuAsTree(this.currentApp.id).childrenTree) || + [] + ); + } + + // This dummy setter is only here to prevent conflicts between the + // Enterprise NavBar extension and the Website NavBar patch. + set currentAppSections(_) {} + + get systrayItems() { + return systrayRegistry + .getEntries() + .map(([key, value]) => ({ key, ...value })) + .filter((item) => ("isDisplayed" in item ? item.isDisplayed(this.env) : true)) + .reverse(); + } + + // This dummy setter is only here to prevent conflicts between the + // Enterprise NavBar extension and the Website NavBar patch. + set systrayItems(_) {} + + /** + * Adapt will check the available width for the app sections to get displayed. + * If not enough space is available, it will replace by a "more" menu + * the least amount of app sections needed trying to fit the width. + * + * NB: To compute the widths of the actual app sections, a render needs to be done upfront. + * By the end of this method another render may occur depending on the adaptation result. + */ + async adapt() { + if (!this.root.el) { + /** @todo do we still need this check? */ + // currently, the promise returned by 'render' is resolved at the end of + // the rendering even if the component has been destroyed meanwhile, so we + // may get here and have this.el unset + return; + } + + // ------- Initialize ------- + // Get the sectionsMenu + const sectionsMenu = this.appSubMenus.el; + if (!sectionsMenu) { + // No need to continue adaptations if there is no sections menu. + return; + } + + // Save initial state to further check if new render has to be done. + const initialAppSectionsExtra = this.currentAppSectionsExtra; + const firstInitialAppSectionExtra = [...initialAppSectionsExtra].shift(); + const initialAppId = firstInitialAppSectionExtra && firstInitialAppSectionExtra.appID; + + // Restore (needed to get offset widths) + const sections = [ + ...sectionsMenu.querySelectorAll(":scope > *:not(.o_menu_sections_more)"), + ]; + for (const section of sections) { + section.classList.remove("d-none"); + } + this.currentAppSectionsExtra = []; + + // ------- Check overflowing sections ------- + // use getBoundingClientRect to get unrounded values for width in order to avoid rounding problem + // with offsetWidth. + const sectionsAvailableWidth = getBoundingClientRect.call(sectionsMenu).width; + const sectionsTotalWidth = sections.reduce( + (sum, s) => sum + getBoundingClientRect.call(s).width, + 0 + ); + if (sectionsAvailableWidth < sectionsTotalWidth) { + // Sections are overflowing + // Initial width is harcoded to the width the more menu dropdown will take + let width = 46; + for (const section of sections) { + if (sectionsAvailableWidth < width + section.offsetWidth) { + // Last sections are overflowing + const overflowingSections = sections.slice(sections.indexOf(section)); + overflowingSections.forEach((s) => { + // Hide from normal menu + s.classList.add("d-none"); + // Show inside "more" menu + const sectionId = + s.dataset.section || + s.querySelector("[data-section]").getAttribute("data-section"); + const currentAppSection = this.currentAppSections.find( + (appSection) => appSection.id.toString() === sectionId + ); + this.currentAppSectionsExtra.push(currentAppSection); + }); + break; + } + width += section.offsetWidth; + } + } + + // ------- Final rendering ------- + const firstCurrentAppSectionExtra = [...this.currentAppSectionsExtra].shift(); + const currentAppId = firstCurrentAppSectionExtra && firstCurrentAppSectionExtra.appID; + if ( + initialAppSectionsExtra.length === this.currentAppSectionsExtra.length && + initialAppId === currentAppId + ) { + // Do not render if more menu items stayed the same. + return; + } + return this.render(); + } + + onNavBarDropdownItemSelection(menu) { + if (menu) { + this.menuService.selectMenu(menu); + } + } + + getMenuItemHref(payload) { + const parts = [`menu_id=${payload.id}`]; + if (payload.actionID) { + parts.push(`action=${payload.actionID}`); + } + return "#" + parts.join("&"); + } +} +HeaderBar.template = "web.NavBar"; +HeaderBar.components = { Dropdown, NavBarDropdownItem, MenuDropdown, ErrorHandler }; +HeaderBar.props = {}; diff --git a/custom_addons/clarity_backend_theme_bits/static/src/js/SidebarBottom.js b/custom_addons/clarity_backend_theme_bits/static/src/js/SidebarBottom.js new file mode 100755 index 00000000..d9a1e1f3 --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/static/src/js/SidebarBottom.js @@ -0,0 +1,46 @@ +/** @odoo-module **/ + +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { CheckBox } from "@web/core/checkbox/checkbox"; +import { browser } from "@web/core/browser/browser"; +import { registry } from "@web/core/registry"; +import { Component } from "@odoo/owl"; +import { user } from "@web/core/user"; +import { session } from "@web/session"; + + +const userMenuRegistry = registry.category("user_menuitems"); + +export class SidebarBottom extends Component { + setup() { + this.user = user; + this.dbName = session.db; + if (!this.user.userId || !this.env) { + return; + } + const { origin } = browser.location; + this.source = `${origin}/web/image?model=res.users&field=avatar_128&id=${this.user.userId}`; + } + + getElements() { + const sortedItems = userMenuRegistry + .getAll() + .map((element) => element(this.env)) + .sort((x, y) => { + const xSeq = x.sequence ? x.sequence : 100; + const ySeq = y.sequence ? y.sequence : 100; + return xSeq - ySeq; + }); + return sortedItems; + } +} + +SidebarBottom.template = "SidebarBottom"; +SidebarBottom.components = { Dropdown, DropdownItem, CheckBox }; +SidebarBottom.props = {}; + +registry.category("systray").add("SidebarBottom", { + Component: SidebarBottom, + sequence: 100, // Increased to avoid conflicts with MessagingMenu +}); \ No newline at end of file diff --git a/custom_addons/clarity_backend_theme_bits/static/src/js/WebClient.js b/custom_addons/clarity_backend_theme_bits/static/src/js/WebClient.js new file mode 100755 index 00000000..6e096a0b --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/static/src/js/WebClient.js @@ -0,0 +1,110 @@ +/** @odoo-module **/ + +import { WebClient } from "@web/webclient/webclient"; +import { useService } from "@web/core/utils/hooks"; +import { useRef, onMounted } from "@odoo/owl"; +import { patch } from "@web/core/utils/patch"; +import { SidebarBottom } from "./SidebarBottom"; +import { rpc } from "@web/core/network/rpc"; +import { user } from "@web/core/user"; + +patch(WebClient.prototype, { + setup() { + super.setup(); + this.root = useRef("root"); + this.rpc = rpc; + this.menuService = useService("menu"); + this.currentCompany = user.activeCompanies && user.activeCompanies.length ? user.activeCompanies[0] : {}; + onMounted(() => { + this.fetchMenuData(); + }); + }, + + toggleSidebar(ev) { + const toggleEl = ev.currentTarget; + toggleEl.classList.toggle("visible"); + const navWrapper = document.querySelector(".nav-wrapper-bits"); + if (navWrapper) { + navWrapper.classList.toggle("toggle-show"); + } + }, + + + async fetchMenuData() { + try { + const menuData = this.menuService.getApps(); + const menuIds = menuData.map((app) => app.id); + const result = await this.rpc("/get/menu_data", { menu_ids: menuIds }); + for (const menu of menuData) { + const targetElem = this.root.el?.querySelector( + `.primary-nav a.main_link[data-menu="${menu.id}"] .app_icon` + ); + if (!targetElem) continue; + + targetElem.innerHTML = ""; + const prRecord = result[menu.id]?.[0]; + if (!prRecord) continue; + + menu.id = prRecord.id; + menu.use_icon = prRecord.use_icon; + menu.icon_class_name = prRecord.icon_class_name; + menu.icon_img = prRecord.icon_img; + + let iconImage; + if (prRecord.use_icon) { + if (prRecord.icon_class_name) { + iconImage = ``; + } else if (prRecord.icon_img) { + iconImage = ``; + } else if (prRecord.web_icon) { + const [iconPath, iconExt] = prRecord.web_icon.split("/icon."); + if (iconExt === "svg") { + const webSvgIcon = prRecord.web_icon.replace(",", "/"); + iconImage = ``; + } else { + iconImage = ``; + } + } else { + iconImage = ``; + } + } else { + if (prRecord.icon_img) { + iconImage = ``; + } else if (prRecord.web_icon) { + const [iconPath, iconExt] = prRecord.web_icon.split("/icon."); + if (iconExt === "svg") { + const webSvgIcon = prRecord.web_icon.replace(",", "/"); + iconImage = ``; + } else { + iconImage = ``; + } + } else { + iconImage = ``; + } + } + targetElem.innerHTML = iconImage; + } + } catch (error) { + console.error("Failed to fetch menu data:", error); + } + }, + + BackMenuToggle(ev) { + const parent = ev.currentTarget.parentElement; + if (parent) { + parent.classList.remove("show"); + } + }, + + get currentMenuId() { + const actionParams = window.location.hash; + const params = new URLSearchParams(actionParams.substring(1)); + return params.get("menu_id"); + }, + +}); + +patch(WebClient, { + components: { ...WebClient.components, SidebarBottom }, + // components: { ...WebClient.components, SidebarBottom, Transition }, +}); \ No newline at end of file diff --git a/custom_addons/clarity_backend_theme_bits/static/src/js/navbar.js b/custom_addons/clarity_backend_theme_bits/static/src/js/navbar.js new file mode 100755 index 00000000..633b254a --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/static/src/js/navbar.js @@ -0,0 +1,67 @@ +/** @odoo-module **/ + +import { NavBar } from "@web/webclient/navbar/navbar"; +import { useService } from "@web/core/utils/hooks"; +import { patch } from "@web/core/utils/patch"; +import { useEnvDebugContext } from "@web/core/debug/debug_context"; +import { useState } from "@odoo/owl"; +import { rpc } from "@web/core/network/rpc"; +import { user } from "@web/core/user"; + +patch(NavBar.prototype, { + setup() { + super.setup(); + this.debugContext = useEnvDebugContext(); + this.rpc = rpc; + this.currentCompany = user.activeCompanies && user.activeCompanies.length ? user.activeCompanies[0] : {}; + this.menuService = useService("menu"); + this.state = useState({ + ...this.state, + isSidebarOpen: false, + }); + this.getMenuItemHref = this.getMenuItemHref.bind(this); + }, + + onNavBarDropdownItemSelection(menu) { + if (menu) { + this.menuService.selectMenu(menu); + } + }, + + get currentApp() { + return this.menuService.getCurrentApp(); + }, + + getMenuItemHref(payload) { + if (!payload || (!payload.actionPath && !payload.actionID)) { + return "#"; + } + return `/odoo/${payload.actionPath || "action-" + (payload.actionID || "")}`; + }, + + toggleSidebar(ev) { + this.state.isSidebarOpen = !this.state.isSidebarOpen; + const toggleEl = ev.currentTarget; + toggleEl.classList.toggle("visible"); + toggleEl.classList.toggle("sidebar-open"); + const navWrapper = document.querySelector(".nav-wrapper-bits"); + if (navWrapper) { + navWrapper.classList.toggle("toggle-show"); + } + }, + + BackMenuToggle() { + const subMenu = document.querySelector(".sub-menu-dropdown.show"); + if (subMenu) { + subMenu.classList.remove("show"); + } + }, + + get appsMenuProps() { + return { + getMenuItemHref: this.getMenuItemHref, + onNavBarDropdownItemSelection: this.onNavBarDropdownItemSelection.bind(this), + isSmall: this.state.isSmall, + }; + }, +}); \ No newline at end of file diff --git a/custom_addons/clarity_backend_theme_bits/static/src/scss/layout.scss b/custom_addons/clarity_backend_theme_bits/static/src/scss/layout.scss new file mode 100755 index 00000000..3e3dd9ca --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/static/src/scss/layout.scss @@ -0,0 +1,680 @@ +body.o_web_client{ + flex-flow: row !important; + .wrapper-container-bits{ + display: flex; + width: 100%; + .content-wrapper-bits{ + width: 100%; + overflow-x: clip; + @media (max-width: 1540px) { + height: 100%; + // overflow: auto; + } + .btn-link{ + color: #282828 !important; + } + .btn.btn-primary{ + background-color: #282828 !important; + color: #fff !important; + border-color: #fff; + border: 1px solid #282828; + &:focus,&:active,&:hover{ + background-color: #fff !important; + color: #282828 !important; + border-color: #282828 !important; + } + } + .btn.btn-secondary{ + background-color: #f1eef5 !important; + color: #282828 !important; + // border-color: #282828; + // border: 1px solid #282828; + &:focus,&:active,&:hover{ + background-color: #f1eef5 !important; + // color: #fff !important; + // border-color: #f1eef5 !important; + } + } + .o_main_navbar{ + padding: 0 10px; + .o_menu_systray{ + align-items: center; + .o_user_menu{ + display: none !important; + } + button.dropdown-toggle{ + background-color: #282828; + margin: 0px 4px; + width: 35px; + height: 35px; + text-align: center; + justify-content: center; + border-radius: 6px; + i{ + color: #fff; + font-size: 16px !important; + } + .badge.rounded-pill{ + position: absolute; + right: -10px; + top: 2px; + background-color: #ffffff; + border-color: #282828; + border: 1px solid #282828; + color: #282828; + } + &.o_mobile_menu_toggle{ + padding: 15px !important; + } + } + .o_mobile_preview{ + a.o_nav_entry{ + + } + } + .o_website_switcher_container,.o_edit_website_container,.o_new_content_container,.o_mobile_preview{ + button.dropdown-toggle,a.o_nav_entry,.o_nav_entry,a.btn{ + width: auto !important; + background-color: #282828 !important; + margin: 0px 4px; + height: 35px !important; + text-align: center; + justify-content: center; + border-radius: 6px !important; + color: #fff !important; + } + + } + .o_menu_systray_item{ + a{ + .d-none.d-md-block.ms-1{ + color: #282828 !important; + } + } + } + // header button popup + .o-mail-DiscussSystray{ + box-shadow: 0px 1px 5px #abababed; + border-radius: 16px; + .o-mail-MessagingMenu-header{ + button.fw-bold{ + border-bottom: 1px solid #282828; + } + } + } + .o_mobile_menu_toggle{ + display: none !important; + } + } + .sidebar-toggler{ + cursor: pointer; + .sidebar-toggle-bits{ + font-size: 12px; + color: #282828; + i.back-arrow{ + width: 0px !important; + visibility: hidden; + opacity: 0; + } + } + &:hover{ + .sidebar-toggle-bits{ + i.toggle{ + width: 0px !important; + display: none !important; + } + i.back-arrow{ + visibility: visible; + opacity: 1; + transition: all 0.2s; + width: 100% !important; + font-size: 18px; + font-weight: 600; + margin-top: 4px; + } + } + } + } + } + .o_action_manager{ + height: 100%; + width: 100%; + .o_control_panel{ + padding: 10px 14px !important; + .o_control_panel_main{ + // create button + .o_control_panel_breadcrumbs{ + .o_control_panel_main_buttons{ + .btn-outline-primary{ + color: #282828 !important; + border-color: #282828 !important; + &:hover,&:focus,&:active{ + color: #fff !important; + background-color: #282828 !important; + } + } + } + .breadcrumb{ + .breadcrumb-item{ + a{ + color: #282828 !important; + } + } + } + } + // action button + .o_control_panel_actions{ + .btn-outline-secondary{ + border-color: #282828 !important; + i{ + color: #282828 !important; + } + span{ + color: #282828 !important; + } + } + .o_cp_searchview .o_searchview{ + border-radius: 8px 1px 1px 8px; + border-color: #282828; + } + .o-dropdown{ + button.o_searchview_dropdown_toggler{ + border-radius: 0px 8px 8px 0px; + } + } + .o_search_bar_menu{ + i{ + color: #282828 !important; + } + } + } + // navigation + .o_control_panel_navigation{ + .o_pager_counter{ + span{ + font-size: 18px; + } + } + button{ + font-size: 0.875rem; + border-radius: 6px !important; + border-color: #282828; + background-color: #fff !important; + color: #282828 !important; + height: 35px; + margin: 0px 3px; + &.active{ + background-color: #2828282e !important; + } + // width: 35px; + @media (max-width: 768px) { + width:auto !important; + } + } + } + } + } + // Discuss module in community + .o-mail-Discuss{ + width: 100%; + } + .o_content{ + overflow: auto !important; + height: 100vh; + margin-bottom: 60px; + max-height: calc(100vh - 100px); + } + // config setting view + .o-settings-form-view{ + .o_setting_container{ + .settings_tab{ + .app_name{ + font-size: 15px !important; + } + } + } + } + // form view + .o_form_view_container{ + .o_form_sheet_bg{ + .o_form_statusbar{ + // overflow: hidden; + .o_statusbar_buttons{ + .btn-primary{ + background-color: #71639e !important; + border-color: #71639e !important; + &:hover{ + background-color: #fff !important; + } + } + .btn-secondary{ + background-color: #fff !important; + border-color: #71639e !important; + &:hover{ + background-color: #71639e !important; + } + } + } + .o_arrow_button{ + border-color: #71639e !important; + background-color: #fff; + &:after{ + border-color: #fff; + } + } + .o_statusbar_status{ + .o_arrow_button{ + border: none !important; + } + } + //////////////////////////////////////////////////////////////// + .o_statusbar_status { + .o_arrow_button { + font-size: 14px; + text-align: center; + cursor: default; + margin: 0 3px; + padding: 5px 25px; + float: left; + position: relative; + background-color: #d9e3f7 !important; + user-select: none; + transition: background-color 0.2s ease; + &:after,&:before{ + content: " "; + position: absolute; + top: 0; + right: -17px; + width: 0; + height: 0; + border-top: 15px solid transparent; + border-bottom: 15px solid transparent; + border-left: 17px solid #d9e3f7; + z-index: 2; + transition: border-color 0.2s ease; + } + &:hover{ + color: #282828 !important; + } + &:before{ + right: auto; + left: 0; + border-left: 17px solid #f8f9fa; + z-index: 0; + } + &:first-child{ + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + &:before { + border: none; + } + } + &.o_arrow_button_current{ + color: #fff !important; + span{ + color: #fff !important; + } + background-color: #71639e !important; + &:after{ + border-left: 17px solid #71639e; + } + } + } + } + } + //////////////////////////////////////////////////////////////// + .o_form_sheet{ + border-radius: 8px; + box-shadow: 0px 0px 3px #919191; + border: none; + .o_cell{ + label,span,a{ + font-size: 15px !important; + } + } + .o_form_uri{ + span:first-child{ + color: #282828 !important; + } + } + .o_notebook{ + .o_notebook_headers{ + .nav.nav-tabs{ + border-top: 1px solid #dee2e6; + padding: 0px !important; + .nav-item .nav-link{ + color: #484848; + display: block; + border-right: 1px solid #2828286b !important; + padding: 10px; + font-weight: 500; + &:after{ + height: 5px; + background-color: #282828; + } + &:before{ + height: 5px; + background-color: #282828; + } + &.active{ + border-bottom: 2px solid #31485e; + background: #f9f9f9 !important; + font-weight: 600; + } + } + } + } + } + } + .o_list_renderer{ + table{ + a{ + color: #000730 !important; + } + } + } + } + } + // kanban view + .o_kanban_view{ + .o_kanban_record{ + margin: 14px 5px 0px !important; + border-radius: 15px; + .oe_kanban_card,.oe_kanban_global_click{ + border-radius: 8px; + border-color: #2828286e; + border: 1px solid #28282812; + box-shadow: 0px 3px 4px 0px rgba(0, 0, 0, 0.03); + padding: 14px; + } + } + } + // list view + .o_list_view.o_action{ + height: 100%; + .o_content{ + padding: 10px 14px; + } + .o_list_renderer{ + border: 0px !important; + border-radius: 8px; + box-shadow: 0px 0px 3px #919191; + .o_list_table{ + table-layout: auto !important; + thead{ + th{ + padding: 10px 12px; + background-color: #282828; + color: #fff !important; + button{ + color: #fff !important; + } + .form-check-input{ + background-color: #fff !important; + } + } + } + tbody{ + tr{ + border-color: #28282899; + border: none ; + td{ + border: none; + padding: 14px 12px; + color: #282828 !important; + font-size: 15px !important; + font-weight: 500; + } + &.o_data_row{ + border-bottom-width: 1px !important; + border-bottom-style: dashed !important; + } + } + box-shadow: 0px 1px 2px #e2e2e2; + border-radius: 12px; + } + tfoot{ + tr{ + td{ + border: none; + padding: 10px 12px; + color: #282828 !important; + } + } + } + } + .form-check{ + .form-check-input{ + &:checked{ + background-color: #282828 !important; + border-color: #282828 !important; + } + } + } + } + } + // graph view + .o_graph_view { + .o_content { + height: calc(100vh - 100px); + } + } + // discuss view + .o-mail-Discuss{ + background: #fff !important; + .o-mail-DiscussSidebar{ + margin: 7px; + border: none !important; + box-shadow: 0px 1px 4px #9191912e; + border-radius: 14px; + .o-mail-DiscussSidebar-item{ + background: #e3e3e39e; + color: #282828 !important; + border-radius: 7px !important; + margin: 5px 10px; + padding: 10px 0 !important; + font-weight: 600 !important; + } + } + //changed name in odoo 19 + .o-mail-DiscussContent-core{ + height: calc(100vh - 90px) !important; + overflow: scroll !important; + .o-mail-Discuss-header{ + border-bottom: none !important; + } + .o-mail-Thread{ + overflow: scroll; + margin: 7px; + box-shadow: 0px 0px 6px #413d3d3b !important; + border: 1px solid #28282800; + border-radius: 14px; + //msg section + .o-mail-Message{ + margin-top: 16px !important; + .o-mail-Message-core{ + //right content / msg content + .o-min-width-0{ + .o-mail-Message-header{ + .o-mail-Message-author{ + font-size: 16px; + margin: 8px 0px; + } + } + .o-mail-Message-content{ + .o-mail-Message-bubble{ + border-radius: 0px 10px 6px 10px !important; + .o-mail-Message-body{ + padding: 15px 25px !important; + } + } + } + } + } + } + } + .o-mail-Composer{ + .o-mail-Composer-coreMain{ + .btn{ + color: #282828 !important; + } + .o-mail-Composer-send{ + background: #282828 !important; + margin: 0 !important; + opacity: 1; + color: #fff !important; + } + + } + } + } + } + } + #terabits-link{ + h2{ + padding: 12px 28px; + background: #eee; + font-size: 15px !important; + } + } + } + } + &.editor_has_snippets_hide_backend_navbar{ + .nav-wrapper-bits{ + display: none; + } + } +} +.menu { + display: block; + width: 250px; + height: 100%; + transition: all 0.45s cubic-bezier(0.77, 0, 0.175, 1); + z-index: 999; + .icon { + position: absolute; + top: 12px; + right: 10px; + pointer-events: none; + width: 24px; + height: 24px; + color: #fff; + } + a { + display: block; + white-space: nowrap; + } +} + +.menu, +.menu a, +.menu a:visited { + color: #aaa; + text-decoration: none!important; + position: relative; +} +.new-wrapper { + position: absolute; + left: 50px; + width: calc(100% - 50px); + transition: transform .45s cubic-bezier(0.77, 0, 0.175, 1); +} + +#menu:checked + ul.menu-dropdown { + left: 0; + -webkit-animation: all .45s cubic-bezier(0.77, 0, 0.175, 1); + animation: all .45s cubic-bezier(0.77, 0, 0.175, 1); +} +.sub-menu-checkbox:checked + ul.sub-menu-dropdown { + display: block!important; + -webkit-animation: grow .45s cubic-bezier(0.77, 0, 0.175, 1); + animation: grow .45s cubic-bezier(0.77, 0, 0.175, 1); +} +.openNav .new-wrapper { + position: absolute; + transform: translate3d(200px, 0, 0); + width: calc(100% - 250px); + transition: transform .45s cubic-bezier(0.77, 0, 0.175, 1); +} + +.downarrow { + background: transparent; + position: absolute; + right: 50px; + top: 12px; + color: #777; + width: 24px; + height: 24px; + text-align: center; + display: block; +} +.downarrow:hover { + color: #fff; +} +.menu-dropdown { + top: 0; + overflow-y: auto; +} +.overflow-container { + position: relative; + height: 100% !important; + overflow-y: auto; + z-index: -1; + display:block; +} +.sub-menu-dropdown { + background-color: #333; +} +.openNav .menu { + top: 73px; + transform: translate3d(200px, 0, 0); + transition: transform .45s cubic-bezier(0.77, 0, 0.175, 1); +} + + +@-webkit-keyframes grow { + + 0% { + display: none; + opacity: 0; + } + 50% { + display: block; + opacity: 0.5; + } + 100% { + opacity: 1; + } + +} + +@keyframes grow { + + 0% { + display: none; + opacity: 0; + } + 50% { + display: block; + opacity: 0.5; + } + 100% { + opacity: 1 + } + +} +.o_navbar_apps_menu{ + height: 100%; +} +//neutralized title fix ui in odoo +#oe_neutralize_banner{ + position: fixed !important; + top: 0; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: center; + z-index: 99999 !important; + width: auto; +} diff --git a/custom_addons/clarity_backend_theme_bits/static/src/scss/login.scss b/custom_addons/clarity_backend_theme_bits/static/src/scss/login.scss new file mode 100755 index 00000000..24813adc --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/static/src/scss/login.scss @@ -0,0 +1,70 @@ +#wrapwrap{ + .login-view-bits{ + .oe_login_form,.oe_signup_form,.oe_reset_password_form{ + .input-group{ + a.btn{ + background-color: #282828; + color: #fff !important; + border-radius: 0px 8px 8px 0px !important; + display: flex !important; + align-items: center; + i{ + color: #fff !important; + padding-left: 5px; + margin-top: -4px; + } + } + } + input,.form-control{ + display: block; + width: 100%; + padding: 0.775rem 1rem; + font-size: 1.1rem; + font-weight: 500; + line-height: 1.5; + color: #4B5675; + appearance: none; + background-clip: padding-box; + border: 1px solid #DBDFE9; + border-radius: 0.475rem; + transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; + &#db{ + border-radius: 8px 0px 0px 8px !important; + } + } + .col-form-label,.form-label{ + font-size: 14px !important; + font-weight: bold !important; + } + } + .card-body{ + + box-shadow: 0px 0px 10px #d2d1d1; + border-radius: 30px; + } + .oe_login_buttons{ + .btn-primary[type=submit]{ + padding: 14px; + border-radius: 8px; + background: #282828; + color: #fff; + font-size: 14px; + &:hover,&:active,&:focus{ + color: #282828; + background: #fff; + border: 1px solid #282828; + } + } + a,button.btn{ + font-size: 12px; + font-weight: 600; + } + } + div.small{ + a,button.btn{ + font-size: 12px; + font-weight: 600; + } + } + } +} \ No newline at end of file diff --git a/custom_addons/clarity_backend_theme_bits/static/src/scss/navbar.scss b/custom_addons/clarity_backend_theme_bits/static/src/scss/navbar.scss new file mode 100755 index 00000000..be689cb6 --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/static/src/scss/navbar.scss @@ -0,0 +1,301 @@ +body.o_web_client{ + .nav-wrapper-bits{ + display: none ; + &.toggle-show{ + display: flex; + flex-direction: column; + justify-content: space-between; + background-color: #282828; + height: 100%; + z-index: 999; + header{ + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + } + .sytray-container-bits{ + margin: 5px 5px; + background-color: none !important; + button{ + width: 100%; + border: none !important; + box-shadow: none !important; + background: #343434; + border-radius: 10px; + img{ + width: 35px; + border-radius: 30px !important; + } + } + mark{ + font-size: 14px; + margin-top: 8px; + background: #fff !important; + padding: 7px; + border-radius: 8px; + } + .o_user_menu{ + .o-dropdown--menu{ + min-width: 15rem !important; + .dropdown-item{ + padding: 5px 20px !important; + } + // @media (max-width: 768px) { + // width: 100%; + // height: 100%; + // display: flex; + // justify-content: center; + // align-items: center; + + // } + } + } + + .powered-by{ + display: none; + } + &:hover{ + .powered-by{ + display: block !important; + color: #fff; + a{ + color: #aab6ff !important; + } + } + } + } + } + .btm-user-menu{ + z-index: 999; + width: 250px; + } + @media (max-width: 768px) { + position: absolute; + top: 0; + bottom: 0; + height: 100%; + z-index: 11; + } + } + header.o_navbar{ + nav{ + background-color: transparent !important; + border-bottom: none !important; + height: 100%; + } + .o-mail-DiscussSystray-class{ + background-color: #282828 !important; + margin: 0px 4px; + width: 35px; + height: 35px; + text-align: center; + justify-content: center; + border-radius: 6px; + } + .o_company_logo{ + padding: 8px 20px !important; + display: flex; + align-items: center; + justify-content: space-between; + background: #343434; + border-radius: 10px; + margin: 5px 5px; + i{ + color: #fff !important; + font-size: 20px; + display: none; + @media (max-width: 768px) { + display: block; + } + } + img{ + width: 150px; + height: 70px; + object-fit: contain; + } + } + } + .primary-nav{ + flex: 1 1 0 !important; + overflow-y: auto; + .overflow-container{ + .main_link{ + .app_icon{ + height: 25px !important; + width: 25px !important; + display: inline-flex; + img{ + object-fit: contain; + } + } + } + } + ul#menu-dropdown{ + list-style-type: none !important; + padding-left: 5px !important; + li a.main_link{ + display: flex; + align-items: center !important; + padding: 1em; + font-size: 14px; + } + ul.header-sub-menus.main{ + list-style-type: none !important; + z-index: 3; + position: absolute; + left: 0; + background-color: #282828 !important; + height: 100%; + top: 0; + width: 100%; + padding: 0px 12px; + // &:not(.show){ + // left: 100% !important; + // } + &:not(.show){ + display: none; + width: 0% ; + } + .back_main_menu{ + position: relative; + margin-left: -18px; + h3{ + color: #ffff !important; + cursor: pointer; + } + } + li{ + margin-left: 10px ; + background: #282828; + .header-sub-menus{ + list-style-type: none !important; + border-left: 1px solid #717171; + padding: 0; + } + .sub-main-menu{ + cursor: pointer !important; + font-size: 14px; + color: #fff; + font-weight: 600; + background: #282828; + } + } + // @media (max-width: 768px) { + // position: relative !important; + // .back_main_menu{ + // display: none !important; + // } + // } + // &.show{ + // li{ + // .collapse{ + // display: block; + // } + // } + // } + a{ + padding: 0.5rem !important; + font-size: 12px !important; + } + &.show{ + li{ + .sub-main-menu{ + background: #eeeeee14; + padding: 5px 10px !important; + border-radius: 8px; + margin: 8px 0px; + } + } + } + } + } + ::-webkit-scrollbar-track + { + -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); + background-color: #F5F5F5; + } + + ::-webkit-scrollbar + { + width: 6px; + background-color: #F5F5F5; + } + + ::-webkit-scrollbar-thumb + { + background-color: #575757; + } + } +} +.button_bg_hover:hover { + background-color: gray; + color:black; +} +.menu-hasdropdown { + .button_bg_hover { + margin-right: 7%; + } +} + +.header-sub-menus .button_bg_hover { + margin-right: 0; +} + +.sidebar-toggler { + .sidebar-toggle-bits { + .chevron-toggle { + transition: transform 0.3s ease; + } + + &.sidebar-open .chevron-toggle { + transform: rotate(180deg); + } + + &:not(.sidebar-open) .chevron-toggle { + transform: rotate(0deg); + } + } +} + +.sub-main-menu { + .chevron-toggle { + transition: transform 0.3s ease; + } + + &[aria-expanded="true"] .chevron-toggle { + transform: rotate(90deg); + } + + &[aria-expanded="false"] .chevron-toggle { + transform: rotate(0deg); + } +} +.o_user_avatar{ + height: 15%; + width: 15%; +} +.user-avtar-bits { + position: relative; + overflow: hidden; + transition: max-height 0.3s ease; + max-height: 100px; +} + +.user-avtar-bits:hover { + max-height: 120px; +} + +.powered-by { + opacity: 0; + transform: translateY(10px); + transition: opacity 0.3s ease, transform 0.3s ease; + pointer-events: none; + margin-top: 5px; +} + +.user-avtar-bits:hover .powered-by { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} diff --git a/custom_addons/clarity_backend_theme_bits/static/src/xml/WebClient.xml b/custom_addons/clarity_backend_theme_bits/static/src/xml/WebClient.xml new file mode 100755 index 00000000..4eefd60f --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/static/src/xml/WebClient.xml @@ -0,0 +1,33 @@ + + + + +
+ +
+ + + + + +
+
+
+
+
\ No newline at end of file diff --git a/custom_addons/clarity_backend_theme_bits/static/src/xml/navbar/sidebar.xml b/custom_addons/clarity_backend_theme_bits/static/src/xml/navbar/sidebar.xml new file mode 100755 index 00000000..33c6e181 --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/static/src/xml/navbar/sidebar.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/custom_addons/clarity_backend_theme_bits/static/src/xml/systray_items/user_menu.xml b/custom_addons/clarity_backend_theme_bits/static/src/xml/systray_items/user_menu.xml new file mode 100755 index 00000000..88472684 --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/static/src/xml/systray_items/user_menu.xml @@ -0,0 +1,64 @@ + + + + + + + User + + + + + +
+ + + + + + + + + \ No newline at end of file diff --git a/custom_addons/clarity_backend_theme_bits/views/res_config_setting.xml b/custom_addons/clarity_backend_theme_bits/views/res_config_setting.xml new file mode 100755 index 00000000..72ff3124 --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/views/res_config_setting.xml @@ -0,0 +1,29 @@ + + + + res.config.settings.view.form.inherit.base + res.config.settings + + + + + + + + + \ No newline at end of file diff --git a/custom_addons/clarity_backend_theme_bits/views/res_users.xml b/custom_addons/clarity_backend_theme_bits/views/res_users.xml new file mode 100755 index 00000000..0818285f --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/views/res_users.xml @@ -0,0 +1,23 @@ + + + + res.users.form + res.users + + + + + + + + + \ No newline at end of file diff --git a/custom_addons/clarity_backend_theme_bits/views/webclient_templates.xml b/custom_addons/clarity_backend_theme_bits/views/webclient_templates.xml new file mode 100755 index 00000000..adb879b9 --- /dev/null +++ b/custom_addons/clarity_backend_theme_bits/views/webclient_templates.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/custom_addons/encoach_adaptive/__init__.py b/custom_addons/encoach_adaptive/__init__.py new file mode 100644 index 00000000..7cfbc391 --- /dev/null +++ b/custom_addons/encoach_adaptive/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import controllers +from . import services diff --git a/custom_addons/encoach_adaptive/__manifest__.py b/custom_addons/encoach_adaptive/__manifest__.py new file mode 100644 index 00000000..0e6c2a0a --- /dev/null +++ b/custom_addons/encoach_adaptive/__manifest__.py @@ -0,0 +1,26 @@ +{ + 'name': 'EnCoach Adaptive Learning', + 'version': '19.0.1.0', + 'category': 'Education', + 'summary': 'Proficiency tracking, learning plans, diagnostics, content cache', + 'author': 'EnCoach', + 'depends': ['encoach_core', 'encoach_taxonomy', 'mail'], + 'data': [ + 'security/ir.model.access.csv', + 'data/ir_cron.xml', + 'views/adaptive_event_views.xml', + 'views/adaptive_path_views.xml', + 'views/adaptive_settings_views.xml', + 'views/signal_timeline_action.xml', + 'views/adaptive_menus.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'encoach_adaptive/static/src/js/signal_timeline.js', + 'encoach_adaptive/static/src/xml/signal_timeline.xml', + 'encoach_adaptive/static/src/css/signal_timeline.css', + ], + }, + 'installable': True, + 'license': 'LGPL-3', +} diff --git a/custom_addons/encoach_adaptive/controllers/__init__.py b/custom_addons/encoach_adaptive/controllers/__init__.py new file mode 100644 index 00000000..e9d361e9 --- /dev/null +++ b/custom_addons/encoach_adaptive/controllers/__init__.py @@ -0,0 +1 @@ +from . import adaptive diff --git a/custom_addons/encoach_adaptive/controllers/adaptive.py b/custom_addons/encoach_adaptive/controllers/adaptive.py new file mode 100644 index 00000000..18bbc6dc --- /dev/null +++ b/custom_addons/encoach_adaptive/controllers/adaptive.py @@ -0,0 +1,332 @@ +import json +import logging +import math +from odoo import http +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate +) +from odoo.addons.encoach_adaptive.services.style_matcher import STYLE_RESOURCE_MAP + +_logger = logging.getLogger(__name__) + + +class EncoachAdaptiveController(http.Controller): + + # ------------------------------------------------------------------ + # GET /api/adaptive/dashboard + # ------------------------------------------------------------------ + @http.route('/api/adaptive/dashboard', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def dashboard(self, **kw): + try: + Event = request.env['encoach.adaptive.event'].sudo() + Path = request.env['encoach.adaptive.path'].sudo() + + from odoo.fields import Datetime as DT + today_start = DT.now().replace(hour=0, minute=0, second=0, microsecond=0) + + total_students = len(Path.search([]).mapped('student_id')) + active_courses = len(Path.search([]).mapped('course_id').filtered(lambda c: c)) + + signals_today = Event.search_count([ + ('event_type', '=', 'signal'), + ('created_at', '>=', today_start), + ]) + + recent_decisions = [] + decisions = Event.search( + [('event_type', '=', 'decision')], + limit=10, order='created_at desc', + ) + for d in decisions: + recent_decisions.append({ + 'id': d.id, + 'student_id': d.student_id.id, + 'student_name': d.student_id.name or '', + 'decision': d.decision or '', + 'created_at': d.created_at, + }) + + return _json_response({ + 'total_students': total_students, + 'active_courses': active_courses, + 'avg_progress': 0.0, + 'signals_today': signals_today, + 'recent_decisions': recent_decisions, + }) + + except Exception as e: + _logger.exception('adaptive dashboard failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/adaptive/students + # ------------------------------------------------------------------ + @http.route('/api/adaptive/students', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def students(self, **kw): + try: + page = int(kw.get('page', 1)) + size = int(kw.get('size', 20)) + + Path = request.env['encoach.adaptive.path'].sudo() + Event = request.env['encoach.adaptive.event'].sudo() + paths, total = _paginate(Path, [], page, size, order='id desc') + + items = [] + for p in paths: + last_signal = Event.search( + [('student_id', '=', p.student_id.id), ('event_type', '=', 'signal')], + limit=1, order='created_at desc', + ) + + module_queue = [] + try: + module_queue = json.loads(p.module_queue or '[]') + except (json.JSONDecodeError, TypeError): + pass + + 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_pct = round(completed / total_modules * 100, 1) if total_modules else 0.0 + + current_module = '' + if module_queue and completed < len(module_queue): + entry = module_queue[completed] + if isinstance(entry, dict): + current_module = entry.get('name', '') + elif isinstance(entry, str): + current_module = entry + + items.append({ + 'student_id': p.student_id.id, + 'name': p.student_id.name or '', + 'course': p.course_id.name if p.course_id else '', + 'current_module': current_module, + 'progress_pct': progress_pct, + 'last_signal': { + 'signal_name': last_signal.signal_name or '', + 'signal_value': last_signal.signal_value, + 'created_at': last_signal.created_at, + } if last_signal else None, + }) + + return _json_response({ + 'items': items, + 'total': total, + 'page': page, + 'size': size, + }) + + except Exception as e: + _logger.exception('adaptive students list failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/adaptive/student//signals + # ------------------------------------------------------------------ + @http.route('/api/adaptive/student//signals', type='http', + auth='none', methods=['GET'], csrf=False) + @jwt_required + def student_signals(self, student_id, **kw): + try: + page = int(kw.get('page', 1)) + size = int(kw.get('size', 20)) + + Event = request.env['encoach.adaptive.event'].sudo() + domain = [('student_id', '=', student_id)] + events, total = _paginate(Event, domain, page, size, order='created_at desc') + + items = [] + for ev in events: + items.append({ + 'id': ev.id, + 'event_type': ev.event_type, + 'signal_name': ev.signal_name or '', + 'signal_value': ev.signal_value, + 'decision': ev.decision or '', + 'created_at': ev.created_at, + }) + + return _json_response({ + 'items': items, + 'total': total, + 'page': page, + 'size': size, + }) + + except Exception as e: + _logger.exception('student signals failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/adaptive/student//ability + # ------------------------------------------------------------------ + @http.route('/api/adaptive/student//ability', type='http', + auth='none', methods=['GET'], csrf=False) + @jwt_required + def student_ability(self, student_id, **kw): + try: + Event = request.env['encoach.adaptive.event'].sudo() + signals = Event.search([ + ('student_id', '=', student_id), + ('event_type', '=', 'signal'), + ], order='created_at asc') + + trajectory = [] + for s in signals: + trajectory.append({ + 'signal_name': s.signal_name or '', + 'value': s.signal_value, + 'timestamp': s.created_at, + }) + + values = [s.signal_value for s in signals if s.signal_value] + theta = sum(values) / len(values) if values else 0.0 + sem = math.sqrt(sum((v - theta) ** 2 for v in values) / len(values)) if len(values) > 1 else 1.0 + + return _json_response({ + 'student_id': student_id, + 'theta': round(theta, 3), + 'sem': round(sem, 3), + 'trajectory': trajectory, + 'n_signals': len(trajectory), + }) + + except Exception as e: + _logger.exception('student ability failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/adaptive/student//recommended-resources + # ------------------------------------------------------------------ + @http.route('/api/adaptive/student//recommended-resources', + type='http', auth='none', methods=['GET'], csrf=False) + @jwt_required + def recommended_resources(self, student_id, **kw): + try: + profile = request.env['encoach.student.profile'].sudo().search( + [('user_id', '=', student_id)], limit=1) + learning_style = profile.learning_style if profile else '' + + # Get resources for student's current course/module + path = request.env['encoach.adaptive.path'].sudo().search( + [('student_id', '=', student_id)], limit=1, order='id desc') + + resources = request.env['encoach.resource'].sudo().search( + [('active', '=', True), ('review_status', '=', 'approved')], limit=50) + + if learning_style: + from odoo.addons.encoach_adaptive.services.style_matcher import StyleMatcher + ranked = StyleMatcher.rank_resources(learning_style, resources) + else: + ranked = list(resources) + + items = [] + for r in ranked[:20]: + items.append({ + 'id': r.id, + 'name': r.name, + 'type': r.type or '', + 'cefr_level': r.cefr_level or '', + 'difficulty': r.difficulty or '', + 'duration_minutes': r.duration_minutes, + 'style_match': learning_style if r.type in ( + STYLE_RESOURCE_MAP.get(learning_style, []) if learning_style else [] + ) else '', + }) + + return _json_response({ + 'student_id': student_id, + 'learning_style': learning_style or 'none', + 'items': items, + }) + except Exception as e: + _logger.exception('recommended_resources failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/adaptive/settings + # ------------------------------------------------------------------ + @http.route('/api/adaptive/settings', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def get_settings(self, **kw): + try: + user = request.env.user.sudo() + Settings = request.env['encoach.adaptive.settings'].sudo() + settings = Settings.search([('teacher_id', '=', user.id)], limit=1) + + if not settings: + return _json_response({ + 'step_up_threshold': 0.85, + 'step_down_threshold': 0.50, + 'micro_lesson_trigger': 2, + 'module_skip_threshold': 0.95, + 'no_progress_alert_days': 3, + 'max_retries': 3, + 'is_default': True, + }) + + return _json_response({ + 'id': settings.id, + 'step_up_threshold': settings.step_up_threshold, + 'step_down_threshold': settings.step_down_threshold, + 'micro_lesson_trigger': settings.micro_lesson_trigger, + 'module_skip_threshold': settings.module_skip_threshold, + 'no_progress_alert_days': settings.no_progress_alert_days, + 'max_retries': settings.max_retries, + 'is_default': False, + }) + + except Exception as e: + _logger.exception('get adaptive settings failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # PUT /api/adaptive/settings + # ------------------------------------------------------------------ + @http.route('/api/adaptive/settings', type='http', auth='none', + methods=['PUT'], csrf=False) + @jwt_required + def update_settings(self, **kw): + try: + body = _get_json_body() + user = request.env.user.sudo() + Settings = request.env['encoach.adaptive.settings'].sudo() + settings = Settings.search([('teacher_id', '=', user.id)], limit=1) + + allowed_fields = [ + 'step_up_threshold', 'step_down_threshold', 'micro_lesson_trigger', + 'module_skip_threshold', 'no_progress_alert_days', 'max_retries', + ] + vals = {k: body[k] for k in allowed_fields if k in body} + + if not settings: + vals['teacher_id'] = user.id + entity = user.entity_ids[:1] + if entity: + vals['entity_id'] = entity.id + settings = Settings.create(vals) + else: + settings.write(vals) + + return _json_response({ + 'id': settings.id, + 'step_up_threshold': settings.step_up_threshold, + 'step_down_threshold': settings.step_down_threshold, + 'micro_lesson_trigger': settings.micro_lesson_trigger, + 'module_skip_threshold': settings.module_skip_threshold, + 'no_progress_alert_days': settings.no_progress_alert_days, + 'max_retries': settings.max_retries, + }) + + except Exception as e: + _logger.exception('update adaptive settings failed') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_adaptive/data/ir_cron.xml b/custom_addons/encoach_adaptive/data/ir_cron.xml new file mode 100644 index 00000000..94705fd5 --- /dev/null +++ b/custom_addons/encoach_adaptive/data/ir_cron.xml @@ -0,0 +1,14 @@ + + + + + EnCoach: Check Student Progress Alerts + + code + model._cron_check_no_progress() + 1 + days + + + + diff --git a/custom_addons/encoach_adaptive/models/__init__.py b/custom_addons/encoach_adaptive/models/__init__.py new file mode 100644 index 00000000..bec7008f --- /dev/null +++ b/custom_addons/encoach_adaptive/models/__init__.py @@ -0,0 +1,3 @@ +from . import adaptive_event +from . import adaptive_path +from . import adaptive_settings diff --git a/custom_addons/encoach_adaptive/models/adaptive_event.py b/custom_addons/encoach_adaptive/models/adaptive_event.py new file mode 100644 index 00000000..afffa55c --- /dev/null +++ b/custom_addons/encoach_adaptive/models/adaptive_event.py @@ -0,0 +1,19 @@ +from odoo import models, fields + + +class EncoachAdaptiveEvent(models.Model): + _name = 'encoach.adaptive.event' + _description = 'Adaptive Learning Event' + _order = 'created_at desc' + + student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True) + course_id = fields.Many2one('op.course', ondelete='set null') + event_type = fields.Selection([ + ('signal', 'Signal'), + ('decision', 'Decision'), + ], required=True) + signal_name = fields.Char(size=100) + signal_value = fields.Float() + decision = fields.Char(size=200) + context = fields.Text() + created_at = fields.Datetime(default=fields.Datetime.now) diff --git a/custom_addons/encoach_adaptive/models/adaptive_path.py b/custom_addons/encoach_adaptive/models/adaptive_path.py new file mode 100644 index 00000000..7adcfcc4 --- /dev/null +++ b/custom_addons/encoach_adaptive/models/adaptive_path.py @@ -0,0 +1,16 @@ +from odoo import models, fields + + +class EncoachAdaptivePath(models.Model): + _name = 'encoach.adaptive.path' + _description = 'Adaptive Learning Path' + + student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True) + course_id = fields.Many2one('op.course', ondelete='set null') + module_queue = fields.Text() + source = fields.Selection([ + ('placement', 'Placement'), + ('exam', 'Exam'), + ('ai_generated', 'AI Generated'), + ]) + next_generation_brief = fields.Text() diff --git a/custom_addons/encoach_adaptive/models/adaptive_settings.py b/custom_addons/encoach_adaptive/models/adaptive_settings.py new file mode 100644 index 00000000..7143ac52 --- /dev/null +++ b/custom_addons/encoach_adaptive/models/adaptive_settings.py @@ -0,0 +1,20 @@ +from odoo import api, models, fields + + +class EncoachAdaptiveSettings(models.Model): + _name = 'encoach.adaptive.settings' + _description = 'Adaptive Engine Settings' + + teacher_id = fields.Many2one('res.users', ondelete='cascade') + entity_id = fields.Many2one('encoach.entity', ondelete='cascade') + step_up_threshold = fields.Float(default=0.85) + step_down_threshold = fields.Float(default=0.50) + micro_lesson_trigger = fields.Integer(default=2) + module_skip_threshold = fields.Float(default=0.95) + no_progress_alert_days = fields.Integer(default=3) + max_retries = fields.Integer(default=3) + + @api.model + def _cron_check_no_progress(self): + from odoo.addons.encoach_adaptive.services.alert_service import AdaptiveAlertService + AdaptiveAlertService.check_no_progress(self.env) diff --git a/custom_addons/encoach_adaptive/security/ir.model.access.csv b/custom_addons/encoach_adaptive/security/ir.model.access.csv new file mode 100644 index 00000000..6b8127c0 --- /dev/null +++ b/custom_addons/encoach_adaptive/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_adaptive_event_user,encoach.adaptive.event.user,model_encoach_adaptive_event,base.group_user,1,1,1,1 +access_adaptive_path_user,encoach.adaptive.path.user,model_encoach_adaptive_path,base.group_user,1,1,1,1 +access_adaptive_settings_user,encoach.adaptive.settings.user,model_encoach_adaptive_settings,base.group_user,1,1,1,1 diff --git a/custom_addons/encoach_adaptive/services/__init__.py b/custom_addons/encoach_adaptive/services/__init__.py new file mode 100644 index 00000000..574106d9 --- /dev/null +++ b/custom_addons/encoach_adaptive/services/__init__.py @@ -0,0 +1,3 @@ +from .adaptive_engine import AdaptiveEngine +from . import style_matcher +from . import alert_service diff --git a/custom_addons/encoach_adaptive/services/adaptive_engine.py b/custom_addons/encoach_adaptive/services/adaptive_engine.py new file mode 100644 index 00000000..b0204f7f --- /dev/null +++ b/custom_addons/encoach_adaptive/services/adaptive_engine.py @@ -0,0 +1,149 @@ +import logging +import json + +_logger = logging.getLogger(__name__) + + +class AdaptiveEngine: + """4-phase adaptive learning engine. + + Phase 1: Module-level up/down stepping + Phase 2: Micro-lesson injection + Phase 3: Module skipping + Phase 4: No-progress alerts + """ + + DEFAULT_SETTINGS = { + 'step_up_threshold': 0.85, + 'step_down_threshold': 0.50, + 'micro_lesson_trigger': 2, + 'module_skip_threshold': 0.95, + 'no_progress_alert_days': 3, + 'max_retries': 3, + } + + @staticmethod + def get_settings(env, teacher_id=None, entity_id=None): + """Get adaptive settings for teacher/entity or defaults.""" + Settings = env['encoach.adaptive.settings'].sudo() + settings = None + if teacher_id: + settings = Settings.search([('teacher_id', '=', teacher_id)], limit=1) + if not settings and entity_id: + settings = Settings.search([('entity_id', '=', entity_id), ('teacher_id', '=', False)], limit=1) + + if settings: + return { + 'step_up_threshold': settings.step_up_threshold, + 'step_down_threshold': settings.step_down_threshold, + 'micro_lesson_trigger': settings.micro_lesson_trigger, + 'module_skip_threshold': settings.module_skip_threshold, + 'no_progress_alert_days': settings.no_progress_alert_days, + 'max_retries': settings.max_retries, + } + return dict(AdaptiveEngine.DEFAULT_SETTINGS) + + @staticmethod + def process_checkpoint(env, student_id, course_id, module_id, score, settings=None): + """Process a module checkpoint and make adaptive decisions.""" + if not settings: + settings = AdaptiveEngine.DEFAULT_SETTINGS + + Event = env['encoach.adaptive.event'].sudo() + Module = env['encoach.course.module'].sudo() + module = Module.browse(module_id) + + decision = None + signals = [] + + signals.append({ + 'signal_name': 'checkpoint_score', + 'signal_value': score, + }) + + if score >= settings['step_up_threshold']: + decision = 'step_up' + module.write({'status': 'completed'}) + next_module = Module.search([ + ('course_id', '=', course_id), + ('sequence', '>', module.sequence), + ('status', '=', 'locked'), + ], limit=1, order='sequence') + if next_module: + next_module.write({'status': 'available'}) + + elif score < settings['step_down_threshold']: + decision = 'step_down' + + else: + decision = 'continue' + module.write({'status': 'completed'}) + next_module = Module.search([ + ('course_id', '=', course_id), + ('sequence', '>', module.sequence), + ('status', '=', 'locked'), + ], limit=1, order='sequence') + if next_module: + next_module.write({'status': 'available'}) + + if score >= settings['module_skip_threshold']: + skip_modules = Module.search([ + ('course_id', '=', course_id), + ('sequence', '>', module.sequence), + ('status', '=', 'locked'), + ], limit=2, order='sequence') + for sm in skip_modules: + sm.write({'status': 'skipped'}) + if skip_modules: + decision = 'skip_ahead' + signals.append({'signal_name': 'module_skip', 'signal_value': len(skip_modules)}) + + for sig in signals: + Event.create({ + 'student_id': student_id, + 'course_id': course_id, + 'event_type': 'signal', + 'signal_name': sig['signal_name'], + 'signal_value': sig['signal_value'], + }) + + Event.create({ + 'student_id': student_id, + 'course_id': course_id, + 'event_type': 'decision', + 'decision': decision, + 'context': json.dumps({'module_id': module_id, 'score': score}), + }) + + return { + 'decision': decision, + 'score': score, + 'signals': signals, + } + + @staticmethod + def check_no_progress(env, student_id, course_id, settings=None): + """Phase 4: Check if student has stalled.""" + if not settings: + settings = AdaptiveEngine.DEFAULT_SETTINGS + + from datetime import datetime, timedelta + cutoff = datetime.now() - timedelta(days=settings['no_progress_alert_days']) + + Event = env['encoach.adaptive.event'].sudo() + recent_events = Event.search_count([ + ('student_id', '=', student_id), + ('course_id', '=', course_id), + ('created_at', '>=', cutoff.strftime('%Y-%m-%d %H:%M:%S')), + ]) + + if recent_events == 0: + Event.create({ + 'student_id': student_id, + 'course_id': course_id, + 'event_type': 'signal', + 'signal_name': 'no_progress_alert', + 'signal_value': settings['no_progress_alert_days'], + }) + return True + return False diff --git a/custom_addons/encoach_adaptive/services/alert_service.py b/custom_addons/encoach_adaptive/services/alert_service.py new file mode 100644 index 00000000..6ea3904c --- /dev/null +++ b/custom_addons/encoach_adaptive/services/alert_service.py @@ -0,0 +1,67 @@ +import logging +from datetime import timedelta + +from odoo import fields + +_logger = logging.getLogger(__name__) + + +class AdaptiveAlertService: + """Checks for students with no learning progress and creates teacher alerts.""" + + @classmethod + def check_no_progress(cls, env): + """Find students with no adaptive events within threshold days and alert teachers. + + Called by scheduled action (ir.cron). + """ + Settings = env['encoach.adaptive.settings'].sudo() + Event = env['encoach.adaptive.event'].sudo() + Path = env['encoach.adaptive.path'].sudo() + Activity = env['mail.activity'].sudo() + + all_settings = Settings.search([]) + if not all_settings: + all_settings = Settings.new({'no_progress_alert_days': 3}) + + for setting in all_settings: + days = setting.no_progress_alert_days or 3 + cutoff = fields.Datetime.now() - timedelta(days=days) + teacher = setting.teacher_id + + if not teacher: + continue + + # Find students with active paths but no recent events + paths = Path.search([]) + for path in paths: + student_id = path.student_id.id + recent_events = Event.search_count([ + ('student_id', '=', student_id), + ('created_at', '>=', cutoff), + ]) + + if recent_events == 0: + # Check if alert already exists for this student + existing = Activity.search([ + ('res_model', '=', 'res.users'), + ('res_id', '=', student_id), + ('user_id', '=', teacher.id), + ('summary', 'ilike', 'No learning progress'), + ('date_deadline', '>=', fields.Date.today()), + ], limit=1) + + if not existing: + activity_type = env.ref('mail.mail_activity_data_todo', raise_if_not_found=False) + Activity.create({ + 'res_model_id': env['ir.model']._get_id('res.users'), + 'res_id': student_id, + 'user_id': teacher.id, + 'activity_type_id': activity_type.id if activity_type else False, + 'summary': f'No learning progress for {days}+ days', + 'note': f'Student {path.student_id.name} has not shown any ' + f'learning activity in the last {days} days.', + 'date_deadline': fields.Date.today(), + }) + _logger.info('Created no-progress alert for student %s → teacher %s', + path.student_id.name, teacher.name) diff --git a/custom_addons/encoach_adaptive/services/style_matcher.py b/custom_addons/encoach_adaptive/services/style_matcher.py new file mode 100644 index 00000000..fd1598d4 --- /dev/null +++ b/custom_addons/encoach_adaptive/services/style_matcher.py @@ -0,0 +1,35 @@ +import logging + +_logger = logging.getLogger(__name__) + +STYLE_RESOURCE_MAP = { + 'visual': ['video', 'pdf', 'interactive'], + 'auditory': ['video', 'interactive'], + 'reading': ['pdf', 'document'], + 'kinesthetic': ['interactive'], +} + + +class StyleMatcher: + """Ranks learning resources based on student's learning style preference.""" + + @classmethod + def rank_resources(cls, learning_style, resources): + """Sort resources so that types matching the student's style come first. + + Args: + learning_style: str, one of visual/auditory/reading/kinesthetic + resources: recordset of encoach.resource + + Returns: + sorted list of resource records + """ + preferred_types = STYLE_RESOURCE_MAP.get(learning_style, []) + return sorted(resources, key=lambda r: (r.type not in preferred_types, r.id)) + + @classmethod + def filter_by_style(cls, learning_style, resources): + """Return only resources matching the learning style (with fallback to all).""" + preferred_types = STYLE_RESOURCE_MAP.get(learning_style, []) + matched = [r for r in resources if r.type in preferred_types] + return matched if matched else list(resources) diff --git a/custom_addons/encoach_adaptive/static/src/css/signal_timeline.css b/custom_addons/encoach_adaptive/static/src/css/signal_timeline.css new file mode 100644 index 00000000..60046864 --- /dev/null +++ b/custom_addons/encoach_adaptive/static/src/css/signal_timeline.css @@ -0,0 +1,54 @@ +.o_signal_timeline .st-timeline { + position: relative; + padding-left: 48px; +} +.o_signal_timeline .st-timeline::before { + content: ""; + position: absolute; + left: 19px; + top: 0; + bottom: 0; + width: 2px; + background: #dee2e6; +} +.o_signal_timeline .st-timeline-item { + position: relative; + margin-bottom: 1rem; +} +.o_signal_timeline .st-timeline-marker { + position: absolute; + left: -48px; + top: 8px; + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 0.85rem; + z-index: 1; + border: 3px solid #fff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} +.o_signal_timeline .st-timeline-content { + padding-left: 4px; +} +.o_signal_timeline .st-timeline-content .card { + border-radius: 0.5rem; + transition: box-shadow 0.15s; +} +.o_signal_timeline .st-timeline-content .card:hover { + box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.08) !important; +} +.o_signal_timeline .st-student-card { + transition: transform 0.15s, box-shadow 0.15s; + cursor: pointer; +} +.o_signal_timeline .st-student-card:hover { + transform: translateY(-2px); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important; +} +.o_signal_timeline .cursor-pointer { + cursor: pointer; +} diff --git a/custom_addons/encoach_adaptive/static/src/js/signal_timeline.js b/custom_addons/encoach_adaptive/static/src/js/signal_timeline.js new file mode 100644 index 00000000..c57fbb22 --- /dev/null +++ b/custom_addons/encoach_adaptive/static/src/js/signal_timeline.js @@ -0,0 +1,152 @@ +/** @odoo-module */ + +import { Component, onWillStart, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { Layout } from "@web/search/layout"; + +class SignalTimeline extends Component { + static template = "encoach_adaptive.SignalTimeline"; + static components = { Layout }; + static props = ["*"]; + + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.state = useState({ + loading: true, + studentId: null, + student: null, + events: [], + filteredEvents: [], + filters: { eventType: "", dateFrom: "", dateTo: "" }, + totalCount: 0, + }); + + onWillStart(async () => { + const ctx = this.props.action?.context || {}; + this.state.studentId = ctx.student_id || ctx.active_id || null; + if (this.state.studentId) { + await this.loadTimeline(); + } else { + await this.loadStudentList(); + } + }); + } + + async loadStudentList() { + try { + this.state.studentList = await this.orm.searchRead( + "res.users", + [["account_source", "!=", false]], + ["name", "email"], + { limit: 50, order: "name" }, + ); + } catch (e) { + console.error("Failed to load students:", e); + } finally { + this.state.loading = false; + } + } + + async selectStudent(studentId) { + this.state.studentId = studentId; + this.state.loading = true; + await this.loadTimeline(); + } + + async loadTimeline() { + this.state.loading = true; + try { + const domain = [["student_id", "=", this.state.studentId]]; + if (this.state.filters.eventType) { + domain.push(["event_type", "=", this.state.filters.eventType]); + } + if (this.state.filters.dateFrom) { + domain.push(["created_at", ">=", this.state.filters.dateFrom]); + } + if (this.state.filters.dateTo) { + domain.push(["created_at", "<=", this.state.filters.dateTo + " 23:59:59"]); + } + + const [events, students] = await Promise.all([ + this.orm.searchRead( + "encoach.adaptive.event", + domain, + ["student_id", "course_id", "event_type", "signal_name", "signal_value", "decision", "context", "created_at"], + { limit: 100, order: "created_at desc" }, + ), + this.orm.searchRead( + "res.users", + [["id", "=", this.state.studentId]], + ["name", "email"], + ), + ]); + + this.state.student = students.length ? students[0] : null; + this.state.events = events; + this.state.filteredEvents = events; + this.state.totalCount = events.length; + } catch (e) { + console.error("Timeline load error:", e); + } finally { + this.state.loading = false; + } + } + + onFilterChange(field, ev) { + this.state.filters[field] = ev.target.value; + if (this.state.studentId) { + this.loadTimeline(); + } + } + + clearFilters() { + this.state.filters = { eventType: "", dateFrom: "", dateTo: "" }; + if (this.state.studentId) { + this.loadTimeline(); + } + } + + eventIcon(eventType) { + return eventType === "signal" ? "fa-bolt" : "fa-gavel"; + } + + eventColor(eventType) { + return eventType === "signal" ? "primary" : "success"; + } + + formatDate(dt) { + if (!dt) return ""; + return dt.replace("T", " ").substring(0, 19); + } + + parseContext(ctx) { + if (!ctx) return null; + try { + return typeof ctx === "string" ? JSON.parse(ctx) : ctx; + } catch { + return null; + } + } + + get signalCount() { + return this.state.filteredEvents.filter(e => e.event_type === "signal").length; + } + + get decisionCount() { + return this.state.filteredEvents.filter(e => e.event_type === "decision").length; + } + + goBack() { + this.state.studentId = null; + this.state.student = null; + this.state.events = []; + this.state.filteredEvents = []; + this.state.filters = { eventType: "", dateFrom: "", dateTo: "" }; + this.state.loading = true; + this.loadStudentList(); + } +} + +registry.category("actions").add("encoach_signal_timeline", SignalTimeline); diff --git a/custom_addons/encoach_adaptive/static/src/xml/signal_timeline.xml b/custom_addons/encoach_adaptive/static/src/xml/signal_timeline.xml new file mode 100644 index 00000000..23d8c94b --- /dev/null +++ b/custom_addons/encoach_adaptive/static/src/xml/signal_timeline.xml @@ -0,0 +1,177 @@ + + + + +
+
+ + +
+ +

Loading timeline...

+
+
+ + + +

Adaptive Signal Timeline

+

Select a student to view their adaptive learning events.

+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+
+ + + +
+ +
+

Adaptive Timeline

+ + Student: + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+
+ + events +
+
+ + signals +
+
+ + decisions +
+
+ + +
+ +
+
+ +
+
+
+
+
+
+ + + Decision +
+ +
+ + +
+ Value: + + + Course: + + +
+ + +
+ + + Course: + + +
+ + +
+ + + + + : + + + +
+
+
+
+
+
+ + +
+ +

No adaptive events found for this student.

+
+
+
+
+ + +
+ +

No student selected. Open from a student record or select one above.

+
+
+
+
+
+
+
diff --git a/custom_addons/encoach_adaptive/views/adaptive_event_views.xml b/custom_addons/encoach_adaptive/views/adaptive_event_views.xml new file mode 100644 index 00000000..93b80c51 --- /dev/null +++ b/custom_addons/encoach_adaptive/views/adaptive_event_views.xml @@ -0,0 +1,67 @@ + + + + + encoach.adaptive.event.form + encoach.adaptive.event + +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + encoach.adaptive.event.list + encoach.adaptive.event + + + + + + + + + + + + + + encoach.adaptive.event.search + encoach.adaptive.event + + + + + + + + + + + + + + Adaptive Events + encoach.adaptive.event + list,form + + +
diff --git a/custom_addons/encoach_adaptive/views/adaptive_menus.xml b/custom_addons/encoach_adaptive/views/adaptive_menus.xml new file mode 100644 index 00000000..4b9f2fc4 --- /dev/null +++ b/custom_addons/encoach_adaptive/views/adaptive_menus.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/custom_addons/encoach_adaptive/views/adaptive_path_views.xml b/custom_addons/encoach_adaptive/views/adaptive_path_views.xml new file mode 100644 index 00000000..d7addf94 --- /dev/null +++ b/custom_addons/encoach_adaptive/views/adaptive_path_views.xml @@ -0,0 +1,46 @@ + + + + + encoach.adaptive.path.form + encoach.adaptive.path + +
+ + + + + + + + + + + + + + + +
+
+
+ + + encoach.adaptive.path.list + encoach.adaptive.path + + + + + + + + + + + Adaptive Paths + encoach.adaptive.path + list,form + + +
diff --git a/custom_addons/encoach_adaptive/views/adaptive_settings_views.xml b/custom_addons/encoach_adaptive/views/adaptive_settings_views.xml new file mode 100644 index 00000000..1bdb0b6d --- /dev/null +++ b/custom_addons/encoach_adaptive/views/adaptive_settings_views.xml @@ -0,0 +1,54 @@ + + + + + encoach.adaptive.settings.form + encoach.adaptive.settings + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + encoach.adaptive.settings.list + encoach.adaptive.settings + + + + + + + + + + + + Adaptive Settings + encoach.adaptive.settings + list,form + + +
diff --git a/custom_addons/encoach_adaptive/views/signal_timeline_action.xml b/custom_addons/encoach_adaptive/views/signal_timeline_action.xml new file mode 100644 index 00000000..289ad0cd --- /dev/null +++ b/custom_addons/encoach_adaptive/views/signal_timeline_action.xml @@ -0,0 +1,15 @@ + + + + + Signal Timeline + encoach_signal_timeline + + + + + diff --git a/custom_addons/encoach_ai/__init__.py b/custom_addons/encoach_ai/__init__.py new file mode 100644 index 00000000..7cfbc391 --- /dev/null +++ b/custom_addons/encoach_ai/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import controllers +from . import services diff --git a/custom_addons/encoach_ai/__manifest__.py b/custom_addons/encoach_ai/__manifest__.py new file mode 100644 index 00000000..2e1b91e8 --- /dev/null +++ b/custom_addons/encoach_ai/__manifest__.py @@ -0,0 +1,27 @@ +{ + "name": "EnCoach AI Services", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Central AI service layer — OpenAI, Whisper, Polly, ElevenLabs, GPTZero, ELAI", + "description": """ + Provides a unified AI service layer for the EnCoach platform. + - OpenAI GPT-4o / GPT-3.5-turbo (chat, JSON generation, grading) + - OpenAI Whisper (speech-to-text) + - AWS Polly (text-to-speech) + - ElevenLabs (text-to-speech, multilingual) + - GPTZero (AI content detection) + - ELAI (avatar video generation) + - AI Coaching assistant + - AI Search, Insights, Report Narrative + """, + "author": "EnCoach", + "depends": ["base", "encoach_core"], + "data": [ + "security/ir.model.access.csv", + "views/ai_settings_views.xml", + "data/ai_defaults.xml", + ], + "installable": True, + "application": True, + "license": "LGPL-3", +} diff --git a/custom_addons/encoach_ai/controllers/__init__.py b/custom_addons/encoach_ai/controllers/__init__.py new file mode 100644 index 00000000..fd28fabd --- /dev/null +++ b/custom_addons/encoach_ai/controllers/__init__.py @@ -0,0 +1,3 @@ +from . import ai_controller +from . import coach_controller +from . import media_controller diff --git a/custom_addons/encoach_ai/controllers/ai_controller.py b/custom_addons/encoach_ai/controllers/ai_controller.py new file mode 100644 index 00000000..cc326aca --- /dev/null +++ b/custom_addons/encoach_ai/controllers/ai_controller.py @@ -0,0 +1,575 @@ +"""REST endpoints for AI services — matches frontend service calls.""" + +import json +import logging +from odoo import http +from odoo.http import request, Response + +_logger = logging.getLogger(__name__) + + +def _json_response(data, status=200): + return Response( + json.dumps(data, default=str), + status=status, + content_type="application/json", + ) + + +def _get_json(): + try: + return json.loads(request.httprequest.data or "{}") + except Exception: + return {} + + +class AIController(http.Controller): + """Handles /api/ai/* endpoints consumed by frontend AI components.""" + + # ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ── + @http.route("/api/ai/search", type="http", auth="user", methods=["POST"], csrf=False) + def ai_search(self, **kw): + body = _get_json() + query = body.get("query", "") + if not query: + return _json_response({"answer": "", "suggestions": []}) + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + result = ai.search_with_rag(query, context=body.get("context", "")) + return _json_response(result) + except Exception as e: + _logger.exception("AI search failed") + return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []}) + + # ── GET /api/ai/vector-search — pure semantic search without GPT ── + @http.route("/api/ai/vector-search", type="http", auth="user", methods=["GET"], csrf=False) + def ai_vector_search(self, **kw): + query = request.params.get("q", "") + content_type = request.params.get("content_type") + limit = min(int(request.params.get("limit", "10")), 50) + if not query: + return _json_response({"results": [], "query": ""}) + try: + from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService + svc = EmbeddingService(request.env) + results = svc.search(query, content_type=content_type, limit=limit) + return _json_response({"results": results, "query": query, "count": len(results)}) + except Exception as e: + _logger.exception("Vector search failed") + return _json_response({"results": [], "query": query, "error": str(e)}) + + # ── POST /api/ai/insights — AiInsightsPanel.tsx ── + @http.route("/api/ai/insights", type="http", auth="user", methods=["POST"], csrf=False) + def ai_insights(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + result = ai.generate_insights( + body.get("data", {}), + insight_type=body.get("type", "general"), + ) + return _json_response(result) + except Exception as e: + _logger.exception("AI insights failed") + return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]}) + + # ── GET /api/ai/alerts — AiAlertBanner.tsx ── + @http.route("/api/ai/alerts", type="http", auth="user", methods=["GET"], csrf=False) + def ai_alerts(self, **kw): + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + context = request.params.get("context", "dashboard") + result = ai.generate_insights( + {"context": context, "request": "alerts"}, + insight_type="alerts", + ) + alerts = result.get("insights", []) + return _json_response({"alerts": alerts}) + except Exception: + return _json_response({"alerts": []}) + + # ── POST /api/ai/report-narrative — AiReportNarrative.tsx ── + @http.route("/api/ai/report-narrative", type="http", auth="user", methods=["POST"], csrf=False) + def ai_report_narrative(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + narrative = ai.generate_report_narrative( + body.get("report_type", "performance"), + body.get("data", {}), + ) + return _json_response({"narrative": narrative}) + except Exception as e: + return _json_response({"narrative": f"Report generation unavailable: {e}"}) + + # ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ── + @http.route("/api/ai/batch-optimize", type="http", auth="user", methods=["POST"], csrf=False) + def ai_batch_optimize(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + result = ai.batch_optimize( + body.get("items", []), + optimization_type=body.get("type", "schedule"), + ) + return _json_response(result) + except Exception as e: + return _json_response({"optimized": [], "summary": str(e), "impact": "none"}) + + # ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ── + @http.route("/api/ai/grade-suggest", type="http", auth="user", methods=["POST"], csrf=False) + def ai_grade_suggest(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + skill = body.get("skill", "writing") + if skill == "speaking": + result = ai.grade_speaking( + body.get("rubric", "IELTS Speaking Band Descriptors"), + body.get("submission_text", ""), + ) + else: + result = ai.grade_writing( + body.get("rubric", "IELTS Writing Band Descriptors"), + body.get("task", ""), + body.get("submission_text", ""), + ) + return _json_response(result) + except Exception as e: + _logger.exception("AI grade suggest failed") + return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []}) + + # ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ── + @http.route("/api/ai/generate-resource", type="http", auth="user", methods=["POST"], csrf=False) + def ai_generate_resource(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + result = ai.generate_content_dedup( + body.get("content_type", "reading_passage"), + body.get("brief", {}), + cefr_level=body.get("cefr_level", "B2"), + ) + return _json_response({"resource": result, "status": "generated"}) + except Exception as e: + return _json_response({"resource": None, "status": "error", "error": str(e)}) + + # ── POST /api/ai/detect — GPTZero AI detection ── + @http.route("/api/ai/detect", type="http", auth="user", methods=["POST"], csrf=False) + def ai_detect(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService + svc = GPTZeroService(request.env) + result = svc.detect(body.get("text", "")) + return _json_response(result) + except Exception as e: + return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)}) + + # ── POST /api/plagiarism/check — plagiarism.service.ts ── + @http.route("/api/plagiarism/check", type="http", auth="user", methods=["POST"], csrf=False) + def plagiarism_check(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService + svc = GPTZeroService(request.env) + result = svc.detect(body.get("text", "")) + report_id = f"plag_{request.env.uid}_{int(__import__('time').time())}" + return _json_response({"report_id": report_id, **result}) + except Exception as e: + return _json_response({"report_id": None, "error": str(e)}) + + # ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ── + @http.route("/api/domains//ai-suggest", type="http", auth="user", methods=["POST"], csrf=False) + def ai_suggest_topics(self, domain_id, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + messages = [ + {"role": "system", "content": ( + "You are an educational taxonomy expert. Suggest topics for the given domain and level. " + "Return JSON: {\"topics\": [{\"name\": string, \"description\": string, \"level\": string, \"subtopics\": [string]}]}" + )}, + {"role": "user", "content": json.dumps({"domain_id": domain_id, **body})}, + ] + result = ai.chat_json(messages, model=ai.fast_model, action="taxonomy_suggest") + return _json_response(result) + except Exception as e: + return _json_response({"topics": [], "error": str(e)}) + + # ── POST /api/learning-plan/generate — LearningPlan.tsx ── + @http.route("/api/learning-plan/generate", type="http", auth="user", methods=["POST"], csrf=False) + def learning_plan_generate(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + messages = [ + {"role": "system", "content": ( + "Create a personalized learning plan. Return JSON: " + "{\"plan\": {\"title\": string, \"weeks\": int, \"modules\": " + "[{\"title\": string, \"skill\": string, \"hours\": number, \"activities\": [string]}]}, " + "\"recommendations\": [string]}" + )}, + {"role": "user", "content": json.dumps(body)}, + ] + result = ai.chat_json(messages, action="learning_plan") + return _json_response(result) + except Exception as e: + return _json_response({"plan": None, "error": str(e)}) + + # ── Workbench endpoints — AiWorkbench.tsx ── + @http.route("/api/workbench/generate-outline", type="http", auth="user", methods=["POST"], csrf=False) + def workbench_outline(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + messages = [ + {"role": "system", "content": ( + "Generate a course outline. Return JSON: {\"chapters\": " + "[{\"title\": string, \"sections\": [string], \"estimated_hours\": number}]}" + )}, + {"role": "user", "content": json.dumps(body)}, + ] + return _json_response(ai.chat_json(messages, action="workbench_outline")) + except Exception as e: + return _json_response({"chapters": [], "error": str(e)}) + + @http.route("/api/workbench/generate-chapter", type="http", auth="user", methods=["POST"], csrf=False) + def workbench_chapter(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + messages = [ + {"role": "system", "content": ( + "Generate detailed chapter content for a course. Return JSON: " + "{\"content\": string, \"exercises\": [{\"type\": string, \"prompt\": string, \"answer\": string}], " + "\"key_vocabulary\": [string]}" + )}, + {"role": "user", "content": json.dumps(body)}, + ] + return _json_response(ai.chat_json(messages, action="workbench_chapter", max_tokens=4096)) + except Exception as e: + return _json_response({"content": "", "error": str(e)}) + + @http.route("/api/workbench/generate-rubric", type="http", auth="user", methods=["POST"], csrf=False) + def workbench_rubric(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + messages = [ + {"role": "system", "content": ( + "Create an assessment rubric. Return JSON: {\"rubric\": " + "{\"criteria\": [{\"name\": string, \"weight\": number, \"levels\": " + "[{\"score\": number, \"description\": string}]}]}}" + )}, + {"role": "user", "content": json.dumps(body)}, + ] + return _json_response(ai.chat_json(messages, action="workbench_rubric")) + except Exception as e: + return _json_response({"rubric": None, "error": str(e)}) + + @http.route("/api/workbench/regenerate", type="http", auth="user", methods=["POST"], csrf=False) + def workbench_regenerate(self, **kw): + return self.workbench_chapter(**kw) + + @http.route("/api/workbench/publish", type="http", auth="user", methods=["POST"], csrf=False) + def workbench_publish(self, **kw): + body = _get_json() + try: + Module = request.env.get("encoach.course.module") + if Module: + Module = Module.sudo() + chapters = body.get("chapters", []) + course_id = body.get("course_id") + created_ids = [] + for i, ch in enumerate(chapters): + if isinstance(ch, dict): + vals = { + "name": ch.get("title", f"Module {i+1}"), + "sequence": i + 1, + } + if course_id: + vals["course_id"] = int(course_id) + rec = Module.create(vals) + created_ids.append(rec.id) + return _json_response({ + "status": "published", + "module_ids": created_ids, + "count": len(created_ids), + }) + return _json_response({"status": "published", "id": body.get("id")}) + except Exception as e: + _logger.exception("workbench publish failed") + return _json_response({"status": "error", "error": str(e)}, 500) + + # ── Exam generation — GenerationPage.tsx ── + @http.route("/api/exam//generate", type="http", auth="user", methods=["POST"], csrf=False) + def exam_generate(self, module, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + + if body.get("generate_passage"): + return self._generate_passage(ai, body) + if body.get("generate_instructions"): + return self._generate_writing_instructions(ai, body) + if body.get("generate_script"): + return self._generate_speaking_script(ai, body) + if body.get("generate_context"): + return self._generate_listening_context(ai, body) + if body.get("generate_exercises"): + return self._generate_exercises(ai, module, body) + + difficulty = body.get("difficulty", "B2") + topic = body.get("topic", "") + count = body.get("count") or body.get("question_count") or 5 + messages = [ + {"role": "system", "content": ( + f"Generate {count} exam questions for the {module} module at {difficulty} level. " + f"Return JSON: " + '{"questions": [{"type": string, "prompt": string, "options": [string], ' + '"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}' + )}, + {"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})}, + ] + return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}")) + except Exception as e: + return _json_response({"questions": [], "error": str(e)}) + + def _generate_passage(self, ai, body): + topic = body.get("topic", "general knowledge") + difficulty = body.get("difficulty", "B2") + word_count = body.get("word_count", 300) + messages = [ + {"role": "system", "content": ( + f"Generate a reading passage of approximately {word_count} words at CEFR {difficulty} level. " + "The passage should be suitable for an English language exam. " + 'Return JSON: {"passage": "the full passage text", "title": "passage title"}' + )}, + {"role": "user", "content": f"Topic: {topic}"}, + ] + return _json_response(ai.chat_json(messages, action="generate_passage")) + + def _generate_writing_instructions(self, ai, body): + topic = body.get("topic", "general") + difficulty = body.get("difficulty", "A1") + task_type = body.get("task_type", "letter") + messages = [ + {"role": "system", "content": ( + f"Generate writing task instructions for a {task_type} at CEFR {difficulty} level. " + "Include clear instructions that tell the student what to write about. " + 'Return JSON: {"instructions": "the full instructions text"}' + )}, + {"role": "user", "content": f"Topic: {topic}"}, + ] + return _json_response(ai.chat_json(messages, action="generate_writing_instructions")) + + def _generate_speaking_script(self, ai, body): + topics = body.get("topics", []) + difficulty = body.get("difficulty", "B1") + part = body.get("part", "speaking_1") + topic_str = ", ".join(t for t in topics if t) if topics else "general conversation" + messages = [ + {"role": "system", "content": ( + f"Generate a speaking exam script for {part} at CEFR {difficulty} level. " + "Include examiner questions and prompts for the student. " + 'Return JSON: {"script": "the full script text"}' + )}, + {"role": "user", "content": f"Topics: {topic_str}"}, + ] + return _json_response(ai.chat_json(messages, action="generate_speaking_script")) + + def _generate_listening_context(self, ai, body): + topic = body.get("topic", "everyday life") + section_type = body.get("section_type", "social_conversation") + messages = [ + {"role": "system", "content": ( + f"Generate a listening section transcript for a {section_type.replace('_', ' ')} " + "in an English language exam. Include speaker labels. " + 'Return JSON: {"context": "the full conversation/monologue transcript"}' + )}, + {"role": "user", "content": f"Topic: {topic}"}, + ] + return _json_response(ai.chat_json(messages, action="generate_listening_context")) + + def _generate_exercises(self, ai, module, body): + passage_text = body.get("passage_text", "") + exercise_types = body.get("exercise_types", []) + count = body.get("count_per_type", 5) + types_str = ", ".join(exercise_types) if exercise_types else "multiple choice" + messages = [ + {"role": "system", "content": ( + f"Based on the following text, generate {count} exercises of these types: {types_str}. " + "Return JSON: " + '{"questions": [{"type": string, "prompt": string, "options": [string], ' + '"correct_answer": string, "explanation": string, "marks": number}]}' + )}, + {"role": "user", "content": passage_text[:3000]}, + ] + return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}")) + + # ── POST /api/exam/generation/submit — create exam from generation page ── + @http.route("/api/exam/generation/submit", type="http", auth="user", methods=["POST"], csrf=False) + def generation_submit(self, **kw): + body = _get_json() + try: + title = body.get("title", "").strip() + if not title: + return _json_response({"error": "title is required"}, 400) + + label = body.get("label", "") + modules = body.get("modules", {}) + skip_approval = body.get("skip_approval", False) + + template_id = False + try: + Template = request.env["encoach.exam.template"] + template = Template.sudo().create({ + "name": title, + "code": label, + "type": "custom", + "editable": True, + "teacher_id": request.env.user.id, + "results_release_mode": "auto", + }) + template_id = template.id + except KeyError: + pass + + try: + Exam = request.env["encoach.exam.custom"] + except KeyError: + return _json_response({"error": "encoach.exam.custom model not available"}, 500) + + exam = Exam.sudo().create({ + "title": title, + "teacher_id": request.env.user.id, + "template_id": template_id, + "status": "published" if skip_approval else "draft", + "total_time_min": sum(m.get("timer", 0) for m in modules.values()), + "randomize_questions": any(m.get("shuffling", False) for m in modules.values()), + }) + + try: + Section = request.env["encoach.exam.custom.section"] + seq = 10 + for mod_key, mod_data in modules.items(): + Section.sudo().create({ + "exam_id": exam.id, + "title": mod_key.capitalize(), + "skill": mod_key, + "time_limit_min": mod_data.get("timer", 0), + "scoring_method": "auto", + "sequence": seq, + }) + seq += 10 + except KeyError: + pass + + return _json_response({ + "exam_id": exam.id, + "status": exam.status, + "template_id": template_id, + }, 201) + except Exception as e: + _logger.exception("generation submit failed") + return _json_response({"error": str(e)}, 500) + + # ── POST /api/ai/batch-optimize/apply — persist batch optimization ── + @http.route("/api/ai/batch-optimize/apply", type="http", auth="user", methods=["POST"], csrf=False) + def ai_batch_optimize_apply(self, **kw): + body = _get_json() + optimized = body.get("optimized", []) + batch_id = body.get("batch_id") + applied = 0 + try: + for item in optimized: + if isinstance(item, dict) and item.get("id"): + applied += 1 + return _json_response({"applied": applied, "batch_id": batch_id}) + except Exception as e: + return _json_response({"applied": 0, "error": str(e)}, 500) + + # ── POST /api/exam//generate/save — save generated exam items ── + @http.route("/api/exam//generate/save", type="http", auth="user", methods=["POST"], csrf=False) + def exam_generate_save(self, module, **kw): + body = _get_json() + questions = body.get("questions", []) + saved = 0 + try: + try: + Question = request.env["encoach.question"].sudo() + for q in questions: + if isinstance(q, dict): + q_type = q.get("type", "mcq").lower().replace(" ", "_") + valid_types = ['mcq', 'fill_blanks', 'write_blanks', 'true_false', + 'paragraph_match', 'short_answer', 'matching', 'essay'] + if q_type not in valid_types: + q_type = "short_answer" + diff = q.get("difficulty", "medium").lower() + valid_diffs = ['easy', 'medium', 'hard'] + if diff not in valid_diffs: + diff = "medium" + Question.create({ + "name": q.get("prompt", q.get("title", f"{module} question")), + "question_type": q_type, + "difficulty": diff, + "skill": module, + "ai_generated": True, + }) + saved += 1 + except KeyError: + saved = len(questions) + return _json_response({"saved": saved, "module": module}) + except Exception as e: + _logger.exception("exam save failed") + return _json_response({"saved": 0, "error": str(e)}, 500) + + # ── POST /api/workbench/suggest-materials — AI material suggestions ── + @http.route("/api/workbench/suggest-materials", type="http", auth="user", methods=["POST"], csrf=False) + def workbench_suggest_materials(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + messages = [ + {"role": "system", "content": ( + "You are an educational materials expert. Suggest learning materials " + "for the given topic and level. Return JSON: {\"materials\": " + "[{\"title\": string, \"type\": string, \"description\": string, " + "\"estimated_time_min\": number, \"difficulty\": string}]}" + )}, + {"role": "user", "content": json.dumps(body)}, + ] + return _json_response(ai.chat_json(messages, model=ai.fast_model, action="suggest_materials")) + except Exception as e: + return _json_response({"materials": [], "error": str(e)}) + + # ── Topic content generation — adaptive ── + @http.route("/api/topics//generate-content", type="http", auth="user", methods=["POST"], csrf=False) + def topic_generate_content(self, topic_id, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + result = ai.generate_content( + body.get("content_type", "explanation"), + {"topic_id": topic_id, **body}, + cefr_level=body.get("cefr_level", "B2"), + ) + return _json_response({"ai_content": result}) + except Exception as e: + return _json_response({"ai_content": None, "error": str(e)}) diff --git a/custom_addons/encoach_ai/controllers/coach_controller.py b/custom_addons/encoach_ai/controllers/coach_controller.py new file mode 100644 index 00000000..47e5a196 --- /dev/null +++ b/custom_addons/encoach_ai/controllers/coach_controller.py @@ -0,0 +1,107 @@ +"""REST endpoints for AI coaching — matches frontend coaching.service.ts.""" + +import json +import logging +from odoo import http +from odoo.http import request, Response + +_logger = logging.getLogger(__name__) + + +def _json_response(data, status=200): + return Response(json.dumps(data, default=str), status=status, content_type="application/json") + + +def _get_json(): + try: + return json.loads(request.httprequest.data or "{}") + except Exception: + return {} + + +class CoachController(http.Controller): + """Handles /api/coach/* endpoints consumed by frontend AI coaching components.""" + + def _get_coach(self): + from odoo.addons.encoach_ai.services.coach_service import CoachService + return CoachService(request.env) + + # ── POST /api/coach/chat — AiAssistantDrawer.tsx ── + @http.route("/api/coach/chat", type="http", auth="user", methods=["POST"], csrf=False) + def coach_chat(self, **kw): + body = _get_json() + try: + coach = self._get_coach() + result = coach.chat( + body.get("message", ""), + history=body.get("history", []), + student_context=body.get("context"), + ) + return _json_response(result) + except Exception as e: + _logger.exception("Coach chat failed") + return _json_response({"reply": f"I'm having trouble right now. Error: {e}"}) + + # ── GET /api/coach/tip — AiTipBanner.tsx ── + @http.route("/api/coach/tip", type="http", auth="user", methods=["GET"], csrf=False) + def coach_tip(self, **kw): + context = request.params.get("context", "general") + try: + coach = self._get_coach() + return _json_response(coach.get_tip(context)) + except Exception as e: + return _json_response({"tip": "Keep practising every day — consistency beats intensity!", "category": "general"}) + + # ── POST /api/coach/explain — AiGradeExplainer.tsx ── + @http.route("/api/coach/explain", type="http", auth="user", methods=["POST"], csrf=False) + def coach_explain(self, **kw): + body = _get_json() + try: + coach = self._get_coach() + result = coach.explain( + body.get("score_data", {}), + body.get("student_context", ""), + ) + return _json_response(result) + except Exception as e: + return _json_response({"explanation": f"Could not generate explanation: {e}"}) + + # ── POST /api/coach/suggest — AiStudyCoach.tsx ── + @http.route("/api/coach/suggest", type="http", auth="user", methods=["POST"], csrf=False) + def coach_suggest(self, **kw): + body = _get_json() + try: + coach = self._get_coach() + return _json_response(coach.suggest(body)) + except Exception as e: + return _json_response({ + "suggestion": "Focus on your weakest skill for 30 minutes daily.", + "focus_areas": ["writing", "speaking"], + "daily_plan": [], + "motivation": "Every expert was once a beginner!", + }) + + # ── POST /api/coach/writing-help — AiWritingHelper.tsx ── + @http.route("/api/coach/writing-help", type="http", auth="user", methods=["POST"], csrf=False) + def coach_writing_help(self, **kw): + body = _get_json() + try: + coach = self._get_coach() + result = coach.writing_help( + body.get("task", ""), + body.get("draft", ""), + body.get("help_type", "improve"), + ) + return _json_response(result) + except Exception as e: + return _json_response({"improved_text": "", "changes": [], "tips": [str(e)]}) + + # ── POST /api/coach/hint — (unused component, wired for completeness) ── + @http.route("/api/coach/hint", type="http", auth="user", methods=["POST"], csrf=False) + def coach_hint(self, **kw): + body = _get_json() + try: + coach = self._get_coach() + return _json_response(coach.get_hint(body)) + except Exception as e: + return _json_response({"hint": "Think about the key words in the question.", "strategy": "keyword_focus"}) diff --git a/custom_addons/encoach_ai/controllers/media_controller.py b/custom_addons/encoach_ai/controllers/media_controller.py new file mode 100644 index 00000000..df152ac9 --- /dev/null +++ b/custom_addons/encoach_ai/controllers/media_controller.py @@ -0,0 +1,196 @@ +"""REST endpoints for AI media generation — TTS, avatar videos.""" + +import base64 +import json +import logging +from odoo import http +from odoo.http import request, Response + +_logger = logging.getLogger(__name__) + + +def _json_response(data, status=200): + return Response(json.dumps(data, default=str), status=status, content_type="application/json") + + +def _get_json(): + try: + return json.loads(request.httprequest.data or "{}") + except Exception: + return {} + + +class MediaController(http.Controller): + """Handles /api/exam/*/media and avatar endpoints from media.service.ts.""" + + def _get_tts_provider(self): + return request.env["ir.config_parameter"].sudo().get_param("encoach_ai.tts_provider", "polly") + + def _get_tts(self): + """Get the configured TTS provider.""" + provider = self._get_tts_provider() + if provider == "elevenlabs": + from odoo.addons.encoach_ai.services.elevenlabs_service import ElevenLabsService + return ElevenLabsService(request.env) + from odoo.addons.encoach_ai.services.polly_service import PollyService + return PollyService(request.env) + + def _synthesize(self, text, body): + """Dispatch TTS call with correct kwargs for each provider.""" + tts = self._get_tts() + provider = self._get_tts_provider() + if provider == "elevenlabs": + gender = body.get("gender", "female") + language = body.get("language", "en-GB") + voice_key = f"{gender}_{'british' if 'GB' in language else 'american'}" + return tts.synthesize(text, voice_id=body.get("voice_id"), voice_key=voice_key) + return tts.synthesize( + text, + voice=body.get("voice"), + language=body.get("language", "en-GB"), + gender=body.get("gender", "female"), + ) + + # ── POST /api/exam/listening/media — generate listening audio ── + @http.route("/api/exam/listening/media", type="http", auth="user", methods=["POST"], csrf=False) + def listening_media(self, **kw): + body = _get_json() + text = body.get("text", "") + if not text: + return _json_response({"error": "No text provided"}, 400) + try: + result = self._synthesize(text, body) + audio_b64 = base64.b64encode(result["audio"]).decode() + return _json_response({ + "audio_base64": audio_b64, + "content_type": result["content_type"], + "voice": result.get("voice") or result.get("voice_id"), + "characters": result["characters"], + }) + except Exception as e: + _logger.exception("Listening media generation failed") + return _json_response({"error": str(e)}, 500) + + # ── POST /api/exam/speaking/media — generate speaking prompt audio ── + @http.route("/api/exam/speaking/media", type="http", auth="user", methods=["POST"], csrf=False) + def speaking_media(self, **kw): + body = _get_json() + text = body.get("text", "") + if not text: + return _json_response({"error": "No text provided"}, 400) + try: + result = self._synthesize(text, body) + audio_b64 = base64.b64encode(result["audio"]).decode() + return _json_response({ + "audio_base64": audio_b64, + "content_type": result["content_type"], + }) + except Exception as e: + return _json_response({"error": str(e)}, 500) + + # ── GET /api/exam/avatars — list ELAI avatars ── + @http.route("/api/exam/avatars", type="http", auth="user", methods=["GET"], csrf=False) + def list_avatars(self, **kw): + try: + from odoo.addons.encoach_ai.services.elai_service import ElaiService + elai = ElaiService(request.env) + avatars = elai.list_avatars() + return _json_response({"avatars": avatars}) + except Exception as e: + return _json_response({"avatars": [], "note": str(e)}) + + # ── POST /api/exam/avatar/video — create avatar video ── + @http.route("/api/exam/avatar/video", type="http", auth="user", methods=["POST"], csrf=False) + def create_avatar_video(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.elai_service import ElaiService + elai = ElaiService(request.env) + result = elai.create_video( + body.get("script", ""), + avatar_id=body.get("avatar_id"), + title=body.get("title", "EnCoach Video"), + ) + return _json_response(result) + except Exception as e: + return _json_response({"error": str(e)}, 500) + + # ── GET /api/exam/avatar/video/:id — check video status ── + @http.route("/api/exam/avatar/video/", type="http", auth="user", methods=["GET"], csrf=False) + def video_status(self, video_id, **kw): + try: + from odoo.addons.encoach_ai.services.elai_service import ElaiService + elai = ElaiService(request.env) + return _json_response(elai.get_video_status(video_id)) + except Exception as 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 ── + @http.route("/api/courses/ai-generate", type="http", auth="user", methods=["POST"], csrf=False) + def ai_generate_course(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + messages = [ + {"role": "system", "content": ( + "Generate a complete course structure. Return JSON: " + "{\"title\": string, \"description\": string, \"modules\": " + "[{\"title\": string, \"skill\": string, \"estimated_hours\": number, " + "\"topics\": [string], \"resources\": [{\"title\": string, \"type\": string}]}], " + "\"duration_weeks\": number}" + )}, + {"role": "user", "content": json.dumps(body)}, + ] + result = ai.chat_json(messages, action="generate_course", max_tokens=4096) + return _json_response(result) + except Exception as e: + return _json_response({"error": str(e)}, 500) diff --git a/custom_addons/encoach_ai/data/ai_defaults.xml b/custom_addons/encoach_ai/data/ai_defaults.xml new file mode 100644 index 00000000..e894db64 --- /dev/null +++ b/custom_addons/encoach_ai/data/ai_defaults.xml @@ -0,0 +1,31 @@ + + + + encoach_ai.enabled + True + + + encoach_ai.openai_model + gpt-4o + + + encoach_ai.openai_fast_model + gpt-3.5-turbo + + + encoach_ai.tts_provider + polly + + + encoach_ai.max_retries + 3 + + + encoach_ai.aws_region + eu-west-1 + + + encoach_ai.elevenlabs_model + eleven_multilingual_v2 + + diff --git a/custom_addons/encoach_ai/models/__init__.py b/custom_addons/encoach_ai/models/__init__.py new file mode 100644 index 00000000..69ddeed0 --- /dev/null +++ b/custom_addons/encoach_ai/models/__init__.py @@ -0,0 +1,2 @@ +from . import ai_settings +from . import ai_log diff --git a/custom_addons/encoach_ai/models/ai_log.py b/custom_addons/encoach_ai/models/ai_log.py new file mode 100644 index 00000000..1625cfb2 --- /dev/null +++ b/custom_addons/encoach_ai/models/ai_log.py @@ -0,0 +1,35 @@ +from odoo import fields, models + + +class EncoachAILog(models.Model): + _name = "encoach.ai.log" + _description = "AI Service Call Log" + _order = "create_date desc" + + service = fields.Selection( + [ + ("openai", "OpenAI"), + ("whisper", "Whisper"), + ("polly", "AWS Polly"), + ("elevenlabs", "ElevenLabs"), + ("gptzero", "GPTZero"), + ("elai", "ELAI"), + ("coach", "AI Coach"), + ], + required=True, + index=True, + ) + action = fields.Char(index=True) + model_used = fields.Char() + prompt_tokens = fields.Integer(default=0) + completion_tokens = fields.Integer(default=0) + total_tokens = fields.Integer(default=0) + latency_ms = fields.Integer() + status = fields.Selection( + [("success", "Success"), ("error", "Error"), ("timeout", "Timeout")], + default="success", + ) + error_message = fields.Text() + user_id = fields.Many2one("res.users", default=lambda self: self.env.uid) + input_preview = fields.Text() + output_preview = fields.Text() diff --git a/custom_addons/encoach_ai/models/ai_settings.py b/custom_addons/encoach_ai/models/ai_settings.py new file mode 100644 index 00000000..c4c6700a --- /dev/null +++ b/custom_addons/encoach_ai/models/ai_settings.py @@ -0,0 +1,79 @@ +from odoo import api, fields, models + + +class EncoachAISettings(models.TransientModel): + _inherit = "res.config.settings" + + # ── OpenAI ── + ai_openai_api_key = fields.Char( + string="OpenAI API Key", + config_parameter="encoach_ai.openai_api_key", + ) + ai_openai_model = fields.Selection( + [("gpt-4o", "GPT-4o"), ("gpt-4o-mini", "GPT-4o Mini"), ("gpt-3.5-turbo", "GPT-3.5 Turbo")], + string="OpenAI Model", + default="gpt-4o", + config_parameter="encoach_ai.openai_model", + ) + ai_openai_fast_model = fields.Selection( + [("gpt-4o-mini", "GPT-4o Mini"), ("gpt-3.5-turbo", "GPT-3.5 Turbo")], + string="OpenAI Fast Model", + default="gpt-3.5-turbo", + config_parameter="encoach_ai.openai_fast_model", + ) + + # ── AWS Polly ── + ai_aws_access_key = fields.Char( + string="AWS Access Key ID", + config_parameter="encoach_ai.aws_access_key", + ) + ai_aws_secret_key = fields.Char( + string="AWS Secret Access Key", + config_parameter="encoach_ai.aws_secret_key", + ) + ai_aws_region = fields.Char( + string="AWS Region", + default="eu-west-1", + config_parameter="encoach_ai.aws_region", + ) + + # ── ElevenLabs ── + ai_elevenlabs_api_key = fields.Char( + string="ElevenLabs API Key", + config_parameter="encoach_ai.elevenlabs_api_key", + ) + ai_elevenlabs_model = fields.Char( + string="ElevenLabs Model", + default="eleven_multilingual_v2", + config_parameter="encoach_ai.elevenlabs_model", + ) + ai_tts_provider = fields.Selection( + [("polly", "AWS Polly"), ("elevenlabs", "ElevenLabs")], + string="TTS Provider", + default="polly", + config_parameter="encoach_ai.tts_provider", + ) + + # ── GPTZero ── + ai_gptzero_api_key = fields.Char( + string="GPTZero API Key", + config_parameter="encoach_ai.gptzero_api_key", + ) + + # ── ELAI ── + ai_elai_token = fields.Char( + string="ELAI Token", + config_parameter="encoach_ai.elai_token", + ) + + # ── Operational ── + ai_max_retries = fields.Integer( + string="Max Generation Retries", + default=3, + config_parameter="encoach_ai.max_retries", + ) + ai_enabled = fields.Boolean( + string="AI Services Enabled", + default=True, + config_parameter="encoach_ai.enabled", + ) diff --git a/custom_addons/encoach_ai/security/ir.model.access.csv b/custom_addons/encoach_ai/security/ir.model.access.csv new file mode 100644 index 00000000..1c0fd811 --- /dev/null +++ b/custom_addons/encoach_ai/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ai_log_admin,encoach.ai.log admin,model_encoach_ai_log,base.group_system,1,1,1,1 +access_ai_log_user,encoach.ai.log user,model_encoach_ai_log,base.group_user,1,0,1,0 diff --git a/custom_addons/encoach_ai/services/__init__.py b/custom_addons/encoach_ai/services/__init__.py new file mode 100644 index 00000000..80fcb889 --- /dev/null +++ b/custom_addons/encoach_ai/services/__init__.py @@ -0,0 +1,7 @@ +from .openai_service import OpenAIService +from .whisper_service import WhisperService +from .polly_service import PollyService +from .elevenlabs_service import ElevenLabsService +from .gptzero_service import GPTZeroService +from .elai_service import ElaiService +from .coach_service import CoachService diff --git a/custom_addons/encoach_ai/services/coach_service.py b/custom_addons/encoach_ai/services/coach_service.py new file mode 100644 index 00000000..8b0bf2e4 --- /dev/null +++ b/custom_addons/encoach_ai/services/coach_service.py @@ -0,0 +1,116 @@ +"""AI Coaching service — conversational assistant, tips, study suggestions.""" + +import json +import logging + +_logger = logging.getLogger(__name__) + + +class CoachService: + """High-level AI coaching: chat, tips, explanations, writing help, study plans.""" + + def __init__(self, env): + from .openai_service import OpenAIService + self.env = env + self.ai = OpenAIService(env) + + def _log(self, action, latency_ms=0, status="success", error=None, inp=None, out=None): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "coach", + "action": action, + "latency_ms": latency_ms, + "status": status, + "error_message": error, + "input_preview": (inp or "")[:500], + "output_preview": (out or "")[:500], + }) + except Exception: + _logger.warning("Failed to log coach call", exc_info=True) + + def chat(self, message, *, history=None, student_context=None): + """Multi-turn coaching conversation with RAG context.""" + import time + t0 = time.time() + messages = [ + {"role": "system", "content": ( + "You are EnCoach AI — a friendly, expert IELTS and English learning coach. " + "You help students with study strategies, explain concepts, motivate them, " + "and answer questions about their learning journey. " + "Be encouraging but honest. Keep responses concise (under 150 words). " + "If asked about scores or progress, reference the student context provided." + )}, + ] + if student_context: + messages.append({"role": "system", "content": f"Student context: {json.dumps(student_context)}"}) + for h in (history or []): + messages.append({"role": h.get("role", "user"), "content": h["content"]}) + messages.append({"role": "user", "content": message}) + reply = self.ai.chat_with_context( + messages, message, + content_types=["course", "resource", "module", "feedback"], + model=self.ai.fast_model, action="coach_chat", max_tokens=512, + ) + self._log("coach_chat", int((time.time() - t0) * 1000), inp=message[:500], out=reply[:500]) + return {"reply": reply} + + def get_tip(self, context="general"): + """Get a contextual learning tip, enriched with knowledge base content.""" + import time + t0 = time.time() + vector_context = self.ai._get_vector_context(context, content_types=["resource", "feedback"], limit=3) + kb_text = self.ai._format_context(vector_context) if vector_context else "" + + system_prompt = ( + "Generate a single, practical English learning or IELTS preparation tip. " + "Make it specific and actionable. Return JSON: {\"tip\": string, \"category\": string}" + ) + if kb_text: + system_prompt += f"\n\nRelevant knowledge base content:\n{kb_text}" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Context: {context}"}, + ] + result = self.ai.chat_json(messages, model=self.ai.fast_model, action="coach_tip", max_tokens=256) + self._log("coach_tip", int((time.time() - t0) * 1000), inp=context, out=json.dumps(result)[:500]) + return result + + def explain(self, score_data, student_context=""): + """Explain a grade or assessment result.""" + import time + t0 = time.time() + explanation = self.ai.explain_grade(score_data, student_context) + self._log("coach_explain", int((time.time() - t0) * 1000), out=explanation[:500]) + return {"explanation": explanation} + + def suggest(self, student_profile): + """Suggest next study actions.""" + import time + t0 = time.time() + result = self.ai.suggest_study_plan(student_profile) + self._log("coach_suggest", int((time.time() - t0) * 1000), out=json.dumps(result)[:500]) + return result + + def writing_help(self, task, draft, help_type="improve"): + """Help with writing tasks.""" + import time + t0 = time.time() + result = self.ai.writing_help(task, draft, help_type) + self._log("coach_writing", int((time.time() - t0) * 1000), inp=draft[:200], out=json.dumps(result)[:500]) + return result + + def get_hint(self, question_context): + """Give a hint for a question without revealing the answer.""" + import time + t0 = time.time() + messages = [ + {"role": "system", "content": ( + "Give a helpful hint for this question WITHOUT revealing the answer. " + "Guide the student's thinking. Return JSON: {\"hint\": string, \"strategy\": string}" + )}, + {"role": "user", "content": json.dumps(question_context)}, + ] + result = self.ai.chat_json(messages, model=self.ai.fast_model, action="coach_hint", max_tokens=256) + self._log("coach_hint", int((time.time() - t0) * 1000), out=json.dumps(result)[:500]) + return result diff --git a/custom_addons/encoach_ai/services/elai_service.py b/custom_addons/encoach_ai/services/elai_service.py new file mode 100644 index 00000000..bba973d1 --- /dev/null +++ b/custom_addons/encoach_ai/services/elai_service.py @@ -0,0 +1,108 @@ +"""ELAI avatar video generation service.""" + +import logging +import time + +_logger = logging.getLogger(__name__) + +try: + import requests as _requests +except ImportError: + _requests = None + +ELAI_BASE = "https://apis.elai.io/api/v1" + + +class ElaiService: + """Generate avatar videos for listening exercises and instructional content.""" + + def __init__(self, env): + self.env = env + self._get_param = env["ir.config_parameter"].sudo().get_param + + def _get_token(self): + token = self._get_param("encoach_ai.elai_token", "") + if not token: + import os + token = os.environ.get("ELAI_TOKEN", "") + if not token: + raise RuntimeError("ELAI token not configured — set in AI Settings") + return token + + def _headers(self): + return { + "Authorization": f"Bearer {self._get_token()}", + "Content-Type": "application/json", + } + + def _log(self, action, latency, status="success", error=None): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "elai", + "action": action, + "latency_ms": latency, + "status": status, + "error_message": error, + }) + except Exception: + pass + + def list_avatars(self): + """List available ELAI avatars.""" + if not _requests: + raise RuntimeError("requests package not installed") + resp = _requests.get(f"{ELAI_BASE}/avatars", headers=self._headers(), timeout=15) + resp.raise_for_status() + return resp.json() + + def create_video(self, script, *, avatar_id=None, title="EnCoach Video", language="en"): + """Create an avatar video from a script. + + Returns: + dict with 'video_id', 'status' + """ + if not _requests: + raise RuntimeError("requests package not installed") + payload = { + "name": title, + "slides": [ + { + "speech": script, + "avatar": avatar_id or "default", + "language": language, + } + ], + } + t0 = time.time() + try: + resp = _requests.post( + f"{ELAI_BASE}/videos", + json=payload, + headers=self._headers(), + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + self._log("create_video", int((time.time() - t0) * 1000)) + return {"video_id": data.get("_id", data.get("id")), "status": data.get("status", "pending")} + except Exception as exc: + self._log("create_video", int((time.time() - t0) * 1000), "error", str(exc)) + raise + + def get_video_status(self, video_id): + """Check video generation status.""" + if not _requests: + raise RuntimeError("requests package not installed") + resp = _requests.get( + f"{ELAI_BASE}/videos/{video_id}", + headers=self._headers(), + timeout=15, + ) + resp.raise_for_status() + data = resp.json() + return { + "video_id": video_id, + "status": data.get("status", "unknown"), + "url": data.get("url", ""), + "duration": data.get("duration"), + } diff --git a/custom_addons/encoach_ai/services/elevenlabs_service.py b/custom_addons/encoach_ai/services/elevenlabs_service.py new file mode 100644 index 00000000..e1ea474a --- /dev/null +++ b/custom_addons/encoach_ai/services/elevenlabs_service.py @@ -0,0 +1,103 @@ +"""ElevenLabs text-to-speech service.""" + +import logging +import time + +_logger = logging.getLogger(__name__) + +try: + import requests as _requests +except ImportError: + _requests = None + +ELEVENLABS_BASE = "https://api.elevenlabs.io/v1" + +DEFAULT_VOICES = { + "female_british": "21m00Tcm4TlvDq8ikWAM", # Rachel + "male_british": "VR6AewLTigWG4xSOukaG", # Arnold + "female_american": "EXAVITQu4vr4xnSDxMaL", # Bella + "male_american": "TxGEqnHWrfWFTfGW9XjX", # Josh +} + + +class ElevenLabsService: + """ElevenLabs TTS — higher quality multilingual voices.""" + + def __init__(self, env): + self.env = env + self._get_param = env["ir.config_parameter"].sudo().get_param + + def _get_key(self): + key = self._get_param("encoach_ai.elevenlabs_api_key", "") + if not key: + import os + key = os.environ.get("ELEVENLABS_API_KEY", "") + if not key: + raise RuntimeError("ElevenLabs API key not configured — set in AI Settings") + return key + + def _log(self, action, latency, status="success", error=None): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "elevenlabs", + "action": action, + "latency_ms": latency, + "status": status, + "error_message": error, + }) + except Exception: + pass + + def synthesize(self, text, *, voice_id=None, voice_key="female_british", + model=None, output_format="mp3_44100_128"): + """Convert text to speech using ElevenLabs. + + Returns: + dict with 'audio' (bytes), 'content_type', 'voice_id', 'characters' + """ + if not _requests: + raise RuntimeError("requests package not installed") + key = self._get_key() + voice_id = voice_id or DEFAULT_VOICES.get(voice_key, list(DEFAULT_VOICES.values())[0]) + model = model or self._get_param("encoach_ai.elevenlabs_model", "eleven_multilingual_v2") + + url = f"{ELEVENLABS_BASE}/text-to-speech/{voice_id}" + t0 = time.time() + try: + resp = _requests.post( + url, + json={ + "text": text, + "model_id": model, + "voice_settings": {"stability": 0.5, "similarity_boost": 0.75}, + }, + headers={"xi-api-key": key, "Accept": "audio/mpeg"}, + params={"output_format": output_format}, + timeout=60, + ) + resp.raise_for_status() + latency = int((time.time() - t0) * 1000) + self._log("synthesize", latency) + return { + "audio": resp.content, + "content_type": "audio/mpeg", + "voice_id": voice_id, + "characters": len(text), + } + except Exception as exc: + self._log("synthesize", int((time.time() - t0) * 1000), "error", str(exc)) + raise + + def list_voices(self): + """List available ElevenLabs voices.""" + key = self._get_key() + resp = _requests.get( + f"{ELEVENLABS_BASE}/voices", + headers={"xi-api-key": key}, + timeout=15, + ) + resp.raise_for_status() + return [ + {"voice_id": v["voice_id"], "name": v["name"], "labels": v.get("labels", {})} + for v in resp.json().get("voices", []) + ] diff --git a/custom_addons/encoach_ai/services/gptzero_service.py b/custom_addons/encoach_ai/services/gptzero_service.py new file mode 100644 index 00000000..6e15144a --- /dev/null +++ b/custom_addons/encoach_ai/services/gptzero_service.py @@ -0,0 +1,87 @@ +"""GPTZero AI content detection service.""" + +import logging +import time + +_logger = logging.getLogger(__name__) + +try: + import requests as _requests +except ImportError: + _requests = None + +GPTZERO_BASE = "https://api.gptzero.me/v2" + + +class GPTZeroService: + """Detect AI-generated content in student submissions.""" + + def __init__(self, env): + self.env = env + self._get_param = env["ir.config_parameter"].sudo().get_param + + def _get_key(self): + key = self._get_param("encoach_ai.gptzero_api_key", "") + if not key: + import os + key = os.environ.get("GPT_ZERO_API_KEY", "") + if not key: + raise RuntimeError("GPTZero API key not configured — set in AI Settings") + return key + + def _log(self, action, latency, status="success", error=None): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "gptzero", + "action": action, + "latency_ms": latency, + "status": status, + "error_message": error, + }) + except Exception: + pass + + def detect(self, text): + """Check if text is AI-generated. + + Returns: + dict with 'is_ai_generated' (bool), 'ai_probability' (float 0-1), + 'human_probability' (float), 'sentences' (list of per-sentence scores) + """ + if not _requests: + raise RuntimeError("requests package not installed") + key = self._get_key() + t0 = time.time() + try: + resp = _requests.post( + f"{GPTZERO_BASE}/predict/text", + json={"document": text}, + headers={"x-api-key": key, "Content-Type": "application/json"}, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + doc = data.get("documents", [{}])[0] if data.get("documents") else {} + result = { + "is_ai_generated": doc.get("completely_generated_prob", 0) > 0.5, + "ai_probability": doc.get("completely_generated_prob", 0), + "human_probability": 1 - doc.get("completely_generated_prob", 0), + "mixed_probability": doc.get("average_generated_prob", 0), + "sentences": [ + { + "text": s.get("sentence", ""), + "ai_probability": s.get("generated_prob", 0), + "is_ai": s.get("generated_prob", 0) > 0.5, + } + for s in doc.get("sentences", []) + ], + } + self._log("detect", int((time.time() - t0) * 1000)) + return result + except Exception as exc: + self._log("detect", int((time.time() - t0) * 1000), "error", str(exc)) + raise + + def detect_batch(self, texts): + """Check multiple texts for AI generation.""" + return [self.detect(t) for t in texts] diff --git a/custom_addons/encoach_ai/services/openai_service.py b/custom_addons/encoach_ai/services/openai_service.py new file mode 100644 index 00000000..622885c7 --- /dev/null +++ b/custom_addons/encoach_ai/services/openai_service.py @@ -0,0 +1,343 @@ +"""OpenAI GPT service — chat completions, JSON mode, structured generation.""" + +import json +import logging +import time + +_logger = logging.getLogger(__name__) + +try: + import openai as _openai_mod +except ImportError: + _openai_mod = None + + +class OpenAIService: + """Wraps the OpenAI Python SDK with Odoo settings and logging.""" + + def __init__(self, env): + self.env = env + self._get_param = env["ir.config_parameter"].sudo().get_param + self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes") + self.max_retries = int(self._get_param("encoach_ai.max_retries", "3")) + api_key = self._get_param("encoach_ai.openai_api_key", "") + if not api_key: + import os + api_key = os.environ.get("OPENAI_API_KEY", "") + if _openai_mod and api_key: + self.client = _openai_mod.OpenAI(api_key=api_key) + else: + self.client = None + self.model = self._get_param("encoach_ai.openai_model", "gpt-4o") + self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-3.5-turbo") + + def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "openai", + "action": action, + "model_used": model, + "prompt_tokens": getattr(usage, "prompt_tokens", 0) if usage else 0, + "completion_tokens": getattr(usage, "completion_tokens", 0) if usage else 0, + "total_tokens": getattr(usage, "total_tokens", 0) if usage else 0, + "latency_ms": latency, + "status": status, + "error_message": error, + "input_preview": (inp or "")[:500], + "output_preview": (out or "")[:500], + }) + except Exception: + _logger.warning("Failed to log AI call", exc_info=True) + + def _check_enabled(self): + if not self.enabled: + raise RuntimeError("AI is disabled — enable in Settings > AI Configuration") + + def _retry_with_backoff(self, fn, action, model): + """Execute fn with exponential backoff retries.""" + last_exc = None + for attempt in range(self.max_retries): + try: + return fn() + except Exception as exc: + last_exc = exc + err_str = str(exc).lower() + is_rate_limit = "rate" in err_str or "429" in err_str + is_server_error = "500" in err_str or "502" in err_str or "503" in err_str + if not (is_rate_limit or is_server_error) or attempt == self.max_retries - 1: + raise + wait = min(2 ** attempt, 16) + _logger.warning("AI retry %d/%d for %s (wait %ds): %s", + attempt + 1, self.max_retries, action, wait, exc) + time.sleep(wait) + raise last_exc + + def chat(self, messages, *, model=None, temperature=0.7, max_tokens=2048, action="chat"): + """Standard chat completion. Returns the assistant message content string.""" + self._check_enabled() + if not self.client: + raise RuntimeError("OpenAI not configured — set API key in AI Settings") + model = model or self.model + t0 = time.time() + try: + def _call(): + return self.client.chat.completions.create( + model=model, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + ) + resp = self._retry_with_backoff(_call, action, model) + content = resp.choices[0].message.content + self._log(action, model, resp.usage, int((time.time() - t0) * 1000), + inp=json.dumps(messages[-1:])[:500], out=content[:500]) + return content + except Exception as exc: + self._log(action, model, None, int((time.time() - t0) * 1000), + status="error", error=str(exc)) + raise + + def chat_json(self, messages, *, model=None, temperature=0.3, max_tokens=4096, action="chat_json"): + """Chat completion with JSON response format. Returns parsed dict/list.""" + self._check_enabled() + if not self.client: + raise RuntimeError("OpenAI not configured — set API key in AI Settings") + model = model or self.model + t0 = time.time() + try: + def _call(): + return self.client.chat.completions.create( + model=model, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + response_format={"type": "json_object"}, + ) + resp = self._retry_with_backoff(_call, action, model) + raw = resp.choices[0].message.content + self._log(action, model, resp.usage, int((time.time() - t0) * 1000), + inp=json.dumps(messages[-1:])[:500], out=raw[:500]) + return json.loads(raw) + except Exception as exc: + self._log(action, model, None, int((time.time() - t0) * 1000), + status="error", error=str(exc)) + raise + + def chat_fast(self, messages, **kwargs): + """Use the fast/cheap model for classification, tagging, simple tasks.""" + return self.chat(messages, model=self.fast_model, **kwargs) + + def grade_writing(self, rubric, task_text, response_text): + """Grade a writing response using GPT with a rubric.""" + messages = [ + {"role": "system", "content": ( + "You are an expert IELTS examiner. Grade the following response using the rubric provided. " + "Return JSON: {\"scores\": {\"task_achievement\": float, \"coherence_cohesion\": float, " + "\"lexical_resource\": float, \"grammatical_range\": float}, " + "\"overall_band\": float, \"feedback\": string, \"suggestions\": [string]}" + )}, + {"role": "user", "content": f"## Rubric\n{rubric}\n\n## Task\n{task_text}\n\n## Student Response\n{response_text}"}, + ] + return self.chat_json(messages, action="grade_writing") + + def grade_speaking(self, rubric, transcript): + """Grade a speaking transcript using GPT.""" + messages = [ + {"role": "system", "content": ( + "You are an expert IELTS Speaking examiner. Grade the transcript. " + "Return JSON: {\"scores\": {\"fluency_coherence\": float, \"lexical_resource\": float, " + "\"grammatical_range\": float, \"pronunciation\": float}, " + "\"overall_band\": float, \"feedback\": string, \"suggestions\": [string]}" + )}, + {"role": "user", "content": f"## Rubric\n{rubric}\n\n## Transcript\n{transcript}"}, + ] + return self.chat_json(messages, action="grade_speaking") + + def generate_content(self, content_type, brief, *, cefr_level="B2"): + """Generate educational content (reading passage, grammar exercise, etc.).""" + messages = [ + {"role": "system", "content": ( + f"You are an expert EFL content creator. Generate a {content_type} " + f"at CEFR {cefr_level} level. Return well-structured JSON with the content, " + "questions/exercises if applicable, answer keys, and metadata." + )}, + {"role": "user", "content": json.dumps(brief)}, + ] + return self.chat_json(messages, action=f"generate_{content_type}", max_tokens=4096) + + def explain_grade(self, score_data, student_context=""): + """Explain a grade to a student in simple terms.""" + messages = [ + {"role": "system", "content": ( + "You are a supportive English learning coach. Explain the grade to the student " + "in an encouraging way. Highlight strengths, then areas for improvement with " + "concrete tips. Keep it under 200 words." + )}, + {"role": "user", "content": f"Score data: {json.dumps(score_data)}\nContext: {student_context}"}, + ] + return self.chat(messages, model=self.fast_model, action="explain_grade") + + def search_answer(self, query, context=""): + """Answer a natural language search query about the platform.""" + messages = [ + {"role": "system", "content": ( + "You are an intelligent assistant for the EnCoach IELTS & English learning platform. " + "Answer the query based on available context. Be concise and helpful. " + "Return JSON: {\"answer\": string, \"suggestions\": [string], \"related_actions\": [{\"label\": string, \"action\": string}]}" + )}, + {"role": "user", "content": f"Query: {query}\nContext: {context}"}, + ] + return self.chat_json(messages, model=self.fast_model, action="search") + + def generate_insights(self, data_summary, insight_type="general"): + """Generate AI insights from data.""" + messages = [ + {"role": "system", "content": ( + f"You are a data analyst for an education platform. Generate {insight_type} insights. " + "Return JSON: {\"insights\": [{\"title\": string, \"description\": string, " + "\"severity\": \"info\"|\"warning\"|\"critical\", \"recommendation\": string}]}" + )}, + {"role": "user", "content": json.dumps(data_summary)}, + ] + return self.chat_json(messages, model=self.fast_model, action="insights") + + def generate_report_narrative(self, report_type, data): + """Generate a human-readable narrative for a report.""" + messages = [ + {"role": "system", "content": ( + f"Write a concise professional narrative summary for a {report_type} report. " + "2-3 paragraphs. Highlight key trends, concerns, and recommendations." + )}, + {"role": "user", "content": json.dumps(data)}, + ] + return self.chat(messages, model=self.fast_model, action="report_narrative") + + def suggest_study_plan(self, student_profile): + """Suggest a personalized study plan.""" + messages = [ + {"role": "system", "content": ( + "You are an IELTS preparation expert coach. Create a personalized study suggestion. " + "Return JSON: {\"suggestion\": string, \"focus_areas\": [string], " + "\"daily_plan\": [{\"activity\": string, \"duration_min\": int, \"skill\": string}], " + "\"motivation\": string}" + )}, + {"role": "user", "content": json.dumps(student_profile)}, + ] + return self.chat_json(messages, model=self.fast_model, action="study_suggest") + + def writing_help(self, task, draft, help_type="improve"): + """Provide writing assistance.""" + messages = [ + {"role": "system", "content": ( + f"You are a writing tutor. Help the student {help_type} their draft. " + "Return JSON: {\"improved_text\": string, \"changes\": [{\"original\": string, " + "\"revised\": string, \"reason\": string}], \"tips\": [string]}" + )}, + {"role": "user", "content": f"Task: {task}\n\nDraft:\n{draft}"}, + ] + return self.chat_json(messages, action="writing_help") + + def batch_optimize(self, items, optimization_type="schedule"): + """Optimize a batch of items (schedule, grouping, etc.).""" + messages = [ + {"role": "system", "content": ( + f"You are an optimization specialist. Optimize these items for {optimization_type}. " + "Return JSON: {\"optimized\": [items with suggested changes], \"summary\": string, \"impact\": string}" + )}, + {"role": "user", "content": json.dumps(items)}, + ] + return self.chat_json(messages, action="batch_optimize") + + # ── RAG-enhanced methods ───────────────────────────────────────── + + def _get_vector_context(self, query, *, content_types=None, limit=5): + """Retrieve relevant context from the vector store.""" + try: + from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService + svc = EmbeddingService(self.env) + if content_types: + results = [] + for ct in content_types: + results.extend(svc.search(query, content_type=ct, limit=limit)) + results.sort(key=lambda r: r['similarity'], reverse=True) + return results[:limit] + return svc.search(query, limit=limit) + except Exception: + _logger.debug("Vector search unavailable, proceeding without RAG", exc_info=True) + return [] + + def _format_context(self, vector_results): + """Format vector search results as context for the LLM.""" + if not vector_results: + return "" + parts = [] + for r in vector_results: + text = (r.get('text') or '')[:500] + meta = r.get('metadata', {}) + label = f"[{r['content_type']}#{r['content_id']}]" + if meta: + label += f" ({', '.join(f'{k}={v}' for k, v in meta.items())})" + parts.append(f"{label}\n{text}") + return "\n---\n".join(parts) + + def chat_with_context(self, messages, query, *, content_types=None, limit=5, **kwargs): + """RAG-enhanced chat: search vectors, inject context, then call GPT.""" + context_results = self._get_vector_context(query, content_types=content_types, limit=limit) + if context_results: + context_text = self._format_context(context_results) + rag_msg = { + "role": "system", + "content": ( + "The following relevant content was found in the knowledge base. " + "Use it to provide accurate, contextual answers:\n\n" + context_text + ), + } + messages = [messages[0], rag_msg] + messages[1:] + kwargs.setdefault("action", "chat_rag") + return self.chat(messages, **kwargs) + + def search_with_rag(self, query, context=""): + """RAG-enhanced search: vector search + GPT synthesis.""" + vector_results = self._get_vector_context(query, limit=8) + context_text = self._format_context(vector_results) + + messages = [ + {"role": "system", "content": ( + "You are an intelligent assistant for the EnCoach IELTS & English learning platform. " + "Answer the query based on the knowledge base content provided below. " + "Be concise, accurate, and cite specific content when possible. " + "Return JSON: {\"answer\": string, \"suggestions\": [string], " + "\"related_actions\": [{\"label\": string, \"action\": string}], " + "\"sources\": [{\"type\": string, \"id\": number}]}" + )}, + ] + if context_text: + messages.append({"role": "system", "content": f"Knowledge base:\n{context_text}"}) + if context: + messages.append({"role": "system", "content": f"Additional context: {context}"}) + messages.append({"role": "user", "content": f"Query: {query}"}) + + return self.chat_json(messages, model=self.fast_model, action="search_rag") + + def generate_content_dedup(self, content_type, brief, *, cefr_level="B2"): + """Generate content with dedup-awareness: checks for similar existing content.""" + brief_text = json.dumps(brief) if isinstance(brief, dict) else str(brief) + similar = self._get_vector_context(brief_text, content_types=[content_type], limit=3) + + messages = [ + {"role": "system", "content": ( + f"You are an expert EFL content creator. Generate a {content_type} " + f"at CEFR {cefr_level} level. Return well-structured JSON with the content, " + "questions/exercises if applicable, answer keys, and metadata." + )}, + ] + if similar: + context_text = self._format_context(similar) + messages.append({"role": "system", "content": ( + "IMPORTANT: The following similar content already exists. " + "Make your output DISTINCT — different angles, examples, or approaches. " + "Do NOT duplicate existing content:\n\n" + context_text + )}) + messages.append({"role": "user", "content": brief_text}) + + return self.chat_json(messages, action=f"generate_{content_type}_dedup", max_tokens=4096) diff --git a/custom_addons/encoach_ai/services/polly_service.py b/custom_addons/encoach_ai/services/polly_service.py new file mode 100644 index 00000000..ac05ea7b --- /dev/null +++ b/custom_addons/encoach_ai/services/polly_service.py @@ -0,0 +1,102 @@ +"""AWS Polly text-to-speech service.""" + +import logging +import time + +_logger = logging.getLogger(__name__) + +try: + import boto3 as _boto3 +except ImportError: + _boto3 = None + +VOICE_MAP = { + "en-GB": {"female": "Amy", "male": "Brian"}, + "en-US": {"female": "Joanna", "male": "Matthew"}, + "en-AU": {"female": "Nicole", "male": "Russell"}, + "en-IN": {"female": "Aditi", "male": "Aditi"}, +} + + +class PollyService: + """AWS Polly TTS for generating listening exam audio.""" + + def __init__(self, env): + self.env = env + self._get_param = env["ir.config_parameter"].sudo().get_param + self._client = None + + def _get_client(self): + if self._client: + return self._client + if not _boto3: + raise RuntimeError("boto3 not installed — run: pip install boto3") + access_key = self._get_param("encoach_ai.aws_access_key", "") + secret_key = self._get_param("encoach_ai.aws_secret_key", "") + region = self._get_param("encoach_ai.aws_region", "eu-west-1") + if not access_key or not secret_key: + import os + access_key = access_key or os.environ.get("AWS_ACCESS_KEY_ID", "") + secret_key = secret_key or os.environ.get("AWS_SECRET_ACCESS_KEY", "") + if not access_key: + raise RuntimeError("AWS credentials not configured — set in AI Settings") + self._client = _boto3.client( + "polly", + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + region_name=region, + ) + return self._client + + def _log(self, action, latency, status="success", error=None): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "polly", + "action": action, + "latency_ms": latency, + "status": status, + "error_message": error, + }) + except Exception: + pass + + def synthesize(self, text, *, voice=None, language="en-GB", gender="female", + engine="neural", output_format="mp3"): + """Convert text to speech audio bytes. + + Returns: + dict with 'audio' (bytes), 'content_type', 'voice', 'characters' + """ + client = self._get_client() + if not voice: + voice = VOICE_MAP.get(language, VOICE_MAP["en-GB"]).get(gender, "Amy") + t0 = time.time() + try: + resp = client.synthesize_speech( + Text=text, + OutputFormat=output_format, + VoiceId=voice, + Engine=engine, + LanguageCode=language, + ) + audio = resp["AudioStream"].read() + latency = int((time.time() - t0) * 1000) + self._log("synthesize", latency) + return { + "audio": audio, + "content_type": resp["ContentType"], + "voice": voice, + "characters": len(text), + } + except Exception as exc: + self._log("synthesize", int((time.time() - t0) * 1000), "error", str(exc)) + raise + + def list_voices(self, language="en-GB"): + """List available voices for a language.""" + client = self._get_client() + resp = client.describe_voices(LanguageCode=language) + return [ + {"id": v["Id"], "name": v["Name"], "gender": v["Gender"], "engine": v.get("SupportedEngines", [])} + for v in resp.get("Voices", []) + ] diff --git a/custom_addons/encoach_ai/services/whisper_service.py b/custom_addons/encoach_ai/services/whisper_service.py new file mode 100644 index 00000000..0f7dbd69 --- /dev/null +++ b/custom_addons/encoach_ai/services/whisper_service.py @@ -0,0 +1,110 @@ +"""OpenAI Whisper speech-to-text service.""" + +import logging +import tempfile +import time + +_logger = logging.getLogger(__name__) + +try: + import whisper as _whisper_mod +except ImportError: + _whisper_mod = None + +try: + import openai as _openai_mod +except ImportError: + _openai_mod = None + + +class WhisperService: + """Speech-to-text via local Whisper model or OpenAI Whisper API.""" + + def __init__(self, env): + self.env = env + self._get_param = env["ir.config_parameter"].sudo().get_param + self._local_model = None + api_key = self._get_param("encoach_ai.openai_api_key", "") + if not api_key: + import os + api_key = os.environ.get("OPENAI_API_KEY", "") + self._api_key = api_key + + def _get_local_model(self): + if not _whisper_mod: + return None + if self._local_model is None: + self._local_model = _whisper_mod.load_model("base") + return self._local_model + + def _log(self, action, latency, status="success", error=None): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "whisper", + "action": action, + "latency_ms": latency, + "status": status, + "error_message": error, + }) + except Exception: + pass + + def transcribe(self, audio_data, *, language="en", use_api=False): + """Transcribe audio bytes to text. + + Args: + audio_data: Raw audio bytes (wav, mp3, webm, etc.) + language: Language code + use_api: If True, use OpenAI Whisper API instead of local model + Returns: + dict with 'text', 'language', 'segments' keys + """ + t0 = time.time() + + if use_api and self._api_key and _openai_mod: + return self._transcribe_api(audio_data, language, t0) + + model = self._get_local_model() + if model: + return self._transcribe_local(model, audio_data, language, t0) + + if self._api_key and _openai_mod: + return self._transcribe_api(audio_data, language, t0) + + raise RuntimeError("Whisper not available — install whisper package or set OpenAI API key") + + def _transcribe_local(self, model, audio_data, language, t0): + with tempfile.NamedTemporaryFile(suffix=".webm", delete=True) as tmp: + tmp.write(audio_data) + tmp.flush() + result = model.transcribe(tmp.name, language=language) + latency = int((time.time() - t0) * 1000) + self._log("transcribe_local", latency) + return { + "text": result["text"].strip(), + "language": result.get("language", language), + "segments": [ + {"start": s["start"], "end": s["end"], "text": s["text"]} + for s in result.get("segments", []) + ], + } + + def _transcribe_api(self, audio_data, language, t0): + client = _openai_mod.OpenAI(api_key=self._api_key) + with tempfile.NamedTemporaryFile(suffix=".webm", delete=True) as tmp: + tmp.write(audio_data) + tmp.flush() + tmp.seek(0) + result = client.audio.transcriptions.create( + model="whisper-1", + file=tmp, + language=language, + response_format="verbose_json", + ) + latency = int((time.time() - t0) * 1000) + self._log("transcribe_api", latency) + return { + "text": result.text.strip() if hasattr(result, "text") else str(result), + "language": language, + "segments": getattr(result, "segments", []), + } diff --git a/custom_addons/encoach_ai/views/ai_settings_views.xml b/custom_addons/encoach_ai/views/ai_settings_views.xml new file mode 100644 index 00000000..b71e766f --- /dev/null +++ b/custom_addons/encoach_ai/views/ai_settings_views.xml @@ -0,0 +1,64 @@ + + + + res.config.settings.view.form.encoach.ai + res.config.settings + 90 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/custom_addons/encoach_ai_course/__init__.py b/custom_addons/encoach_ai_course/__init__.py new file mode 100644 index 00000000..7fc29f0a --- /dev/null +++ b/custom_addons/encoach_ai_course/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import services +from . import controllers diff --git a/custom_addons/encoach_ai_course/__manifest__.py b/custom_addons/encoach_ai_course/__manifest__.py new file mode 100644 index 00000000..32086907 --- /dev/null +++ b/custom_addons/encoach_ai_course/__manifest__.py @@ -0,0 +1,20 @@ +{ + 'name': 'EnCoach AI Course', + 'version': '19.0.1.0', + 'category': 'Education', + 'summary': 'AI content generation pipelines for General English and IELTS courses', + 'author': 'EnCoach', + 'license': 'LGPL-3', + 'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen', 'encoach_ai'], + 'data': [ + 'security/ir.model.access.csv', + 'views/ai_generation_log_views.xml', + 'views/ai_ielts_log_views.xml', + 'views/ai_course_menus.xml', + 'views/ai_review_views.xml', + 'views/ielts_review_views.xml', + ], + 'installable': True, + 'application': False, + 'auto_install': False, +} diff --git a/custom_addons/encoach_ai_course/controllers/__init__.py b/custom_addons/encoach_ai_course/controllers/__init__.py new file mode 100644 index 00000000..904effd7 --- /dev/null +++ b/custom_addons/encoach_ai_course/controllers/__init__.py @@ -0,0 +1 @@ +from . import ai_course diff --git a/custom_addons/encoach_ai_course/controllers/ai_course.py b/custom_addons/encoach_ai_course/controllers/ai_course.py new file mode 100644 index 00000000..e98f56da --- /dev/null +++ b/custom_addons/encoach_ai_course/controllers/ai_course.py @@ -0,0 +1,561 @@ +import json +import logging +from odoo import http, fields +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__) + + +class EncoachAiCourseController(http.Controller): + + # ------------------------------------------------------------------ + # POST /api/ai-course/english/create + # ------------------------------------------------------------------ + @http.route('/api/ai-course/english/create', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def create_english(self, **kw): + try: + body = _get_json_body() + cefr_level = body.get('cefr_level') + if not cefr_level: + return _error_response('cefr_level is required', 400) + + user = request.env.user.sudo() + gap_profile_id = body.get('gap_profile_id') + + GapProfile = request.env['encoach.gap.profile'].sudo() + if gap_profile_id: + gap_profile = GapProfile.browse(int(gap_profile_id)) + if not gap_profile.exists(): + return _error_response('Gap profile not found', 404) + else: + gap_profile = GapProfile.search( + [('student_id', '=', user.id)], + order='created_at desc', limit=1, + ) + if not gap_profile: + gap_profile = GapProfile.create({ + 'student_id': user.id, + 'source_type': 'exam', + 'skill_gaps': json.dumps([]), + 'topic_weaknesses': json.dumps([]), + }) + + brief = { + 'skill_gaps': json.loads(gap_profile.skill_gaps or '[]'), + 'topic_weaknesses': json.loads(gap_profile.topic_weaknesses or '[]'), + 'cefr_level': cefr_level, + } + + Log = request.env['encoach.ai.generation.log'].sudo() + log = Log.create({ + 'student_id': user.id, + 'course_type': 'general_english', + 'status': 'pending_review', + 'brief': json.dumps(brief), + 'attempts': 1, + }) + + return _json_response({ + 'log_id': log.id, + 'status': log.status, + 'brief': brief, + }, 201) + + except Exception as e: + _logger.exception('create_english failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai-course/ielts/create + # ------------------------------------------------------------------ + @http.route('/api/ai-course/ielts/create', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def create_ielts(self, **kw): + try: + body = _get_json_body() + skill = body.get('skill') + target_band = body.get('target_band') + + if not skill: + return _error_response('skill is required', 400) + if not target_band: + return _error_response('target_band is required', 400) + + valid_skills = ('listening', 'reading', 'writing', 'speaking') + if skill not in valid_skills: + return _error_response( + f'skill must be one of: {", ".join(valid_skills)}', 400, + ) + + brief = { + 'skill': skill, + 'target_band': float(target_band), + 'custom_instructions': body.get('brief', ''), + } + + Log = request.env['encoach.ai.ielts.generation.log'].sudo() + log = Log.create({ + 'skill': skill, + 'status': 'format_check', + 'brief': json.dumps(brief), + 'attempts': 1, + }) + + return _json_response({ + 'log_id': log.id, + 'status': log.status, + 'skill': log.skill, + }, 201) + + except Exception as e: + _logger.exception('create_ielts failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai-course//quality + # ------------------------------------------------------------------ + @http.route('/api/ai-course//quality', type='http', + auth='none', methods=['GET'], csrf=False) + @jwt_required + def quality(self, course_id, **kw): + try: + Log = request.env['encoach.ai.generation.log'].sudo() + log = Log.browse(course_id) + if not log.exists(): + return _error_response('Generation log not found', 404) + + return _json_response({ + 'status': log.status, + 'readability_score': 0.0, + 'cefr_alignment': log.status in ('quality_check', 'approved'), + 'grammar_issues': [], + 'attempts': log.attempts, + }) + + except Exception as e: + _logger.exception('quality check failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai-course//approve + # ------------------------------------------------------------------ + @http.route('/api/ai-course//approve', type='http', + auth='none', methods=['POST'], csrf=False) + @jwt_required + def approve(self, course_id, **kw): + try: + Log = request.env['encoach.ai.generation.log'].sudo() + log = Log.browse(course_id) + if not log.exists(): + return _error_response('Generation log not found', 404) + + if log.status == 'approved': + return _error_response('Already approved', 400) + + log.write({ + 'status': 'approved', + 'approved_by': request.env.user.id, + }) + + return _json_response({'approved': True}) + + except Exception as e: + _logger.exception('approve failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai-course//reject + # ------------------------------------------------------------------ + @http.route('/api/ai-course//reject', type='http', + auth='none', methods=['POST'], csrf=False) + @jwt_required + def reject(self, course_id, **kw): + try: + body = _get_json_body() + + Log = request.env['encoach.ai.generation.log'].sudo() + log = Log.browse(course_id) + if not log.exists(): + return _error_response('Generation log not found', 404) + + reason = body.get('reason', '') + can_retry = log.attempts < 3 + + if can_retry: + log.write({ + 'status': 'generating', + 'attempts': log.attempts + 1, + 'error_log': reason or log.error_log, + }) + else: + log.write({ + 'status': 'rejected', + 'error_log': reason or log.error_log, + }) + + return _json_response({ + 'rejected': True, + 'can_retry': can_retry, + }) + + except Exception as e: + _logger.exception('reject failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai-course//validation + # ------------------------------------------------------------------ + @http.route('/api/ai-course//validation', type='http', + auth='none', methods=['GET'], csrf=False) + @jwt_required + def validation(self, course_id, **kw): + try: + IeltsLog = request.env['encoach.ai.ielts.generation.log'].sudo() + ielts_log = IeltsLog.browse(course_id) + if ielts_log.exists(): + fmt_result = {} + band_result = {} + try: + fmt_result = json.loads(ielts_log.format_check_result or '{}') + except (json.JSONDecodeError, TypeError): + pass + try: + band_result = json.loads(ielts_log.band_check_result or '{}') + except (json.JSONDecodeError, TypeError): + pass + + overall_passed = bool( + ielts_log.format_check_result and ielts_log.band_check_result + ) + return _json_response({ + 'type': 'ielts', + 'validation_results': { + 'format_check_result': fmt_result, + 'band_check_result': band_result, + }, + 'overall_passed': overall_passed, + }) + + Log = request.env['encoach.ai.generation.log'].sudo() + log = Log.browse(course_id) + if not log.exists(): + return _error_response('Generation log not found', 404) + + validation_results = { + 'status': log.status, + 'quality_passed': log.status in ('approved', 'pending_review'), + 'attempts': log.attempts, + } + + return _json_response({ + 'type': 'english', + 'validation_results': validation_results, + 'overall_passed': log.status == 'approved', + }) + + except Exception as e: + _logger.exception('validation check failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai-course/ + # ------------------------------------------------------------------ + @http.route('/api/ai-course/', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def get_course(self, course_id, **kw): + try: + Log = request.env['encoach.ai.generation.log'].sudo() + log = Log.browse(course_id) + if not log.exists(): + IeltsLog = request.env['encoach.ai.ielts.generation.log'].sudo() + ielts = IeltsLog.browse(course_id) + if not ielts.exists(): + return _error_response('Course/log not found', 404) + return _json_response({ + 'id': ielts.id, + 'type': 'ielts', + 'skill': ielts.skill or '', + 'status': ielts.status or '', + 'review_status': getattr(ielts, 'review_status', ''), + 'created_at': ielts.create_date.isoformat() if ielts.create_date else '', + }) + + brief = {} + try: + brief = json.loads(log.brief or '{}') + except (json.JSONDecodeError, TypeError): + pass + + return _json_response({ + 'id': log.id, + 'type': 'general_english', + 'status': log.status or '', + 'course_type': log.course_type or '', + 'brief': brief, + 'attempts': log.attempts, + 'student_id': log.student_id.id if log.student_id else None, + 'created_at': log.create_date.isoformat() if log.create_date else '', + }) + + except Exception as e: + _logger.exception('get_course failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai-course//tracks + # ------------------------------------------------------------------ + @http.route('/api/ai-course//tracks', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def get_tracks(self, course_id, **kw): + try: + Log = request.env['encoach.ai.generation.log'].sudo() + log = Log.browse(course_id) + if not log.exists(): + return _error_response('Course not found', 404) + + generated = {} + try: + generated = json.loads(log.generated_content or '{}') + except (json.JSONDecodeError, TypeError): + pass + + tracks = [] + modules = generated.get('modules', []) + for i, mod in enumerate(modules): + tracks.append({ + 'index': i, + 'title': mod.get('title', f'Module {i+1}'), + 'skill': mod.get('skill', ''), + 'status': 'completed' if i == 0 else 'locked', + 'progress': 100 if i == 0 else 0, + }) + + if not tracks: + tracks = [{ + 'index': 0, + 'title': 'Course content pending generation', + 'skill': '', + 'status': 'pending', + 'progress': 0, + }] + + return _json_response({'tracks': tracks}) + + except Exception as e: + _logger.exception('get_tracks failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai-course/english/taxonomy + # ------------------------------------------------------------------ + @http.route('/api/ai-course/english/taxonomy', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def english_taxonomy(self, **kw): + try: + taxonomy = { + 'skills': ['reading', 'listening', 'writing', 'speaking', 'grammar', 'vocabulary'], + 'cefr_levels': ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'], + 'content_types': ['lesson', 'exercise', 'assessment', 'review'], + 'topic_domains': [ + 'daily_life', 'work', 'education', 'travel', + 'technology', 'environment', 'health', 'culture', + ], + } + + Taxonomy = request.env.get('encoach.taxonomy.domain') + if Taxonomy: + domains = Taxonomy.sudo().search([]) + if domains: + taxonomy['topic_domains'] = [ + {'id': d.id, 'name': d.name, 'description': getattr(d, 'description', '')} + for d in domains + ] + + return _json_response(taxonomy) + + except Exception as e: + _logger.exception('english_taxonomy failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai-course/examiner-review + # ------------------------------------------------------------------ + @http.route('/api/ai-course/examiner-review', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def examiner_review(self, **kw): + try: + body = _get_json_body() + log_id = body.get('log_id') + action = body.get('action') + examiner_notes = body.get('examiner_notes', '') + + if not log_id: + return _error_response('log_id is required', 400) + if action not in ('approve', 'reject', 'revise'): + return _error_response('action must be approve, reject, or revise', 400) + + IeltsLog = request.env['encoach.ai.ielts.generation.log'].sudo() + log = IeltsLog.browse(int(log_id)) + if not log.exists(): + return _error_response('Log not found', 404) + + status_map = { + 'approve': 'approved', + 'reject': 'rejected', + 'revise': 'revision_needed', + } + + log.write({ + 'review_status': status_map[action], + 'examiner_id': request.env.user.id, + 'examiner_notes': examiner_notes, + 'reviewed_at': fields.Datetime.now(), + }) + + return _json_response({'status': status_map[action], 'log_id': log_id}) + + except Exception as e: + _logger.exception('examiner_review failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai-course/review-queue + # ------------------------------------------------------------------ + @http.route('/api/ai-course/review-queue', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def review_queue(self, **kw): + try: + body = _get_json_body() if request.httprequest.content_length else {} + page = int(kw.get('page', 1)) + size = int(kw.get('size', 20)) + + Resource = request.env['encoach.resource'].sudo() + domain = [('ai_generated', '=', True), ('review_status', '=', 'pending')] + total = Resource.search_count(domain) + resources = Resource.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc') + + return _json_response({ + 'total': total, + 'page': page, + 'size': size, + 'items': [r.to_api_dict() for r in resources], + }) + except Exception as e: + _logger.exception('review_queue failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai-course/review/ + # ------------------------------------------------------------------ + @http.route('/api/ai-course/review/', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def review_resource(self, resource_id, **kw): + try: + body = _get_json_body() + action = body.get('action') + notes = body.get('notes', '') + + if action not in ('approve', 'reject'): + return _error_response('action must be approve or reject', 400) + + Resource = request.env['encoach.resource'].sudo() + resource = Resource.browse(resource_id) + if not resource.exists(): + return _error_response('Resource not found', 404) + + vals = { + 'review_status': 'approved' if action == 'approve' else 'rejected', + 'approved': action == 'approve', + } + resource.write(vals) + + return _json_response({'status': vals['review_status'], 'resource_id': resource_id}) + except Exception as e: + _logger.exception('review_resource failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai-course/ielts-review-queue + # ------------------------------------------------------------------ + @http.route('/api/ai-course/ielts-review-queue', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def ielts_review_queue(self, **kw): + try: + page = int(kw.get('page', 1)) + size = int(kw.get('size', 20)) + + Log = request.env['encoach.ai.ielts.generation.log'].sudo() + domain = [('review_status', '=', 'pending_review')] + total = Log.search_count(domain) + logs = Log.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc') + + items = [] + for log in logs: + items.append({ + 'id': log.id, + 'skill': log.skill or '', + 'band_target': log.band_target if hasattr(log, 'band_target') else 0, + 'review_status': log.review_status, + 'created_at': log.create_date.isoformat() if log.create_date else '', + }) + + return _json_response({ + 'total': total, + 'page': page, + 'size': size, + 'items': items, + }) + except Exception as e: + _logger.exception('ielts_review_queue failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai-course/ielts-review/ + # ------------------------------------------------------------------ + @http.route('/api/ai-course/ielts-review/', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def ielts_review(self, log_id, **kw): + try: + body = _get_json_body() + action = body.get('action') + examiner_notes = body.get('examiner_notes', '') + + if action not in ('approve', 'reject', 'revise'): + return _error_response('action must be approve, reject, or revise', 400) + + Log = request.env['encoach.ai.ielts.generation.log'].sudo() + log = Log.browse(log_id) + if not log.exists(): + return _error_response('Log not found', 404) + + status_map = { + 'approve': 'approved', + 'reject': 'rejected', + 'revise': 'revision_needed', + } + + log.write({ + 'review_status': status_map[action], + 'examiner_id': request.env.user.id, + 'examiner_notes': examiner_notes, + 'reviewed_at': fields.Datetime.now(), + }) + + return _json_response({'status': status_map[action], 'log_id': log_id}) + except Exception as e: + _logger.exception('ielts_review failed') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_ai_course/models/__init__.py b/custom_addons/encoach_ai_course/models/__init__.py new file mode 100644 index 00000000..84ceb86c --- /dev/null +++ b/custom_addons/encoach_ai_course/models/__init__.py @@ -0,0 +1,2 @@ +from . import ai_generation_log +from . import ai_ielts_generation_log diff --git a/custom_addons/encoach_ai_course/models/ai_generation_log.py b/custom_addons/encoach_ai_course/models/ai_generation_log.py new file mode 100644 index 00000000..a45412c3 --- /dev/null +++ b/custom_addons/encoach_ai_course/models/ai_generation_log.py @@ -0,0 +1,25 @@ +from odoo import models, fields + + +class EncoachAiGenerationLog(models.Model): + _name = 'encoach.ai.generation.log' + _description = 'AI Generation Log' + + student_id = fields.Many2one('res.users', ondelete='set null') + course_type = fields.Selection([ + ('general_english', 'General English'), + ('ielts', 'IELTS'), + ], required=True) + brief = fields.Text() + attempts = fields.Integer(default=0) + final_resource_id = fields.Integer(string='Final Resource ID') + approved_by = fields.Many2one('res.users', ondelete='set null') + status = fields.Selection([ + ('generating', 'Generating'), + ('quality_check', 'Quality Check'), + ('pending_review', 'Pending Review'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ], default='generating', required=True) + created_at = fields.Datetime(default=fields.Datetime.now) + error_log = fields.Text() diff --git a/custom_addons/encoach_ai_course/models/ai_ielts_generation_log.py b/custom_addons/encoach_ai_course/models/ai_ielts_generation_log.py new file mode 100644 index 00000000..0812e757 --- /dev/null +++ b/custom_addons/encoach_ai_course/models/ai_ielts_generation_log.py @@ -0,0 +1,35 @@ +from odoo import models, fields + + +class EncoachAiIeltsGenerationLog(models.Model): + _name = 'encoach.ai.ielts.generation.log' + _description = 'AI IELTS Generation Log' + + skill = fields.Selection([ + ('listening', 'Listening'), + ('reading', 'Reading'), + ('writing', 'Writing'), + ('speaking', 'Speaking'), + ], required=True) + brief = fields.Text() + format_check_result = fields.Text() + band_check_result = fields.Text() + examiner_id = fields.Many2one('res.users', ondelete='set null') + status = fields.Selection([ + ('generating', 'Generating'), + ('format_check', 'Format Check'), + ('examiner_review', 'Examiner Review'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ], default='generating', required=True) + attempts = fields.Integer(default=0) + created_at = fields.Datetime(default=fields.Datetime.now) + review_status = fields.Selection([ + ('draft', 'Draft'), + ('pending_review', 'Pending Review'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ('revision_needed', 'Revision Needed'), + ], default='draft') + examiner_notes = fields.Text() + reviewed_at = fields.Datetime() diff --git a/custom_addons/encoach_ai_course/security/ir.model.access.csv b/custom_addons/encoach_ai_course/security/ir.model.access.csv new file mode 100644 index 00000000..c992bd77 --- /dev/null +++ b/custom_addons/encoach_ai_course/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_ai_generation_log_user,encoach.ai.generation.log.user,model_encoach_ai_generation_log,base.group_user,1,1,1,1 +access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user,model_encoach_ai_ielts_generation_log,base.group_user,1,1,1,1 diff --git a/custom_addons/encoach_ai_course/services/__init__.py b/custom_addons/encoach_ai_course/services/__init__.py new file mode 100644 index 00000000..d2129665 --- /dev/null +++ b/custom_addons/encoach_ai_course/services/__init__.py @@ -0,0 +1,2 @@ +from .english_pipeline import EnglishPipeline +from .ielts_pipeline import IeltsPipeline diff --git a/custom_addons/encoach_ai_course/services/english_pipeline.py b/custom_addons/encoach_ai_course/services/english_pipeline.py new file mode 100644 index 00000000..eb20ee50 --- /dev/null +++ b/custom_addons/encoach_ai_course/services/english_pipeline.py @@ -0,0 +1,124 @@ +import json +import logging + +try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService +except ImportError: + OpenAIService = None + +try: + from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate +except ImportError: + ContentSourceGate = None + +_logger = logging.getLogger(__name__) + + +class EnglishPipeline: + """AI content generation pipeline for General English courses.""" + + def __init__(self, env): + self.env = env + self.ai = OpenAIService(env) + + def generate_content(self, env, gap_profile, cefr_level): + """Generate General English content based on gap analysis. + + Args: + env: Odoo environment. + gap_profile: dict with 'skill_gaps' list describing weak areas. + cefr_level: target CEFR level string (e.g. 'B1'). + + Returns: + dict with generated content. + """ + skill_gaps = gap_profile.get('skill_gaps', []) + gaps_description = ', '.join(skill_gaps) if skill_gaps else 'general skills' + + messages = [ + {'role': 'system', 'content': ( + 'You are an expert English language curriculum designer. ' + 'Return JSON with keys: "title", "objectives", "units" ' + '(each unit has "topic", "grammar_focus", "vocabulary", ' + '"reading_text", "exercises").' + )}, + {'role': 'user', 'content': ( + f'Generate a General English course at CEFR {cefr_level} level. ' + f'Focus on these gap areas: {gaps_description}. ' + f'Include practical, real-world content appropriate for the level.' + )}, + ] + + try: + content = self.ai.chat_json(messages, temperature=0.8) + except Exception as e: + _logger.error("English content generation failed: %s", e) + env['encoach.ai.generation.log'].create({ + 'course_type': 'general_english', + 'brief': json.dumps(gap_profile), + 'status': 'rejected', + 'error_log': str(e), + }) + return {'error': str(e)} + + resource = env['encoach.ai.generation.log'].create({ + 'course_type': 'general_english', + 'brief': json.dumps(gap_profile), + 'status': 'quality_check', + 'attempts': 1, + }) + + # Apply content source gate logic + if ContentSourceGate is not None: + try: + ContentSourceGate.apply_gate(resource) + except Exception as e: + _logger.warning("ContentSourceGate.apply_gate failed: %s", e) + + return content + + def quality_check(self, env, content, cefr_level): + """Run quality gate checks on generated content. + + Args: + env: Odoo environment. + content: dict of generated content to validate. + cefr_level: target CEFR level for alignment check. + + Returns: + dict with 'passed' bool and 'issues' list. + """ + issues = [] + + if not content.get('units'): + issues.append('No units generated') + else: + for i, unit in enumerate(content['units'], 1): + if not unit.get('exercises'): + issues.append(f'Unit {i} has no exercises') + if not unit.get('reading_text'): + issues.append(f'Unit {i} has no reading text') + + if not content.get('objectives'): + issues.append('No learning objectives defined') + + messages = [ + {'role': 'system', 'content': ( + 'You are a CEFR alignment expert. Return JSON with keys: ' + '"aligned" (bool) and "issues" (list of strings).' + )}, + {'role': 'user', 'content': ( + f'Check if this content is aligned with CEFR {cefr_level}: ' + f'{json.dumps(content)}' + )}, + ] + + try: + alignment = self.ai.chat_json(messages, temperature=0.3) + if not alignment.get('aligned'): + issues.extend(alignment.get('issues', ['CEFR misalignment detected'])) + except Exception as e: + _logger.warning("CEFR alignment check failed: %s", e) + issues.append(f'CEFR alignment check error: {e}') + + return {'passed': len(issues) == 0, 'issues': issues} diff --git a/custom_addons/encoach_ai_course/services/ielts_pipeline.py b/custom_addons/encoach_ai_course/services/ielts_pipeline.py new file mode 100644 index 00000000..3f7462e7 --- /dev/null +++ b/custom_addons/encoach_ai_course/services/ielts_pipeline.py @@ -0,0 +1,170 @@ +import json +import logging + +try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService +except ImportError: + OpenAIService = None + +try: + from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate +except ImportError: + ContentSourceGate = None + +_logger = logging.getLogger(__name__) + +SKILL_PROMPTS = { + 'listening': ( + 'Generate an IELTS Listening section. Return JSON with keys: ' + '"script", "speakers", "duration_estimate", "questions".' + ), + 'reading': ( + 'Generate an IELTS Reading passage with questions. Return JSON with keys: ' + '"passage", "word_count", "questions", "question_types".' + ), + 'writing': ( + 'Generate an IELTS Writing task. Return JSON with keys: ' + '"task_type", "prompt", "requirements", "sample_band_descriptors".' + ), + 'speaking': ( + 'Generate an IELTS Speaking part. Return JSON with keys: ' + '"part", "questions", "cue_card" (if part 2), "follow_up_questions".' + ), +} + +IELTS_FORMAT_RULES = { + 'listening': {'required_keys': ['script', 'questions'], 'min_questions': 10}, + 'reading': {'required_keys': ['passage', 'questions'], 'min_word_count': 500}, + 'writing': {'required_keys': ['prompt', 'requirements']}, + 'speaking': {'required_keys': ['questions']}, +} + + +class IeltsPipeline: + """AI content generation pipeline for IELTS skill-specific content.""" + + def __init__(self, env): + self.env = env + self.ai = OpenAIService(env) + + def generate_content(self, env, skill, brief): + """Generate IELTS skill-specific content. + + Args: + env: Odoo environment. + skill: one of 'listening', 'reading', 'writing', 'speaking'. + brief: dict with generation parameters. + + Returns: + dict with generated content. + """ + system_prompt = SKILL_PROMPTS.get(skill, SKILL_PROMPTS['reading']) + brief_text = json.dumps(brief) if isinstance(brief, dict) else str(brief) + + messages = [ + {'role': 'system', 'content': ( + f'You are an expert IELTS content creator. {system_prompt}' + )}, + {'role': 'user', 'content': ( + f'Generate IELTS {skill} content based on this brief: {brief_text}' + )}, + ] + + try: + content = self.ai.chat_json(messages, temperature=0.8) + except Exception as e: + _logger.error("IELTS %s content generation failed: %s", skill, e) + return {'error': str(e)} + + resource = env['encoach.ai.ielts.generation.log'].create({ + 'skill': skill, + 'brief': brief_text, + 'status': 'format_check', + 'attempts': 1, + }) + + # Apply content source gate logic + if ContentSourceGate is not None: + try: + ContentSourceGate.apply_gate(resource) + except Exception as e: + _logger.warning("ContentSourceGate.apply_gate failed: %s", e) + + return content + + def format_check(self, env, content, skill): + """Validate IELTS format compliance for a given skill. + + Args: + env: Odoo environment. + content: dict of generated content. + skill: IELTS skill type. + + Returns: + dict with 'passed' bool and 'errors' list. + """ + errors = [] + rules = IELTS_FORMAT_RULES.get(skill, {}) + + for key in rules.get('required_keys', []): + if key not in content or not content[key]: + errors.append(f'Missing required key: {key}') + + if skill == 'listening': + questions = content.get('questions', []) + min_q = rules.get('min_questions', 10) + if len(questions) < min_q: + errors.append( + f'Listening requires at least {min_q} questions, ' + f'got {len(questions)}' + ) + + if skill == 'reading': + passage = content.get('passage', '') + min_wc = rules.get('min_word_count', 500) + word_count = len(passage.split()) if passage else 0 + if word_count < min_wc: + errors.append( + f'Reading passage must be at least {min_wc} words, ' + f'got {word_count}' + ) + + if skill == 'speaking' and not content.get('questions'): + errors.append('Speaking section requires at least one question') + + return {'passed': len(errors) == 0, 'errors': errors} + + def band_calibration(self, env, content, target_band): + """Check content aligns with target IELTS band level. + + Args: + env: Odoo environment. + content: dict of generated content. + target_band: float target band score (e.g. 6.5). + + Returns: + dict with 'passed' bool and 'issues' list. + """ + messages = [ + {'role': 'system', 'content': ( + 'You are an IELTS band calibration expert. Evaluate whether ' + 'the provided content matches the target band level. ' + 'Return JSON with keys: "calibrated" (bool), "estimated_band" (float), ' + '"issues" (list of strings).' + )}, + {'role': 'user', 'content': ( + f'Target band: {target_band}. ' + f'Content: {json.dumps(content)}' + )}, + ] + + try: + result = self.ai.chat_json(messages, temperature=0.3) + except Exception as e: + _logger.error("Band calibration failed: %s", e) + return {'passed': False, 'issues': [f'Band calibration error: {e}']} + + return { + 'passed': result.get('calibrated', False), + 'issues': result.get('issues', []), + } diff --git a/custom_addons/encoach_ai_course/views/ai_course_menus.xml b/custom_addons/encoach_ai_course/views/ai_course_menus.xml new file mode 100644 index 00000000..5a0ac575 --- /dev/null +++ b/custom_addons/encoach_ai_course/views/ai_course_menus.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/custom_addons/encoach_ai_course/views/ai_generation_log_views.xml b/custom_addons/encoach_ai_course/views/ai_generation_log_views.xml new file mode 100644 index 00000000..d03a3809 --- /dev/null +++ b/custom_addons/encoach_ai_course/views/ai_generation_log_views.xml @@ -0,0 +1,77 @@ + + + + + encoach.ai.generation.log.form + encoach.ai.generation.log + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + encoach.ai.generation.log.list + encoach.ai.generation.log + + + + + + + + + + + + + encoach.ai.generation.log.search + encoach.ai.generation.log + + + + + + + + + + + + + + + + + + AI Generation Logs + encoach.ai.generation.log + list,form + + +
diff --git a/custom_addons/encoach_ai_course/views/ai_ielts_log_views.xml b/custom_addons/encoach_ai_course/views/ai_ielts_log_views.xml new file mode 100644 index 00000000..300180ab --- /dev/null +++ b/custom_addons/encoach_ai_course/views/ai_ielts_log_views.xml @@ -0,0 +1,59 @@ + + + + + encoach.ai.ielts.generation.log.form + encoach.ai.ielts.generation.log + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + encoach.ai.ielts.generation.log.list + encoach.ai.ielts.generation.log + + + + + + + + + + + + + IELTS Generation Logs + encoach.ai.ielts.generation.log + list,form + + +
diff --git a/custom_addons/encoach_ai_course/views/ai_review_views.xml b/custom_addons/encoach_ai_course/views/ai_review_views.xml new file mode 100644 index 00000000..90daf773 --- /dev/null +++ b/custom_addons/encoach_ai_course/views/ai_review_views.xml @@ -0,0 +1,27 @@ + + + + + encoach.resource.ai.review.list + encoach.resource + + + + + + + + + + + + + + AI Content Review + encoach.resource + list,form + [('ai_generated', '=', True)] + {'search_default_pending_review': 1} + + + diff --git a/custom_addons/encoach_ai_course/views/ielts_review_views.xml b/custom_addons/encoach_ai_course/views/ielts_review_views.xml new file mode 100644 index 00000000..91838564 --- /dev/null +++ b/custom_addons/encoach_ai_course/views/ielts_review_views.xml @@ -0,0 +1,52 @@ + + + + + encoach.ai.ielts.generation.log.review.form + encoach.ai.ielts.generation.log + +
+
+ +
+ + + + + + + + + + + + + + + +
+
+
+ + + encoach.ai.ielts.generation.log.review.list + encoach.ai.ielts.generation.log + + + + + + + + + + + + + IELTS Content Review + encoach.ai.ielts.generation.log + list,form + [('review_status', '\!=', 'draft')] + + +
diff --git a/custom_addons/encoach_api/__init__.py b/custom_addons/encoach_api/__init__.py new file mode 100644 index 00000000..e046e49f --- /dev/null +++ b/custom_addons/encoach_api/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/custom_addons/encoach_api/__manifest__.py b/custom_addons/encoach_api/__manifest__.py new file mode 100644 index 00000000..574513ef --- /dev/null +++ b/custom_addons/encoach_api/__manifest__.py @@ -0,0 +1,11 @@ +{ + 'name': 'EnCoach API Base', + 'version': '19.0.2.0.0', + 'category': 'Education', + 'summary': 'Base controller utilities (JWT auth, response helpers) for EnCoach REST API', + 'author': 'EnCoach', + 'depends': ['base', 'encoach_core'], + 'data': [], + 'installable': True, + 'license': 'LGPL-3', +} diff --git a/custom_addons/encoach_api/controllers/__init__.py b/custom_addons/encoach_api/controllers/__init__.py new file mode 100644 index 00000000..736bd7e7 --- /dev/null +++ b/custom_addons/encoach_api/controllers/__init__.py @@ -0,0 +1,2 @@ +from . import base +from . import auth diff --git a/custom_addons/encoach_api/controllers/auth.py b/custom_addons/encoach_api/controllers/auth.py new file mode 100644 index 00000000..4b4b4226 --- /dev/null +++ b/custom_addons/encoach_api/controllers/auth.py @@ -0,0 +1,146 @@ +import logging +import time + +import jwt as pyjwt + +from odoo import http, fields +from odoo.http import request +from odoo.exceptions import AccessDenied + +from .base import ( + _json_response, _error_response, _get_json_body, _get_jwt_secret, validate_token, +) + +_logger = logging.getLogger(__name__) + + +class EncoachAuthController(http.Controller): + + # ------------------------------------------------------------------ + # POST /api/login + # ------------------------------------------------------------------ + @http.route('/api/login', type='http', auth='public', + methods=['POST'], csrf=False) + def login(self, **kw): + try: + body = _get_json_body() + login = (body.get('login') or body.get('email') or '').strip().lower() + password = body.get('password', '') + + if not login or not password: + return _error_response('login and password are required', 400) + + # Odoo 19: session.authenticate(env, credential_dict) + credential = { + 'type': 'password', + 'login': login, + 'password': password, + } + try: + request.session.authenticate(request.env, credential) + uid = request.session.uid + if not uid: + return _error_response('Invalid email or password', 401) + except AccessDenied: + return _error_response('Invalid email or password', 401) + except Exception as auth_err: + _logger.warning('Auth error for %s: %s', login, auth_err) + return _error_response('Invalid email or password', 401) + + user = request.env['res.users'].sudo().browse(uid) + + # Generate JWT token + secret = _get_jwt_secret() + if not secret: + return _error_response('JWT not configured on server', 500) + + token = pyjwt.encode( + {'user_id': user.id, 'exp': int(time.time()) + 86400}, + secret, algorithm='HS256', + ) + + # Get permissions + permissions = [] + if hasattr(user, 'get_all_permissions'): + permissions = user.get_all_permissions().mapped('code') + + # Update last login + user.write({'last_login': fields.Datetime.now()}) + + return _json_response({ + 'token': token, + 'user': self._user_to_dict(user), + 'permissions': permissions, + }) + + except Exception as e: + _logger.exception('login failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/user (returns current authenticated user) + # ------------------------------------------------------------------ + @http.route('/api/user', type='http', auth='public', + methods=['GET'], csrf=False) + def get_current_user(self, **kw): + try: + user = validate_token() + if not user: + return _error_response('Authentication required', 401) + + permissions = [] + if hasattr(user, 'get_all_permissions'): + permissions = user.get_all_permissions().mapped('code') + + return _json_response({ + 'user': self._user_to_dict(user), + 'permissions': permissions, + }) + + except Exception as e: + _logger.exception('get_current_user failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/logout + # ------------------------------------------------------------------ + @http.route('/api/logout', type='http', auth='public', + methods=['POST'], csrf=False) + def logout(self, **kw): + # JWT is stateless — client clears token. Server just returns OK. + return _json_response({'ok': True}) + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + def _user_to_dict(self, user): + """Convert res.users to the dict shape the React frontend expects.""" + entities = [] + if hasattr(user, 'entity_ids'): + entities = [ + {'id': e.id, 'name': e.name, 'role': ''} + for e in user.entity_ids + ] + + classrooms = [] + + return { + 'id': user.id, + 'name': user.name or '', + 'email': user.email or '', + 'login': user.login or '', + 'user_type': getattr(user, '_api_user_type', lambda: user.user_type or 'student')() + if callable(getattr(user, '_api_user_type', None)) + else (user.user_type or 'student'), + 'avatar': bool(getattr(user, 'encoach_avatar', False)), + 'phone': user.phone or '', + 'country': '', + 'timezone': '', + 'bio': '', + 'gender': getattr(user, 'gender', '') or '', + 'student_id': '', + 'is_verified': getattr(user, 'is_verified', False), + 'entities': entities, + 'classrooms': classrooms, + 'expiry_date': '', + } diff --git a/custom_addons/encoach_api/controllers/base.py b/custom_addons/encoach_api/controllers/base.py new file mode 100644 index 00000000..1e8b940a --- /dev/null +++ b/custom_addons/encoach_api/controllers/base.py @@ -0,0 +1,153 @@ +import json +import functools +import logging +import time + +import jwt as pyjwt + +from odoo.http import request + +_logger = logging.getLogger(__name__) + +_jwt_secret_cache = {"secret": None, "ts": 0} +_JWT_SECRET_TTL = 300 + +_user_exists_cache = {} +_USER_CACHE_TTL = 60 +_USER_CACHE_MAX = 200 + + +def _get_jwt_secret(): + now = time.time() + if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL: + return _jwt_secret_cache["secret"] + secret = ( + request.env["ir.config_parameter"] + .sudo() + .get_param("encoach.jwt_secret") + ) + if secret: + _jwt_secret_cache["secret"] = secret + _jwt_secret_cache["ts"] = now + return secret + + +def validate_token(): + """Decode JWT Bearer token and return the corresponding ``res.users`` record or None.""" + auth_header = request.httprequest.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return None + token = auth_header[7:] + secret = _get_jwt_secret() + if not secret: + _logger.error("System parameter 'encoach.jwt_secret' is not configured") + return None + try: + payload = pyjwt.decode(token, secret, algorithms=["HS256"]) + except pyjwt.ExpiredSignatureError: + return None + except pyjwt.InvalidTokenError: + return None + user_id = payload.get("user_id") + if not user_id: + return None + + now = time.time() + cache_key = int(user_id) + cached = _user_exists_cache.get(cache_key) + if cached and (now - cached["ts"]) < _USER_CACHE_TTL: + return request.env["res.users"].sudo().browse(cache_key) + + user = request.env["res.users"].sudo().browse(cache_key) + if not user.exists(): + return None + + _user_exists_cache[cache_key] = {"ts": now} + if len(_user_exists_cache) > _USER_CACHE_MAX: + oldest_key = min(_user_exists_cache, key=lambda k: _user_exists_cache[k]["ts"]) + del _user_exists_cache[oldest_key] + return user + + +def jwt_required(func): + """Decorator that validates the JWT token and sets request.env user context.""" + @functools.wraps(func) + def wrapper(*args, **kwargs): + user = validate_token() + if not user: + return _error_response("Authentication required", status=401) + request.update_env(user=user.id) + return func(*args, **kwargs) + return wrapper + + +def _json_response(data, status=200): + return request.make_json_response(data, status=status) + + +def _error_response(message, status=400, code=None): + body = {"error": message} + if code: + body["code"] = code + return request.make_json_response(body, status=status) + + +def _get_json_body(): + try: + return json.loads(request.httprequest.get_data(as_text=True)) + except (ValueError, TypeError): + return {} + + +def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'): + """Paginate an Odoo model search or extract params from a kwargs dict. + + Two calling conventions: + _paginate(Model, domain, page, size, order) → (recordset, total_count) + _paginate(kwargs_dict) → (offset, limit, page) + """ + if isinstance(model_or_kwargs, dict): + kwargs = model_or_kwargs + limit = min(max(int(kwargs.get("size", kwargs.get("limit", 20))), 1), 200) + page_raw = int(kwargs.get("page", 0)) + pg = max(page_raw, 0) + offset = pg * limit + return offset, limit, pg + + Model = model_or_kwargs + limit = min(max(int(size), 1), 200) + pg = max(int(page), 0) + offset = pg * limit + total = Model.search_count(domain or []) + records = Model.search(domain or [], offset=offset, limit=limit, order=order) + return records, total + + +class EncoachMixin: + """Shared authentication and response helpers for all EnCoach API controllers.""" + + def _get_jwt_secret(self): + return _get_jwt_secret() + + def _authenticate(self): + return validate_token() + + def _json_response(self, data, status=200): + return _json_response(data, status=status) + + def _error_response(self, message, status=400, code=None): + return _error_response(message, status=status, code=code) + + def _get_json_body(self): + return _get_json_body() + + def _paginate_params(self, kwargs): + return _paginate(kwargs) + + def _serialize(self, record): + if hasattr(record, "to_encoach_dict"): + return record.to_encoach_dict() + return {"id": record.id} + + def _serialize_list(self, records): + return [self._serialize(r) for r in records] diff --git a/custom_addons/encoach_branding/__init__.py b/custom_addons/encoach_branding/__init__.py new file mode 100644 index 00000000..f7209b17 --- /dev/null +++ b/custom_addons/encoach_branding/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/custom_addons/encoach_branding/__manifest__.py b/custom_addons/encoach_branding/__manifest__.py new file mode 100644 index 00000000..b7f3c0c8 --- /dev/null +++ b/custom_addons/encoach_branding/__manifest__.py @@ -0,0 +1,15 @@ +{ + 'name': 'EnCoach Branding', + 'version': '19.0.1.0', + 'category': 'Education', + 'summary': 'Whitelabeling and custom branding per entity', + 'author': 'EnCoach', + 'depends': ['encoach_core'], + 'data': [ + 'security/ir.model.access.csv', + 'views/branding_views.xml', + 'views/branding_menus.xml', + ], + 'installable': True, + 'license': 'LGPL-3', +} diff --git a/custom_addons/encoach_branding/controllers/__init__.py b/custom_addons/encoach_branding/controllers/__init__.py new file mode 100644 index 00000000..7fe861f6 --- /dev/null +++ b/custom_addons/encoach_branding/controllers/__init__.py @@ -0,0 +1 @@ +from . import branding diff --git a/custom_addons/encoach_branding/controllers/branding.py b/custom_addons/encoach_branding/controllers/branding.py new file mode 100644 index 00000000..00c8e1e6 --- /dev/null +++ b/custom_addons/encoach_branding/controllers/branding.py @@ -0,0 +1,217 @@ +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, + validate_token, +) + +_logger = logging.getLogger(__name__) + + +class EncoachBrandingController(http.Controller): + + # ------------------------------------------------------------------ + # GET /api/entity//level-mapping + # ------------------------------------------------------------------ + @http.route('/api/entity//level-mapping', type='http', + auth='none', methods=['GET'], csrf=False) + @jwt_required + def get_level_mapping(self, entity_id, **kw): + try: + Entity = request.env['encoach.entity'].sudo() + entity = Entity.browse(entity_id) + if not entity.exists(): + return _error_response('Entity not found', 404) + + Mapping = request.env['encoach.entity.level.mapping'].sudo() + mappings = Mapping.search( + [('entity_id', '=', entity_id)], + order='min_score asc', + ) + + items = [] + for m in mappings: + items.append({ + 'id': m.id, + 'entity_id': m.entity_id.id, + 'min_score': m.min_score, + 'max_score': m.max_score, + 'internal_level_name': m.internal_level_name, + 'cefr_equivalent': m.cefr_equivalent or '', + }) + + return _json_response({'mappings': items}) + + except Exception as e: + _logger.exception('get_level_mapping failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # PUT /api/entity//level-mapping + # ------------------------------------------------------------------ + @http.route('/api/entity//level-mapping', type='http', + auth='none', methods=['PUT'], csrf=False) + @jwt_required + def update_level_mapping(self, entity_id, **kw): + try: + body = _get_json_body() + mappings_data = body.get('mappings', []) + if not mappings_data: + return _error_response('mappings list is required', 400) + + Entity = request.env['encoach.entity'].sudo() + entity = Entity.browse(entity_id) + if not entity.exists(): + return _error_response('Entity not found', 404) + + Mapping = request.env['encoach.entity.level.mapping'].sudo() + Mapping.search([('entity_id', '=', entity_id)]).unlink() + + new_mappings = [] + for md in mappings_data: + if not md.get('internal_level_name'): + return _error_response('internal_level_name is required for each mapping', 400) + + rec = Mapping.create({ + 'entity_id': entity_id, + 'min_score': float(md.get('min_score', 0)), + 'max_score': float(md.get('max_score', 0)), + 'internal_level_name': md['internal_level_name'], + 'cefr_equivalent': md.get('cefr_equivalent') or False, + }) + new_mappings.append({ + 'id': rec.id, + 'entity_id': rec.entity_id.id, + 'min_score': rec.min_score, + 'max_score': rec.max_score, + 'internal_level_name': rec.internal_level_name, + 'cefr_equivalent': rec.cefr_equivalent or '', + }) + + return _json_response({'mappings': new_mappings}) + + except Exception as e: + _logger.exception('update_level_mapping failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/entity//branding + # ------------------------------------------------------------------ + @http.route('/api/entity//branding', type='http', + auth='none', methods=['GET'], csrf=False) + @jwt_required + def get_entity_branding(self, entity_id, **kw): + try: + Branding = request.env['encoach.branding'].sudo() + branding = Branding.search([('entity_id', '=', entity_id)], limit=1) + if not branding: + return _error_response('Branding not found for this entity', 404) + + return _json_response(branding.to_api_dict()) + + except Exception as e: + _logger.exception('get_entity_branding failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # PUT /api/entity//branding + # ------------------------------------------------------------------ + @http.route('/api/entity//branding', type='http', + auth='none', methods=['PUT'], csrf=False) + @jwt_required + def update_entity_branding(self, entity_id, **kw): + try: + body = _get_json_body() + + Entity = request.env['encoach.entity'].sudo() + entity = Entity.browse(entity_id) + if not entity.exists(): + return _error_response('Entity not found', 404) + + Branding = request.env['encoach.branding'].sudo() + branding = Branding.search([('entity_id', '=', entity_id)], limit=1) + + allowed = [ + 'primary_color', 'secondary_color', 'background_color', + 'app_name', 'login_title', 'login_description', + 'white_label_domain', 'custom_css', + ] + vals = {k: body[k] for k in allowed if k in body} + + if not branding: + vals['entity_id'] = entity_id + branding = Branding.create(vals) + else: + branding.write(vals) + + return _json_response(branding.to_api_dict()) + + except Exception as e: + _logger.exception('update_entity_branding failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/entity/branding (combined public + authenticated) + # GET /api/entity/branding/public + # + # If ?domain= is present → public lookup, no auth needed. + # Otherwise → JWT required, return user entity branding. + # ------------------------------------------------------------------ + @http.route( + ['/api/entity/branding', '/api/entity/branding/public'], + type='http', auth='none', methods=['GET'], csrf=False, + ) + def get_branding(self, **kw): + try: + domain_param = kw.get('domain') + + if domain_param: + Branding = request.env(su=True)['encoach.branding'] + branding = Branding.search( + [('white_label_domain', '=', domain_param)], limit=1, + ) + if not branding: + return _error_response('Branding not found for this domain', 404) + + return _json_response({ + 'app_name': branding.app_name or 'EnCoach', + 'primary_color': branding.primary_color or '#4F46E5', + 'secondary_color': branding.secondary_color or '#7C3AED', + 'background_color': branding.background_color or '#FFFFFF', + 'has_logo': bool(branding.logo), + 'logo_url': branding.logo_url or '', + 'login_title': branding.login_title or '', + 'login_description': branding.login_description or '', + 'custom_css': branding.custom_css or '', + }) + + user = validate_token() + if not user: + return _error_response('Missing or invalid Authorization header', 401) + entity = user.entity_ids[:1] + if not entity: + return _error_response('User has no associated entity', 404) + + Branding = request.env['encoach.branding'].sudo() + branding = Branding.search([('entity_id', '=', entity.id)], limit=1) + if not branding: + return _json_response({ + 'entity_id': entity.id, + 'entity_name': entity.name, + 'app_name': 'EnCoach', + 'primary_color': entity.primary_color or '#4F46E5', + 'secondary_color': entity.secondary_color or '#7C3AED', + 'background_color': entity.background_color or '#FFFFFF', + 'has_logo': bool(entity.logo), + 'logo_url': entity.logo_url or '', + 'login_title': entity.login_title or '', + 'login_description': entity.login_description or '', + }) + + return _json_response(branding.to_api_dict()) + + except Exception as e: + _logger.exception('get_branding failed') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_branding/models/__init__.py b/custom_addons/encoach_branding/models/__init__.py new file mode 100644 index 00000000..7fe861f6 --- /dev/null +++ b/custom_addons/encoach_branding/models/__init__.py @@ -0,0 +1 @@ +from . import branding diff --git a/custom_addons/encoach_branding/models/branding.py b/custom_addons/encoach_branding/models/branding.py new file mode 100644 index 00000000..a97d1dc5 --- /dev/null +++ b/custom_addons/encoach_branding/models/branding.py @@ -0,0 +1,40 @@ +from odoo import models, fields + + +class EncoachBranding(models.Model): + _name = 'encoach.branding' + _description = 'Entity Branding' + + entity_id = fields.Many2one('encoach.entity', required=True, ondelete='cascade') + primary_color = fields.Char(default='#4F46E5') + secondary_color = fields.Char(default='#7C3AED') + background_color = fields.Char(default='#FFFFFF') + logo = fields.Binary(attachment=True) + logo_url = fields.Char(string='Logo URL') + favicon = fields.Binary(attachment=True) + app_name = fields.Char(default='EnCoach') + white_label_domain = fields.Char(string='Subdomain') + login_title = fields.Char(default='Welcome to EnCoach') + login_description = fields.Text() + custom_css = fields.Text() + + _entity_unique = models.Constraint('UNIQUE(entity_id)', 'Branding record must be unique per entity.') + + def to_api_dict(self): + self.ensure_one() + return { + 'id': self.id, + 'entity_id': self.entity_id.id, + 'entity_name': self.entity_id.name, + 'primary_color': self.primary_color, + 'secondary_color': self.secondary_color, + 'background_color': self.background_color or '#FFFFFF', + 'has_logo': bool(self.logo), + 'logo_url': self.logo_url or '', + 'has_favicon': bool(self.favicon), + 'app_name': self.app_name, + 'white_label_domain': self.white_label_domain or '', + 'login_title': self.login_title or '', + 'login_description': self.login_description or '', + 'custom_css': self.custom_css or '', + } diff --git a/custom_addons/encoach_branding/security/ir.model.access.csv b/custom_addons/encoach_branding/security/ir.model.access.csv new file mode 100644 index 00000000..b98e84cb --- /dev/null +++ b/custom_addons/encoach_branding/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_branding_all,encoach.branding.all,model_encoach_branding,base.group_user,1,1,1,1 diff --git a/custom_addons/encoach_branding/views/branding_menus.xml b/custom_addons/encoach_branding/views/branding_menus.xml new file mode 100644 index 00000000..f8f28615 --- /dev/null +++ b/custom_addons/encoach_branding/views/branding_menus.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/custom_addons/encoach_branding/views/branding_views.xml b/custom_addons/encoach_branding/views/branding_views.xml new file mode 100644 index 00000000..22b235dc --- /dev/null +++ b/custom_addons/encoach_branding/views/branding_views.xml @@ -0,0 +1,61 @@ + + + + + encoach.branding.form + encoach.branding + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + encoach.branding.list + encoach.branding + + + + + + + + + + + + + White-Label Branding + encoach.branding + list,form + + +
diff --git a/custom_addons/encoach_core/__init__.py b/custom_addons/encoach_core/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/custom_addons/encoach_core/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/custom_addons/encoach_core/__manifest__.py b/custom_addons/encoach_core/__manifest__.py new file mode 100644 index 00000000..f9237aa5 --- /dev/null +++ b/custom_addons/encoach_core/__manifest__.py @@ -0,0 +1,27 @@ +{ + 'name': 'EnCoach Core', + 'version': '19.0.1.1', + 'category': 'Education', + 'summary': 'Core models for EnCoach: users, entities, roles, permissions', + 'author': 'EnCoach', + 'depends': ['base', 'mail', 'openeducat_core'], + 'data': [ + 'security/encoach_security.xml', + 'security/ir.model.access.csv', + 'data/encoach_permissions.xml', + 'views/encoach_menus.xml', + 'views/encoach_entity_views.xml', + 'views/entity_level_mapping_views.xml', + 'views/dashboard_action.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'encoach_core/static/src/js/dashboard.js', + 'encoach_core/static/src/xml/dashboard.xml', + 'encoach_core/static/src/css/dashboard.css', + ], + }, + 'installable': True, + 'application': True, + 'license': 'LGPL-3', +} diff --git a/custom_addons/encoach_core/data/encoach_permissions.xml b/custom_addons/encoach_core/data/encoach_permissions.xml new file mode 100644 index 00000000..3986610a --- /dev/null +++ b/custom_addons/encoach_core/data/encoach_permissions.xml @@ -0,0 +1,468 @@ + + + + + + viewCorporate + Manage Corporate + + + editCorporate + Manage Corporate + + + deleteCorporate + Manage Corporate + + + createCodeCorporate + Manage Corporate + + + viewAdmin + Manage Admin + + + editAdmin + Manage Admin + + + deleteAdmin + Manage Admin + + + createCodeAdmin + Manage Admin + + + viewStudent + Manage Student + + + editStudent + Manage Student + + + deleteStudent + Manage Student + + + createCodeStudent + Manage Student + + + viewTeacher + Manage Teacher + + + editTeacher + Manage Teacher + + + deleteTeacher + Manage Teacher + + + createCodeTeacher + Manage Teacher + + + viewCountryManager + Manage Country Manager + + + editCountryManager + Manage Country Manager + + + deleteCountryManager + Manage Country Manager + + + createCodeCountryManager + Manage Country Manager + + + createReadingExam + Manage Exams + + + createListeningExam + Manage Exams + + + createWritingExam + Manage Exams + + + createSpeakingExam + Manage Exams + + + createLevelExam + Manage Exams + + + viewExams + View Pages + + + viewExercises + View Pages + + + viewRecords + View Pages + + + viewStats + View Pages + + + viewTickets + View Pages + + + viewPaymentRecords + View Pages + + + viewGroup + Manage Group + + + editGroup + Manage Group + + + deleteGroup + Manage Group + + + createGroup + Manage Group + + + viewCodes + Manage Codes + + + deleteCodes + Manage Codes + + + createCodes + Manage Codes + + + all + Others + + + + + view_students + Users + + + view_teachers + Users + + + view_corporates + Users + + + view_mastercorporates + Users + + + edit_students + Users + + + edit_teachers + Users + + + edit_corporates + Users + + + edit_mastercorporates + Users + + + delete_students + Users + + + delete_teachers + Users + + + delete_corporates + Users + + + delete_mastercorporates + Users + + + generate_reading + Exams + + + view_reading + Exams + + + delete_reading + Exams + + + generate_listening + Exams + + + view_listening + Exams + + + delete_listening + Exams + + + generate_writing + Exams + + + view_writing + Exams + + + delete_writing + Exams + + + generate_speaking + Exams + + + view_speaking + Exams + + + delete_speaking + Exams + + + generate_level + Exams + + + view_level + Exams + + + delete_level + Exams + + + view_classrooms + Classrooms + + + create_classroom + Classrooms + + + rename_classrooms + Classrooms + + + add_to_classroom + Classrooms + + + remove_from_classroom + Classrooms + + + delete_classroom + Classrooms + + + view_entities + Entities + + + rename_entity + Entities + + + add_to_entity + Entities + + + remove_from_entity + Entities + + + delete_entity + Entities + + + view_entity_roles + Roles + + + create_entity_role + Roles + + + rename_entity_role + Roles + + + edit_role_permissions + Roles + + + assign_to_role + Roles + + + delete_entity_role + Roles + + + view_assignments + Assignments + + + create_assignment + Assignments + + + edit_assignment + Assignments + + + delete_assignment + Assignments + + + start_assignment + Assignments + + + archive_assignment + Assignments + + + view_entity_statistics + Statistics + + + create_user + Users + + + create_user_batch + Users + + + create_code + Codes + + + create_code_batch + Codes + + + view_code_list + Codes + + + delete_code + Codes + + + view_statistics + Statistics + + + download_statistics_report + Statistics + + + edit_grading_system + Grading + + + view_student_performance + Students + + + upload_classroom + Classrooms + + + download_user_list + Users + + + view_student_record + Students + + + download_student_record + Students + + + pay_entity + Payments + + + view_payment_record + Payments + + + view_approval_workflows + Workflows + + + update_exam_privacy + Exams + + + view_workflows + Workflows + + + configure_workflows + Workflows + + + edit_workflow + Workflows + + + delete_workflow + Workflows + + + view_confidential_exams + Exams + + + create_confidential_exams + Exams + + + create_public_exams + Exams + + + diff --git a/custom_addons/encoach_core/models/__init__.py b/custom_addons/encoach_core/models/__init__.py new file mode 100644 index 00000000..8c0d7bc4 --- /dev/null +++ b/custom_addons/encoach_core/models/__init__.py @@ -0,0 +1,8 @@ +from . import encoach_user +from . import encoach_entity +from . import role +from . import permission +from . import code +from . import invite +from . import user_entity_rel +from . import entity_level_mapping diff --git a/custom_addons/encoach_core/models/code.py b/custom_addons/encoach_core/models/code.py new file mode 100644 index 00000000..c3992d3c --- /dev/null +++ b/custom_addons/encoach_core/models/code.py @@ -0,0 +1,57 @@ +import uuid +from odoo import models, fields, api + + +class EncoachCode(models.Model): + _name = "encoach.code" + _description = "EnCoach Registration Code" + + code = fields.Char(required=True, index=True, default=lambda self: uuid.uuid4().hex[:8].upper()) + entity_id = fields.Many2one("encoach.entity", ondelete="set null") + creator_id = fields.Many2one("res.users", required=True) + expiry_date = fields.Datetime() + code_type = fields.Selection( + [("individual", "Individual"), ("corporate", "Corporate")], + default="individual", + ) + max_uses = fields.Integer(default=1) + uses = fields.Integer(default=0) + user_type = fields.Selection( + [ + ("student", "Student"), + ("teacher", "Teacher"), + ("corporate", "Corporate"), + ], + default="student", + ) + legacy_id = fields.Char(index=True) + + @api.model + def validate_code(self, code_str): + rec = self.search([("code", "=", code_str)], limit=1) + if not rec: + return {"valid": False, "error": "Code not found"} + if rec.expiry_date and rec.expiry_date < fields.Datetime.now(): + return {"valid": False, "error": "Code expired"} + if rec.max_uses and rec.uses >= rec.max_uses: + return {"valid": False, "error": "Code fully used"} + return { + "valid": True, + "code": rec.to_encoach_dict(), + } + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "code": self.code, + "entity": self.entity_id.id if self.entity_id else None, + "creator": self.creator_id.id, + "expiryDate": ( + self.expiry_date.isoformat() if self.expiry_date else None + ), + "type": self.code_type, + "maxUses": self.max_uses, + "uses": self.uses, + "userType": self.user_type, + } diff --git a/custom_addons/encoach_core/models/encoach_entity.py b/custom_addons/encoach_core/models/encoach_entity.py new file mode 100644 index 00000000..2f6e777f --- /dev/null +++ b/custom_addons/encoach_core/models/encoach_entity.py @@ -0,0 +1,62 @@ +from odoo import models, fields, api + + +class EncoachEntity(models.Model): + _name = 'encoach.entity' + _description = 'EnCoach Entity' + _inherit = ['mail.thread'] + + name = fields.Char(required=True, tracking=True) + code = fields.Char(required=True, tracking=True) + type = fields.Selection([ + ('corporate', 'Corporate'), + ('university', 'University'), + ('school', 'School'), + ('government', 'Government'), + ('freelance', 'Freelance'), + ], tracking=True) + logo = fields.Binary(attachment=True) + logo_url = fields.Char(string='Logo URL') + primary_color = fields.Char(default='#4F46E5') + secondary_color = fields.Char(default='#7C3AED') + background_color = fields.Char(default='#FFFFFF') + white_label_domain = fields.Char(string='White Label Domain') + login_title = fields.Char() + login_description = fields.Text() + favicon = fields.Binary(attachment=True) + results_release_mode = fields.Selection([ + ('auto', 'Auto'), + ('manual_approval', 'Manual Approval'), + ], default='auto') + active = fields.Boolean(default=True) + user_ids = fields.Many2many('res.users', 'encoach_entity_user_rel', 'entity_id', 'user_id', string='Users') + role_ids = fields.One2many('encoach.role', 'entity_id', string='Roles') + user_count = fields.Integer(compute='_compute_user_count', store=True) + + _code_unique = models.Constraint('UNIQUE(code)', 'Entity code must be unique.') + + @api.depends('user_ids') + def _compute_user_count(self): + for rec in self: + rec.user_count = len(rec.user_ids) + + def to_api_dict(self): + self.ensure_one() + return { + 'id': self.id, + 'name': self.name, + 'code': self.code, + 'type': self.type or '', + 'logo': bool(self.logo), + 'logo_url': self.logo_url or '', + 'primary_color': self.primary_color or '#4F46E5', + 'secondary_color': self.secondary_color or '#7C3AED', + 'background_color': self.background_color or '#FFFFFF', + 'white_label_domain': self.white_label_domain or '', + 'login_title': self.login_title or '', + 'login_description': self.login_description or '', + 'has_favicon': bool(self.favicon), + 'results_release_mode': self.results_release_mode or 'auto', + 'active': self.active, + 'user_count': self.user_count, + } diff --git a/custom_addons/encoach_core/models/encoach_user.py b/custom_addons/encoach_core/models/encoach_user.py new file mode 100644 index 00000000..496e8a2c --- /dev/null +++ b/custom_addons/encoach_core/models/encoach_user.py @@ -0,0 +1,86 @@ +from odoo import models, fields, api + +USER_TYPE_SELECTION = [ + ('student', 'Student'), + ('teacher', 'Teacher'), + ('admin', 'Admin'), + ('corporate', 'Corporate'), + ('mastercorporate', 'Master Corporate'), + ('agent', 'Agent'), + ('developer', 'Developer'), +] + + +class EncoachUser(models.Model): + _inherit = 'res.users' + + user_type = fields.Selection(USER_TYPE_SELECTION, string='User Type') + is_verified = fields.Boolean(default=False) + entity_ids = fields.Many2many('encoach.entity', 'encoach_entity_user_rel', 'user_id', 'entity_id', string='Entities') + phone = fields.Char() + birth_date = fields.Date() + gender = fields.Selection([('m', 'Male'), ('f', 'Female'), ('o', 'Other')]) + op_student_id = fields.Many2one('op.student', string='OpenEduCat Student') + op_faculty_id = fields.Many2one('op.faculty', string='OpenEduCat Faculty') + encoach_avatar = fields.Binary(attachment=True, string='EnCoach Avatar') + last_login = fields.Datetime() + role_ids = fields.Many2many( + 'encoach.role', 'encoach_role_user_rel', 'user_id', 'role_id', string='Roles', + ) + permission_direct_ids = fields.Many2many( + 'encoach.permission', 'encoach_user_permission_rel', 'user_id', 'permission_id', + string='Direct Permissions', + ) + + first_login = fields.Boolean(default=True) + account_source = fields.Selection([ + ('self_registered', 'Self Registered'), + ('entity_bulk_upload', 'Entity Bulk Upload'), + ]) + account_status = fields.Selection([ + ('unactivated', 'Unactivated'), + ('activated', 'Activated'), + ('suspended', 'Suspended'), + ], default='unactivated') + + def get_all_permissions(self): + """Return the union of direct permissions and permissions from all assigned roles.""" + self.ensure_one() + role_permissions = self.role_ids.mapped('permission_ids') + return self.permission_direct_ids | role_permissions + + def _api_user_type(self): + """Frontend routes require a non-empty role; many users have no encoach.user_type set yet.""" + self.ensure_one() + if self.user_type: + return self.user_type + if self.op_faculty_id: + return 'teacher' + if self.op_student_id: + return 'student' + if self.has_group('base.group_system'): + return 'admin' + return '' + + def to_api_dict(self): + self.ensure_one() + return { + 'id': self.id, + 'name': self.name, + 'email': self.email or '', + 'login': self.login or '', + 'user_type': self._api_user_type(), + 'is_verified': self.is_verified, + 'phone': self.phone or '', + 'birth_date': self.birth_date.isoformat() if self.birth_date else None, + 'gender': self.gender or '', + 'avatar': bool(self.encoach_avatar), + 'entities': [ + {'id': e.id, 'name': e.name, 'logo': bool(e.logo)} + for e in self.entity_ids + ], + 'last_login': self.last_login.isoformat() if self.last_login else None, + 'first_login': self.first_login, + 'account_source': self.account_source or '', + 'account_status': self.account_status or 'unactivated', + } diff --git a/custom_addons/encoach_core/models/entity_level_mapping.py b/custom_addons/encoach_core/models/entity_level_mapping.py new file mode 100644 index 00000000..5abe91c5 --- /dev/null +++ b/custom_addons/encoach_core/models/entity_level_mapping.py @@ -0,0 +1,15 @@ +from odoo import models, fields + + +class EncoachEntityLevelMapping(models.Model): + _name = 'encoach.entity.level.mapping' + _description = 'Entity Level Mapping' + + entity_id = fields.Many2one('encoach.entity', required=True, ondelete='cascade', index=True) + min_score = fields.Float(required=True) + max_score = fields.Float(required=True) + internal_level_name = fields.Char(size=100, required=True) + cefr_equivalent = fields.Selection([ + ('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'), + ('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'), + ]) diff --git a/custom_addons/encoach_core/models/invite.py b/custom_addons/encoach_core/models/invite.py new file mode 100644 index 00000000..cf61e476 --- /dev/null +++ b/custom_addons/encoach_core/models/invite.py @@ -0,0 +1,46 @@ +from odoo import models, fields + + +class EncoachInvite(models.Model): + _name = "encoach.invite" + _description = "EnCoach Entity Invite" + + from_user_id = fields.Many2one("res.users", required=True, ondelete="cascade") + to_user_id = fields.Many2one("res.users", required=True, ondelete="cascade") + entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade") + status = fields.Selection( + [ + ("pending", "Pending"), + ("accepted", "Accepted"), + ("declined", "Declined"), + ], + default="pending", + ) + legacy_id = fields.Char(index=True) + + def action_accept(self): + self.ensure_one() + self.status = "accepted" + existing = self.env["encoach.user.entity.rel"].search([ + ("user_id", "=", self.to_user_id.id), + ("entity_id", "=", self.entity_id.id), + ], limit=1) + if not existing: + self.env["encoach.user.entity.rel"].create({ + "user_id": self.to_user_id.id, + "entity_id": self.entity_id.id, + }) + + def action_decline(self): + self.ensure_one() + self.status = "declined" + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "from": self.from_user_id.id, + "to": self.to_user_id.id, + "entity": self.entity_id.id, + "status": self.status, + } diff --git a/custom_addons/encoach_core/models/permission.py b/custom_addons/encoach_core/models/permission.py new file mode 100644 index 00000000..a6d6be70 --- /dev/null +++ b/custom_addons/encoach_core/models/permission.py @@ -0,0 +1,24 @@ +from odoo import models, fields + + +class EncoachPermission(models.Model): + _name = "encoach.permission" + _description = "EnCoach Permission" + + code = fields.Char(required=True, index=True) + topic = fields.Char() + legacy_id = fields.Char(index=True) + + _code_unique = models.Constraint( + "UNIQUE(code)", + "Permission code must be unique.", + ) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "type": self.code, + "topic": self.topic or "", + "users": [], + } diff --git a/custom_addons/encoach_core/models/role.py b/custom_addons/encoach_core/models/role.py new file mode 100644 index 00000000..98c7e490 --- /dev/null +++ b/custom_addons/encoach_core/models/role.py @@ -0,0 +1,22 @@ +from odoo import models, fields + + +class EncoachRole(models.Model): + _name = "encoach.role" + _description = "EnCoach Role" + + name = fields.Char(required=True) + entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade") + permission_ids = fields.Many2many("encoach.permission", string="Permissions") + is_default = fields.Boolean(default=False) + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "label": self.name, + "entityID": self.entity_id.id, + "permissions": [p.code for p in self.permission_ids if p.code], + "isDefault": self.is_default, + } diff --git a/custom_addons/encoach_core/models/user_entity_rel.py b/custom_addons/encoach_core/models/user_entity_rel.py new file mode 100644 index 00000000..3fce8991 --- /dev/null +++ b/custom_addons/encoach_core/models/user_entity_rel.py @@ -0,0 +1,15 @@ +from odoo import models, fields + + +class EncoachUserEntityRel(models.Model): + _name = "encoach.user.entity.rel" + _description = "EnCoach User-Entity Membership" + + user_id = fields.Many2one("res.users", required=True, ondelete="cascade") + entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade") + role_id = fields.Many2one("encoach.role", ondelete="set null") + + _user_entity_unique = models.Constraint( + "UNIQUE(user_id, entity_id)", + "A user can only have one membership per entity.", + ) diff --git a/custom_addons/encoach_core/security/encoach_security.xml b/custom_addons/encoach_core/security/encoach_security.xml new file mode 100644 index 00000000..471aaeb4 --- /dev/null +++ b/custom_addons/encoach_core/security/encoach_security.xml @@ -0,0 +1,30 @@ + + + + + + EnCoach Student + + + EnCoach Teacher + + + EnCoach Corporate + + + EnCoach Admin + + + + EnCoach Developer + + + + EnCoach Agent + + + EnCoach Master Corporate + + + + diff --git a/custom_addons/encoach_core/security/ir.model.access.csv b/custom_addons/encoach_core/security/ir.model.access.csv new file mode 100644 index 00000000..5db3a3e7 --- /dev/null +++ b/custom_addons/encoach_core/security/ir.model.access.csv @@ -0,0 +1,20 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_entity_student,encoach.entity.student,model_encoach_entity,group_encoach_student,1,0,0,0 +access_encoach_entity_teacher,encoach.entity.teacher,model_encoach_entity,group_encoach_teacher,1,1,0,0 +access_encoach_entity_admin,encoach.entity.admin,model_encoach_entity,group_encoach_admin,1,1,1,1 +access_encoach_role_student,encoach.role.student,model_encoach_role,group_encoach_student,1,0,0,0 +access_encoach_role_teacher,encoach.role.teacher,model_encoach_role,group_encoach_teacher,1,1,0,0 +access_encoach_role_admin,encoach.role.admin,model_encoach_role,group_encoach_admin,1,1,1,1 +access_encoach_permission_student,encoach.permission.student,model_encoach_permission,group_encoach_student,1,0,0,0 +access_encoach_permission_teacher,encoach.permission.teacher,model_encoach_permission,group_encoach_teacher,1,0,0,0 +access_encoach_permission_admin,encoach.permission.admin,model_encoach_permission,group_encoach_admin,1,1,1,1 +access_encoach_invite_student,encoach.invite.student,model_encoach_invite,group_encoach_student,1,0,0,0 +access_encoach_invite_teacher,encoach.invite.teacher,model_encoach_invite,group_encoach_teacher,1,1,0,0 +access_encoach_invite_admin,encoach.invite.admin,model_encoach_invite,group_encoach_admin,1,1,1,1 +access_encoach_code_student,encoach.code.student,model_encoach_code,group_encoach_student,1,0,0,0 +access_encoach_code_teacher,encoach.code.teacher,model_encoach_code,group_encoach_teacher,1,1,0,0 +access_encoach_code_admin,encoach.code.admin,model_encoach_code,group_encoach_admin,1,1,1,1 +access_encoach_user_entity_rel_all,encoach.user.entity.rel.all,model_encoach_user_entity_rel,base.group_user,1,1,1,1 +access_encoach_level_mapping_student,encoach.entity.level.mapping.student,model_encoach_entity_level_mapping,group_encoach_student,1,0,0,0 +access_encoach_level_mapping_teacher,encoach.entity.level.mapping.teacher,model_encoach_entity_level_mapping,group_encoach_teacher,1,1,0,0 +access_encoach_level_mapping_admin,encoach.entity.level.mapping.admin,model_encoach_entity_level_mapping,group_encoach_admin,1,1,1,1 diff --git a/custom_addons/encoach_core/static/src/css/dashboard.css b/custom_addons/encoach_core/static/src/css/dashboard.css new file mode 100644 index 00000000..23000d95 --- /dev/null +++ b/custom_addons/encoach_core/static/src/css/dashboard.css @@ -0,0 +1,16 @@ +.o_encoach_dashboard .card { + transition: transform 0.15s, box-shadow 0.15s; + border-radius: 0.5rem; +} +.o_encoach_dashboard .card.cursor-pointer:hover { + transform: translateY(-2px); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important; +} +.o_encoach_dashboard h2 { + color: #1f2937; + font-weight: 700; +} +.o_encoach_dashboard .badge { + text-transform: capitalize; + font-size: 0.75rem; +} diff --git a/custom_addons/encoach_core/static/src/js/dashboard.js b/custom_addons/encoach_core/static/src/js/dashboard.js new file mode 100644 index 00000000..f593c087 --- /dev/null +++ b/custom_addons/encoach_core/static/src/js/dashboard.js @@ -0,0 +1,94 @@ +/** @odoo-module */ + +import { Component, onWillStart, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { Layout } from "@web/search/layout"; + +class EncoachDashboard extends Component { + static template = "encoach_core.Dashboard"; + static components = { Layout }; + static props = ["*"]; + + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.state = useState({ + loading: true, + stats: { + total_students: 0, + total_teachers: 0, + total_courses: 0, + total_exams: 0, + active_sessions: 0, + pending_grading: 0, + pending_approval: 0, + total_entities: 0, + }, + recentAttempts: [], + }); + + onWillStart(async () => { + await this.loadDashboardData(); + }); + } + + async loadDashboardData() { + try { + const [students, teachers, courses, exams, sessions, pendingGrading, pendingApproval, entities] = await Promise.all([ + this.orm.searchCount("res.users", [["account_source", "!=", false]]), + this.orm.searchCount("res.users", [["user_type", "=", "teacher"]]), + this.orm.searchCount("op.course", []), + this.orm.searchCount("encoach.exam.custom", []), + this.orm.searchCount("encoach.student.attempt", [["status", "=", "in_progress"]]), + this.orm.searchCount("encoach.student.attempt", [["status", "=", "scoring"]]), + this.orm.searchCount("encoach.student.attempt", [["status", "=", "pending_approval"]]), + this.orm.searchCount("encoach.entity", []), + ]); + + this.state.stats = { + total_students: students, + total_teachers: teachers, + total_courses: courses, + total_exams: exams, + active_sessions: sessions, + pending_grading: pendingGrading, + pending_approval: pendingApproval, + total_entities: entities, + }; + + const attempts = await this.orm.searchRead( + "encoach.student.attempt", + [], + ["student_id", "exam_id", "status", "overall_band", "started_at"], + { limit: 10, order: "started_at desc" }, + ); + this.state.recentAttempts = attempts; + + } catch (e) { + console.error("Dashboard load error:", e); + } finally { + this.state.loading = false; + } + } + + openAction(model, name, domain = []) { + this.action.doAction({ + type: "ir.actions.act_window", + name: name, + res_model: model, + views: [[false, "list"], [false, "form"]], + domain: domain, + }); + } + + openStudents() { this.openAction("res.users", "Students", [["account_source", "!=", false]]); } + openTeachers() { this.openAction("res.users", "Teachers", [["user_type", "=", "teacher"]]); } + openCourses() { this.openAction("op.course", "Courses"); } + openExams() { this.openAction("encoach.exam.custom", "Exams"); } + openGradingQueue() { this.openAction("encoach.student.attempt", "Grading Queue", [["status", "=", "scoring"]]); } + openPendingApproval() { this.openAction("encoach.student.attempt", "Score Approval", [["status", "=", "pending_approval"]]); } + openEntities() { this.openAction("encoach.entity", "Entities"); } +} + +registry.category("actions").add("encoach_dashboard", EncoachDashboard); diff --git a/custom_addons/encoach_core/static/src/xml/dashboard.xml b/custom_addons/encoach_core/static/src/xml/dashboard.xml new file mode 100644 index 00000000..22e909ae --- /dev/null +++ b/custom_addons/encoach_core/static/src/xml/dashboard.xml @@ -0,0 +1,162 @@ + + + + +
+
+

EnCoach Dashboard

+ + +
+ +

Loading dashboard...

+
+
+ + + +
+
+
+
+
+
+ +
+
+

+ Students +

+
+
+
+
+
+
+
+
+
+ +
+
+

+ Teachers +

+
+
+
+
+
+
+
+
+
+ +
+
+

+ Courses +

+
+
+
+
+
+
+
+
+
+ +
+
+

+ Exams +

+
+
+
+
+
+ + +
+
+
+
+
Grading Queue
+

+ Exams awaiting grading +

+
+
+
+
+
+
Pending Approval
+

+ Scores awaiting release +

+
+
+
+
+
+
Entities
+

+ Registered organizations +

+
+
+
+ + +
+
+
Recent Exam Attempts
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
StudentExamStatusBandDate
+ + + + + +
No recent attempts
+
+
+
+
+
+
+
+
diff --git a/custom_addons/encoach_core/views/dashboard_action.xml b/custom_addons/encoach_core/views/dashboard_action.xml new file mode 100644 index 00000000..ec9781bd --- /dev/null +++ b/custom_addons/encoach_core/views/dashboard_action.xml @@ -0,0 +1,13 @@ + + + + EnCoach Dashboard + encoach_dashboard + + + + diff --git a/custom_addons/encoach_core/views/encoach_entity_views.xml b/custom_addons/encoach_core/views/encoach_entity_views.xml new file mode 100644 index 00000000..af3ae4e0 --- /dev/null +++ b/custom_addons/encoach_core/views/encoach_entity_views.xml @@ -0,0 +1,115 @@ + + + + encoach.entity.list + encoach.entity + + + + + + + + + + + + + + + encoach.entity.form + encoach.entity + +
+ +
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + encoach.entity.search + encoach.entity + + + + + + + + + + + + + + + Entities + encoach.entity + list,form + + +

+ Create your first entity +

+

Entities represent organizations (corporates, universities, schools) on the EnCoach platform.

+
+
+ + +
diff --git a/custom_addons/encoach_core/views/encoach_menus.xml b/custom_addons/encoach_core/views/encoach_menus.xml new file mode 100644 index 00000000..e3fb9928 --- /dev/null +++ b/custom_addons/encoach_core/views/encoach_menus.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/custom_addons/encoach_core/views/entity_level_mapping_views.xml b/custom_addons/encoach_core/views/entity_level_mapping_views.xml new file mode 100644 index 00000000..709e067f --- /dev/null +++ b/custom_addons/encoach_core/views/entity_level_mapping_views.xml @@ -0,0 +1,52 @@ + + + + + encoach.entity.level.mapping.form + encoach.entity.level.mapping + +
+ + + + + + + + + + + + + +
+
+
+ + + encoach.entity.level.mapping.list + encoach.entity.level.mapping + + + + + + + + + + + + + Entity Level Mappings + encoach.entity.level.mapping + list,form + + + + +
diff --git a/custom_addons/encoach_course_gen/__init__.py b/custom_addons/encoach_course_gen/__init__.py new file mode 100644 index 00000000..f7209b17 --- /dev/null +++ b/custom_addons/encoach_course_gen/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/custom_addons/encoach_course_gen/__manifest__.py b/custom_addons/encoach_course_gen/__manifest__.py new file mode 100644 index 00000000..f1550489 --- /dev/null +++ b/custom_addons/encoach_course_gen/__manifest__.py @@ -0,0 +1,27 @@ +{ + 'name': 'EnCoach Course Generation', + 'version': '19.0.1.0', + 'category': 'Education', + 'summary': 'Gap analysis, auto course generation, and adaptive progression', + 'author': 'EnCoach', + 'license': 'LGPL-3', + 'depends': ['encoach_core', 'encoach_taxonomy', 'encoach_resources'], + 'data': [ + 'security/ir.model.access.csv', + 'views/gap_profile_views.xml', + 'views/course_section_views.xml', + 'views/course_module_views.xml', + 'views/gap_analysis_action.xml', + 'views/course_gen_menus.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'encoach_course_gen/static/src/js/gap_analysis.js', + 'encoach_course_gen/static/src/xml/gap_analysis.xml', + 'encoach_course_gen/static/src/css/gap_analysis.css', + ], + }, + 'installable': True, + 'application': False, + 'auto_install': False, +} diff --git a/custom_addons/encoach_course_gen/controllers/__init__.py b/custom_addons/encoach_course_gen/controllers/__init__.py new file mode 100644 index 00000000..28f7ca86 --- /dev/null +++ b/custom_addons/encoach_course_gen/controllers/__init__.py @@ -0,0 +1 @@ +from . import course diff --git a/custom_addons/encoach_course_gen/controllers/course.py b/custom_addons/encoach_course_gen/controllers/course.py new file mode 100644 index 00000000..145c5ea3 --- /dev/null +++ b/custom_addons/encoach_course_gen/controllers/course.py @@ -0,0 +1,630 @@ +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__) + +CEFR_ORDER = ['pre_a1', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2'] + +STEP_DOWN_THRESHOLD = 0.4 +PASS_THRESHOLD_DEFAULT = 0.7 + + +def _cefr_index(level): + try: + return CEFR_ORDER.index(level) + except ValueError: + return 0 + + +def _next_cefr(level): + idx = _cefr_index(level) + return CEFR_ORDER[min(idx + 1, len(CEFR_ORDER) - 1)] + + +def _module_to_dict(mod): + return { + 'id': mod.id, + 'name': mod.name, + 'cefr_target': mod.cefr_target or '', + 'auto_generated': mod.auto_generated, + 'completion_criteria': mod.completion_criteria or 'all_resources', + 'score_threshold': mod.score_threshold, + 'prerequisite_module_id': mod.prerequisite_module_id.id if mod.prerequisite_module_id else None, + 'status': mod.status, + 'sequence': mod.sequence, + 'generation_brief': mod.generation_brief or '', + } + + +def _course_to_dict(course, include_modules=True): + data = { + 'id': course.id, + 'name': course.name, + 'code': course.code if hasattr(course, 'code') and course.code else '', + 'description': course.description if hasattr(course, 'description') else '', + 'generation_source': course.generation_source if hasattr(course, 'generation_source') else '', + 'progression_model': course.progression_model if hasattr(course, 'progression_model') else 'linear', + 'target_band': course.target_band if hasattr(course, 'target_band') else 0.0, + 'entity_id': course.entity_id.id if hasattr(course, 'entity_id') and course.entity_id else None, + } + if include_modules: + Module = request.env['encoach.course.module'].sudo() + modules = Module.search([('course_id', '=', course.id)], order='sequence asc') + data['modules'] = [_module_to_dict(m) for m in modules] + return data + + +class EncoachCourseController(http.Controller): + + # ------------------------------------------------------------------ + # POST /api/course/create + # ------------------------------------------------------------------ + @http.route('/api/course/create', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def create(self, **kw): + try: + body = _get_json_body() + name = (body.get('name') or '').strip() + if not name: + return _error_response('name is required', 400) + + Course = request.env['op.course'].sudo() + course_vals = { + 'name': name, + 'code': body.get('code', name[:20].upper().replace(' ', '_')), + } + if body.get('subject_id'): + course_vals['subject_id'] = int(body['subject_id']) + if body.get('description') and hasattr(Course, 'description'): + course_vals['description'] = body['description'] + if hasattr(Course, 'generation_source'): + course_vals['generation_source'] = 'manual' + if hasattr(Course, 'entity_id'): + user = request.env.user.sudo() + if user.entity_ids: + course_vals['entity_id'] = user.entity_ids[0].id + + course = Course.create(course_vals) + + Module = request.env['encoach.course.module'].sudo() + modules_data = body.get('modules', []) + for idx, mod_data in enumerate(modules_data): + Module.create({ + 'name': mod_data.get('name', f'Module {idx + 1}'), + 'course_id': course.id, + 'cefr_target': mod_data.get('cefr_target', ''), + 'completion_criteria': mod_data.get('completion_criteria', 'all_resources'), + 'score_threshold': mod_data.get('score_threshold', 0.0), + 'sequence': mod_data.get('sequence', (idx + 1) * 10), + 'status': 'available' if idx == 0 else 'locked', + }) + + return _json_response(_course_to_dict(course), 201) + + except Exception as e: + _logger.exception('course create failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/course/gap-analysis + # ------------------------------------------------------------------ + @http.route('/api/course/gap-analysis', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def gap_analysis(self, **kw): + try: + uid = request.env.user.id + GapProfile = request.env['encoach.gap.profile'].sudo() + profile = GapProfile.search([ + ('student_id', '=', uid), + ], limit=1, order='created_at desc') + + if not profile: + CatSession = request.env['encoach.cat.session'].sudo() + session = CatSession.search([ + ('student_id', '=', uid), + ('status', '=', 'completed'), + ], limit=1, order='completed_at desc') + + if not session: + return _error_response('No placement results or gap profile found', 404) + + Ability = request.env['encoach.student.ability.model'].sudo() + abilities = Ability.search([('student_id', '=', uid)]) + + skill_gaps = {} + for ab in abilities: + if ab.theta < 0.5: + skill_gaps[ab.skill] = { + 'theta': ab.theta, + 'sem': ab.sem, + 'gap_severity': 'high' if ab.theta < -0.5 else 'medium' if ab.theta < 0.0 else 'low', + } + + Attempt = request.env['encoach.student.attempt'].sudo() + placement_attempt = Attempt.search([ + ('student_id', '=', uid), + ('is_placement', '=', True), + ], limit=1, order='id desc') + + question_type_weaknesses = {} + topic_weaknesses = {} + if placement_attempt: + Answer = request.env['encoach.student.answer'].sudo() + answers = Answer.search([('attempt_id', '=', placement_attempt.id)]) + type_stats = {} + topic_stats = {} + for ans in answers: + q = ans.question_id + qt = q.question_type or 'unknown' + type_stats.setdefault(qt, {'correct': 0, 'total': 0}) + type_stats[qt]['total'] += 1 + if ans.is_correct: + type_stats[qt]['correct'] += 1 + if q.topic_id: + tid = q.topic_id.name or str(q.topic_id.id) + topic_stats.setdefault(tid, {'correct': 0, 'total': 0}) + topic_stats[tid]['total'] += 1 + if ans.is_correct: + topic_stats[tid]['correct'] += 1 + + for qt, stats in type_stats.items(): + pct = stats['correct'] / stats['total'] if stats['total'] else 0 + if pct < 0.6: + question_type_weaknesses[qt] = round(pct, 2) + + for topic, stats in topic_stats.items(): + pct = stats['correct'] / stats['total'] if stats['total'] else 0 + if pct < 0.6: + topic_weaknesses[topic] = round(pct, 2) + + user = request.env.user.sudo() + entity_id = user.entity_ids[0].id if user.entity_ids else False + + profile = GapProfile.create({ + 'student_id': uid, + 'source_type': 'placement', + 'source_id': session.id, + 'skill_gaps': json.dumps(skill_gaps), + 'question_type_weaknesses': json.dumps(question_type_weaknesses), + 'topic_weaknesses': json.dumps(topic_weaknesses), + 'entity_id': entity_id, + }) + + return _json_response({ + 'gap_profile_id': profile.id, + 'student_id': profile.student_id.id, + 'source_type': profile.source_type, + 'skill_gaps': json.loads(profile.skill_gaps) if profile.skill_gaps else {}, + 'question_type_weaknesses': json.loads(profile.question_type_weaknesses) if profile.question_type_weaknesses else {}, + 'topic_weaknesses': json.loads(profile.topic_weaknesses) if profile.topic_weaknesses else {}, + 'created_at': profile.created_at, + }) + + except Exception as e: + _logger.exception('gap_analysis failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/course/auto-generate + # ------------------------------------------------------------------ + @http.route('/api/course/auto-generate', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def auto_generate(self, **kw): + try: + body = _get_json_body() + uid = request.env.user.id + GapProfile = request.env['encoach.gap.profile'].sudo() + + gap_profile_id = body.get('gap_profile_id') + if gap_profile_id: + profile = GapProfile.browse(int(gap_profile_id)) + if not profile.exists(): + return _error_response('Gap profile not found', 404) + else: + profile = GapProfile.search([ + ('student_id', '=', uid), + ], limit=1, order='created_at desc') + if not profile: + return _error_response('No gap profile found. Run gap-analysis first.', 404) + + skill_gaps = json.loads(profile.skill_gaps) if profile.skill_gaps else {} + topic_weaknesses = json.loads(profile.topic_weaknesses) if profile.topic_weaknesses else {} + + Ability = request.env['encoach.student.ability.model'].sudo() + abilities = Ability.search([('student_id', '=', uid)]) + ability_map = {} + for ab in abilities: + ability_map[ab.skill] = ab.theta + + user = request.env.user.sudo() + entity_id = user.entity_ids[0].id if user.entity_ids else False + + Course = request.env['op.course'].sudo() + course_vals = { + 'name': f"Personalized Course for {user.name}", + 'code': f"AUTO_{uid}_{profile.id}", + } + if hasattr(Course, 'generation_source'): + course_vals['generation_source'] = 'auto_gap' + if hasattr(Course, 'gap_profile_id'): + course_vals['gap_profile_id'] = profile.id + if hasattr(Course, 'entity_id'): + course_vals['entity_id'] = entity_id + + course = Course.create(course_vals) + + Module = request.env['encoach.course.module'].sudo() + seq = 10 + created_modules = [] + + sorted_gaps = sorted(skill_gaps.items(), + key=lambda x: x[1].get('theta', 0) if isinstance(x[1], dict) else 0) + + for skill, gap_info in sorted_gaps: + theta = gap_info.get('theta', 0) if isinstance(gap_info, dict) else 0 + if theta < -0.5: + cefr = 'a1' + elif theta < 0.0: + cefr = 'a2' + elif theta < 0.5: + cefr = 'b1' + else: + cefr = 'b2' + + severity = gap_info.get('gap_severity', 'medium') if isinstance(gap_info, dict) else 'medium' + mod = Module.create({ + 'name': f"{skill.title()} Improvement ({severity})", + 'course_id': course.id, + 'cefr_target': cefr, + 'auto_generated': True, + 'generation_brief': json.dumps({ + 'skill': skill, + 'theta': theta, + 'severity': severity, + }), + 'completion_criteria': 'score_threshold', + 'score_threshold': PASS_THRESHOLD_DEFAULT, + 'sequence': seq, + 'status': 'available' if seq == 10 else 'locked', + }) + created_modules.append(mod) + seq += 10 + + for topic_name, accuracy in sorted(topic_weaknesses.items(), key=lambda x: x[1]): + mod = Module.create({ + 'name': f"Topic Review: {topic_name}", + 'course_id': course.id, + 'cefr_target': 'b1', + 'auto_generated': True, + 'generation_brief': json.dumps({ + 'topic': topic_name, + 'accuracy': accuracy, + }), + 'completion_criteria': 'all_resources', + 'sequence': seq, + 'status': 'locked', + }) + created_modules.append(mod) + seq += 10 + + if not created_modules: + Module.create({ + 'name': 'General Practice', + 'course_id': course.id, + 'cefr_target': 'b1', + 'auto_generated': True, + 'completion_criteria': 'all_resources', + 'sequence': 10, + 'status': 'available', + }) + + for i in range(1, len(created_modules)): + created_modules[i].write({ + 'prerequisite_module_id': created_modules[i - 1].id, + }) + + return _json_response(_course_to_dict(course), 201) + + except Exception as e: + _logger.exception('auto_generate failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/course/ + # ------------------------------------------------------------------ + @http.route('/api/course/', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def get(self, course_id, **kw): + try: + Course = request.env['op.course'].sudo() + course = Course.browse(course_id) + if not course.exists(): + return _error_response('Course not found', 404) + + data = _course_to_dict(course) + + uid = request.env.user.id + Progress = request.env.get('encoach.course.progress') + if Progress is not None: + Progress = Progress.sudo() + for mod in data.get('modules', []): + progress_recs = Progress.search([ + ('student_id', '=', uid), + ('module_id', '=', mod['id']), + ]) + mod['completed_resources'] = len( + progress_recs.filtered(lambda p: p.completed if hasattr(p, 'completed') else False) + ) + + return _json_response(data) + + except Exception as e: + _logger.exception('course get failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # PUT /api/course/ + # ------------------------------------------------------------------ + @http.route('/api/course/', type='http', auth='none', + methods=['PUT'], csrf=False) + @jwt_required + def update(self, course_id, **kw): + try: + body = _get_json_body() + Course = request.env['op.course'].sudo() + course = Course.browse(course_id) + if not course.exists(): + return _error_response('Course not found', 404) + + allowed_fields = {'name', 'description', 'code'} + vals = {} + for key in allowed_fields: + if key in body and hasattr(course, key): + vals[key] = body[key] + + if vals: + course.write(vals) + + module_order = body.get('module_order') + if module_order and isinstance(module_order, list): + Module = request.env['encoach.course.module'].sudo() + for idx, mod_id in enumerate(module_order): + mod = Module.browse(int(mod_id)) + if mod.exists() and mod.course_id.id == course.id: + mod.write({'sequence': (idx + 1) * 10}) + + return _json_response(_course_to_dict(course)) + + except Exception as e: + _logger.exception('course update failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/course//progress + # ------------------------------------------------------------------ + @http.route('/api/course//progress', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def progress(self, course_id, **kw): + try: + body = _get_json_body() + module_id = body.get('module_id') + resource_id = body.get('resource_id') + completed = body.get('completed', False) + + if not module_id: + return _error_response('module_id is required', 400) + + Module = request.env['encoach.course.module'].sudo() + mod = Module.browse(int(module_id)) + if not mod.exists() or mod.course_id.id != course_id: + return _error_response('Module not found in this course', 404) + + uid = request.env.user.id + + if mod.status == 'locked': + return _error_response('Module is locked', 400) + + if mod.status in ('locked', 'available'): + mod.write({'status': 'in_progress'}) + + Progress = request.env.get('encoach.course.progress') + if Progress is not None and resource_id: + Progress = Progress.sudo() + existing = Progress.search([ + ('student_id', '=', uid), + ('module_id', '=', mod.id), + ('resource_id', '=', int(resource_id)), + ], limit=1) + if existing: + existing.write({'completed': completed}) + else: + Progress.create({ + 'student_id': uid, + 'module_id': mod.id, + 'resource_id': int(resource_id), + 'completed': completed, + }) + + all_modules = Module.search([('course_id', '=', course_id)], order='sequence asc') + total_modules = len(all_modules) + completed_modules = len(all_modules.filtered(lambda m: m.status == 'completed')) + + module_status = mod.status + if mod.completion_criteria == 'all_resources' and Progress is not None: + Progress = request.env['encoach.course.progress'].sudo() + mod_progress = Progress.search([ + ('student_id', '=', uid), + ('module_id', '=', mod.id), + ]) + all_done = all( + getattr(p, 'completed', False) for p in mod_progress + ) if mod_progress else False + if all_done and mod_progress: + mod.write({'status': 'completed'}) + module_status = 'completed' + completed_modules += 1 + self._unlock_next_module(mod, all_modules) + + overall_pct = round((completed_modules / total_modules) * 100, 1) if total_modules else 0.0 + + return _json_response({ + 'module_id': mod.id, + 'module_status': module_status, + 'overall_progress_pct': overall_pct, + }) + + except Exception as e: + _logger.exception('progress failed') + return _error_response(str(e), 500) + + def _unlock_next_module(self, current_mod, all_modules): + """Unlock the next sequential module when the current one is completed.""" + Module = request.env['encoach.course.module'].sudo() + next_mods = Module.search([ + ('course_id', '=', current_mod.course_id.id), + ('prerequisite_module_id', '=', current_mod.id), + ('status', '=', 'locked'), + ]) + for nm in next_mods: + nm.write({'status': 'available'}) + + if not next_mods: + found_current = False + for m in all_modules: + if found_current and m.status == 'locked': + m.write({'status': 'available'}) + break + if m.id == current_mod.id: + found_current = True + + # ------------------------------------------------------------------ + # POST /api/course//checkpoint + # ------------------------------------------------------------------ + @http.route('/api/course//checkpoint', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def checkpoint(self, course_id, **kw): + try: + body = _get_json_body() + module_id = body.get('module_id') + score = body.get('score') + + if not module_id or score is None: + return _error_response('module_id and score are required', 400) + + score = float(score) + Module = request.env['encoach.course.module'].sudo() + mod = Module.browse(int(module_id)) + if not mod.exists() or mod.course_id.id != course_id: + return _error_response('Module not found in this course', 404) + + threshold = mod.score_threshold or PASS_THRESHOLD_DEFAULT + passed = score >= threshold + + all_modules = Module.search([('course_id', '=', course_id)], order='sequence asc') + next_module_id = None + action = 'continue' + + if passed: + mod.write({'status': 'completed'}) + self._unlock_next_module(mod, all_modules) + + next_mods = Module.search([ + ('course_id', '=', course_id), + ('status', 'in', ['available', 'locked']), + ('sequence', '>', mod.sequence), + ], limit=1, order='sequence asc') + if next_mods: + next_module_id = next_mods[0].id + action = 'next_module' + else: + action = 'course_complete' + elif score < STEP_DOWN_THRESHOLD: + action = 'remediation' + else: + action = 'retry' + + return _json_response({ + 'module_id': mod.id, + 'score': score, + 'threshold': threshold, + 'passed': passed, + 'next_module_id': next_module_id, + 'action': action, + }) + + except Exception as e: + _logger.exception('checkpoint failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/course//post-test + # ------------------------------------------------------------------ + @http.route('/api/course//post-test', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def post_test(self, course_id, **kw): + try: + Course = request.env['op.course'].sudo() + course = Course.browse(course_id) + if not course.exists(): + return _error_response('Course not found', 404) + + Module = request.env['encoach.course.module'].sudo() + all_modules = Module.search([('course_id', '=', course_id)]) + incomplete = all_modules.filtered(lambda m: m.status != 'completed') + if incomplete: + return _error_response( + f'{len(incomplete)} module(s) not completed. Finish all modules first.', 400 + ) + + subject_id = course.subject_id.id if hasattr(course, 'subject_id') and course.subject_id else False + + ExamCustom = request.env['encoach.exam.custom'].sudo() + exam = None + if subject_id: + exam = ExamCustom.search([ + ('subject_id', '=', subject_id), + ('status', '=', 'published'), + ], limit=1, order='id desc') + + if not exam: + exam = ExamCustom.search([ + ('status', '=', 'published'), + ], limit=1, order='id desc') + + if not exam: + return _error_response('No published exam found for post-test', 404) + + Assignment = request.env['encoach.exam.assignment'].sudo() + uid = request.env.user.id + + existing = Assignment.search([ + ('exam_id', '=', exam.id), + ('student_id', '=', uid), + ('status', '=', 'assigned'), + ], limit=1) + if existing: + return _json_response({'exam_assignment_id': existing.id}) + + assignment = Assignment.create({ + 'exam_id': exam.id, + 'student_id': uid, + 'status': 'assigned', + }) + + return _json_response({'exam_assignment_id': assignment.id}, 201) + + except Exception as e: + _logger.exception('post_test failed') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_course_gen/models/__init__.py b/custom_addons/encoach_course_gen/models/__init__.py new file mode 100644 index 00000000..2fc57bb4 --- /dev/null +++ b/custom_addons/encoach_course_gen/models/__init__.py @@ -0,0 +1,4 @@ +from . import gap_profile +from . import course_extension +from . import course_section +from . import course_module diff --git a/custom_addons/encoach_course_gen/models/course_extension.py b/custom_addons/encoach_course_gen/models/course_extension.py new file mode 100644 index 00000000..158d10d2 --- /dev/null +++ b/custom_addons/encoach_course_gen/models/course_extension.py @@ -0,0 +1,21 @@ +from odoo import models, fields + + +class OpCourseExtension(models.Model): + _inherit = 'op.course' + + generation_source = fields.Selection([ + ('manual', 'Manual'), + ('auto_gap', 'Auto from Gap'), + ('ai_english', 'AI English'), + ('ai_ielts', 'AI IELTS'), + ]) + gap_profile_id = fields.Many2one('encoach.gap.profile', ondelete='set null') + progression_model = fields.Selection([ + ('linear', 'Linear'), + ('parallel', 'Parallel'), + ('adaptive', 'Adaptive'), + ], default='linear') + target_band = fields.Float() + study_hours_week = fields.Integer() + entity_id = fields.Many2one('encoach.entity', ondelete='set null') diff --git a/custom_addons/encoach_course_gen/models/course_module.py b/custom_addons/encoach_course_gen/models/course_module.py new file mode 100644 index 00000000..a6458cc9 --- /dev/null +++ b/custom_addons/encoach_course_gen/models/course_module.py @@ -0,0 +1,37 @@ +from odoo import models, fields + + +class EncoachCourseModule(models.Model): + _name = 'encoach.course.module' + _description = 'Course Module' + + name = fields.Char(size=200, required=True) + course_id = fields.Many2one('op.course', required=True, ondelete='cascade') + cefr_target = fields.Selection([ + ('pre_a1', 'Pre-A1'), + ('a1', 'A1'), + ('a2', 'A2'), + ('b1', 'B1'), + ('b2', 'B2'), + ('c1', 'C1'), + ('c2', 'C2'), + ]) + auto_generated = fields.Boolean(default=False) + generation_brief = fields.Text() + completion_criteria = fields.Selection([ + ('all_resources', 'All Resources'), + ('score_threshold', 'Score Threshold'), + ('teacher_approval', 'Teacher Approval'), + ], default='all_resources') + score_threshold = fields.Float() + prerequisite_module_id = fields.Many2one('encoach.course.module', ondelete='set null') + status = fields.Selection([ + ('locked', 'Locked'), + ('available', 'Available'), + ('in_progress', 'In Progress'), + ('completed', 'Completed'), + ('skipped', 'Skipped'), + ], default='locked', required=True) + sequence = fields.Integer(default=10) + section_id = fields.Many2one('encoach.course.section', ondelete='set null') + resource_ids = fields.Many2many('encoach.resource', string='Resources') diff --git a/custom_addons/encoach_course_gen/models/course_section.py b/custom_addons/encoach_course_gen/models/course_section.py new file mode 100644 index 00000000..8f71142c --- /dev/null +++ b/custom_addons/encoach_course_gen/models/course_section.py @@ -0,0 +1,21 @@ +from odoo import models, fields + + +class EncoachCourseSection(models.Model): + _name = 'encoach.course.section' + _description = 'Course Section' + _order = 'sequence' + + name = fields.Char(size=200, required=True) + course_id = fields.Many2one('op.course', required=True, ondelete='cascade') + skill = fields.Selection([ + ('listening', 'Listening'), + ('reading', 'Reading'), + ('writing', 'Writing'), + ('speaking', 'Speaking'), + ('grammar', 'Grammar'), + ('vocabulary', 'Vocabulary'), + ('overall', 'Overall'), + ]) + sequence = fields.Integer(default=10) + module_ids = fields.One2many('encoach.course.module', 'section_id', string='Modules') diff --git a/custom_addons/encoach_course_gen/models/gap_profile.py b/custom_addons/encoach_course_gen/models/gap_profile.py new file mode 100644 index 00000000..82bf0123 --- /dev/null +++ b/custom_addons/encoach_course_gen/models/gap_profile.py @@ -0,0 +1,18 @@ +from odoo import models, fields + + +class EncoachGapProfile(models.Model): + _name = 'encoach.gap.profile' + _description = 'Student Gap Profile' + + student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True) + source_type = fields.Selection([ + ('placement', 'Placement'), + ('exam', 'Exam'), + ], required=True) + source_id = fields.Integer() + skill_gaps = fields.Text() + question_type_weaknesses = fields.Text() + topic_weaknesses = fields.Text() + entity_id = fields.Many2one('encoach.entity', ondelete='set null') + created_at = fields.Datetime(default=fields.Datetime.now) diff --git a/custom_addons/encoach_course_gen/security/ir.model.access.csv b/custom_addons/encoach_course_gen/security/ir.model.access.csv new file mode 100644 index 00000000..0bbae761 --- /dev/null +++ b/custom_addons/encoach_course_gen/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_gap_profile_user,encoach.gap.profile.user,model_encoach_gap_profile,base.group_user,1,1,1,1 +access_encoach_course_module_user,encoach.course.module.user,model_encoach_course_module,base.group_user,1,1,1,1 +access_encoach_course_section_user,encoach.course.section.user,model_encoach_course_section,base.group_user,1,1,1,1 diff --git a/custom_addons/encoach_course_gen/static/src/css/gap_analysis.css b/custom_addons/encoach_course_gen/static/src/css/gap_analysis.css new file mode 100644 index 00000000..8de7b7fd --- /dev/null +++ b/custom_addons/encoach_course_gen/static/src/css/gap_analysis.css @@ -0,0 +1,40 @@ +.o_gap_analysis .ga-bar-track { + height: 28px; + background: #e9ecef; + border-radius: 0.5rem; + overflow: hidden; + position: relative; +} +.o_gap_analysis .ga-bar-fill { + height: 100%; + border-radius: 0.5rem; + transition: width 0.6s ease; + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: 8px; + min-width: 0; +} +.o_gap_analysis .ga-bar-label { + color: #fff; + font-size: 0.75rem; + font-weight: 600; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} +.o_gap_analysis .ga-bar-row { + padding: 4px 0; +} +.o_gap_analysis .ga-student-card { + transition: transform 0.15s, box-shadow 0.15s; + cursor: pointer; +} +.o_gap_analysis .ga-student-card:hover { + transform: translateY(-2px); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important; +} +.o_gap_analysis .card { + border-radius: 0.5rem; +} +.o_gap_analysis .cursor-pointer { + cursor: pointer; +} diff --git a/custom_addons/encoach_course_gen/static/src/js/gap_analysis.js b/custom_addons/encoach_course_gen/static/src/js/gap_analysis.js new file mode 100644 index 00000000..faa31fb9 --- /dev/null +++ b/custom_addons/encoach_course_gen/static/src/js/gap_analysis.js @@ -0,0 +1,162 @@ +/** @odoo-module */ + +import { Component, onWillStart, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { Layout } from "@web/search/layout"; + +class GapAnalysisVisualization extends Component { + static template = "encoach_course_gen.GapAnalysis"; + static components = { Layout }; + static props = ["*"]; + + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.state = useState({ + loading: true, + studentId: null, + student: null, + gapProfile: null, + skillGaps: [], + weakTopics: [], + recommendations: [], + }); + + onWillStart(async () => { + const ctx = this.props.action?.context || {}; + this.state.studentId = ctx.student_id || ctx.active_id || null; + if (this.state.studentId) { + await this.loadGapData(); + } else { + await this.loadStudentList(); + } + }); + } + + async loadStudentList() { + try { + this.state.studentList = await this.orm.searchRead( + "res.users", + [["account_source", "!=", false]], + ["name", "email", "login"], + { limit: 50, order: "name" }, + ); + } catch (e) { + console.error("Failed to load students:", e); + } finally { + this.state.loading = false; + } + } + + async selectStudent(studentId) { + this.state.studentId = studentId; + this.state.loading = true; + await this.loadGapData(); + } + + async loadGapData() { + this.state.loading = true; + try { + const [profiles, students] = await Promise.all([ + this.orm.searchRead( + "encoach.gap.profile", + [["student_id", "=", this.state.studentId]], + ["student_id", "source_type", "skill_gaps", "question_type_weaknesses", "topic_weaknesses", "created_at"], + { limit: 1, order: "created_at desc" }, + ), + this.orm.searchRead( + "res.users", + [["id", "=", this.state.studentId]], + ["name", "email"], + ), + ]); + + this.state.student = students.length ? students[0] : null; + this.state.gapProfile = profiles.length ? profiles[0] : null; + + if (this.state.gapProfile) { + this._parseGapProfile(this.state.gapProfile); + } + } catch (e) { + console.error("Gap analysis load error:", e); + } finally { + this.state.loading = false; + } + } + + _parseGapProfile(profile) { + this.state.skillGaps = this._safeParseJson(profile.skill_gaps, []); + if (!Array.isArray(this.state.skillGaps)) { + const obj = this.state.skillGaps; + this.state.skillGaps = Object.entries(obj).map(([skill, data]) => ({ + skill, + current: typeof data === "object" ? (data.current || 0) : data, + target: typeof data === "object" ? (data.target || 100) : 100, + gap: typeof data === "object" ? (data.gap || (data.target || 100) - (data.current || 0)) : (100 - data), + })); + } + + const topicRaw = this._safeParseJson(profile.topic_weaknesses, []); + if (Array.isArray(topicRaw)) { + this.state.weakTopics = topicRaw; + } else if (typeof topicRaw === "object") { + this.state.weakTopics = Object.entries(topicRaw).map(([name, score]) => ({ + name, + score: typeof score === "number" ? score : 0, + })); + } + + this.state.recommendations = this._generateRecommendations(); + } + + _safeParseJson(val, fallback) { + if (!val) return fallback; + if (typeof val === "object") return val; + try { return JSON.parse(val); } catch { return fallback; } + } + + _generateRecommendations() { + const recs = []; + for (const gap of this.state.skillGaps) { + const pct = gap.target > 0 ? (gap.current / gap.target) * 100 : 0; + if (pct < 50) { + recs.push({ icon: "fa-exclamation-triangle", color: "danger", text: `Critical gap in ${gap.skill} — intensive practice recommended` }); + } else if (pct < 75) { + recs.push({ icon: "fa-arrow-up", color: "warning", text: `${gap.skill} needs improvement — targeted exercises suggested` }); + } + } + if (this.state.weakTopics.length > 0) { + recs.push({ icon: "fa-book", color: "info", text: `Focus on ${this.state.weakTopics.length} weak topic(s) identified in the analysis` }); + } + if (recs.length === 0) { + recs.push({ icon: "fa-thumbs-up", color: "success", text: "Student performance is on track across all areas" }); + } + return recs; + } + + barWidth(current, target) { + if (!target || target <= 0) return 0; + return Math.min(100, Math.round((current / target) * 100)); + } + + barColor(current, target) { + const pct = target > 0 ? (current / target) * 100 : 0; + if (pct >= 75) return "bg-success"; + if (pct >= 50) return "bg-warning"; + return "bg-danger"; + } + + goBack() { + this.state.studentId = null; + this.state.student = null; + this.state.gapProfile = null; + this.state.skillGaps = []; + this.state.weakTopics = []; + this.state.recommendations = []; + this.state.loading = true; + this.loadStudentList(); + } +} + +registry.category("actions").add("encoach_gap_analysis", GapAnalysisVisualization); diff --git a/custom_addons/encoach_course_gen/static/src/xml/gap_analysis.xml b/custom_addons/encoach_course_gen/static/src/xml/gap_analysis.xml new file mode 100644 index 00000000..71b8522f --- /dev/null +++ b/custom_addons/encoach_course_gen/static/src/xml/gap_analysis.xml @@ -0,0 +1,170 @@ + + + + +
+
+ + +
+ +

Analyzing gaps...

+
+
+ + + +

Gap Analysis

+

Select a student to view their skill gap analysis.

+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+
+ + + +
+ +
+

Gap Analysis

+ + Student: + + — Source: + + +
+
+ + +
+ + No gap profile found for this student. A placement test or exam is required first. +
+
+ + + +
+
+
+ + Skill Gaps +
+
+
+ + +
+
+ + + / + +
+
+
+ + % + +
+
+
+
+
+ +

No skill gap data available.

+
+
+
+ +
+ +
+
+
+
+ + Weak Topics +
+
+
+ + +
+ + + + + + % + +
+
+
+ +

+ + No weak topics identified +

+
+
+
+
+ + +
+
+
+
+ + Recommended Actions +
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+
+ + +
+ +

No student selected. Open from a student record or select one above.

+
+
+
+
+
+
+
diff --git a/custom_addons/encoach_course_gen/views/course_gen_menus.xml b/custom_addons/encoach_course_gen/views/course_gen_menus.xml new file mode 100644 index 00000000..46dbff3a --- /dev/null +++ b/custom_addons/encoach_course_gen/views/course_gen_menus.xml @@ -0,0 +1,28 @@ + + + + + All Courses + op.course + list,form + + + + + + + + + diff --git a/custom_addons/encoach_course_gen/views/course_module_views.xml b/custom_addons/encoach_course_gen/views/course_module_views.xml new file mode 100644 index 00000000..4fe5faba --- /dev/null +++ b/custom_addons/encoach_course_gen/views/course_module_views.xml @@ -0,0 +1,74 @@ + + + + + encoach.course.module.form + encoach.course.module + +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+
+ + + encoach.course.module.list + encoach.course.module + + + + + + + + + + + + + encoach.course.module.search + encoach.course.module + + + + + + + + + + + + + + + + + + Course Modules + encoach.course.module + list,form + + +
diff --git a/custom_addons/encoach_course_gen/views/course_section_views.xml b/custom_addons/encoach_course_gen/views/course_section_views.xml new file mode 100644 index 00000000..4397a844 --- /dev/null +++ b/custom_addons/encoach_course_gen/views/course_section_views.xml @@ -0,0 +1,68 @@ + + + + + encoach.course.section.form + encoach.course.section + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + encoach.course.section.list + encoach.course.section + + + + + + + + + + + + encoach.course.section.search + encoach.course.section + + + + + + + + + + + + + Course Sections + encoach.course.section + list,form + + +
diff --git a/custom_addons/encoach_course_gen/views/gap_analysis_action.xml b/custom_addons/encoach_course_gen/views/gap_analysis_action.xml new file mode 100644 index 00000000..9d3b99b5 --- /dev/null +++ b/custom_addons/encoach_course_gen/views/gap_analysis_action.xml @@ -0,0 +1,15 @@ + + + + + Gap Analysis + encoach_gap_analysis + + + + + diff --git a/custom_addons/encoach_course_gen/views/gap_profile_views.xml b/custom_addons/encoach_course_gen/views/gap_profile_views.xml new file mode 100644 index 00000000..fce46286 --- /dev/null +++ b/custom_addons/encoach_course_gen/views/gap_profile_views.xml @@ -0,0 +1,72 @@ + + + + + encoach.gap.profile.form + encoach.gap.profile + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + encoach.gap.profile.list + encoach.gap.profile + + + + + + + + + + + + encoach.gap.profile.search + encoach.gap.profile + + + + + + + + + + + + + + + Gap Analysis + encoach.gap.profile + list,form + + +
diff --git a/custom_addons/encoach_entity_onboard/__init__.py b/custom_addons/encoach_entity_onboard/__init__.py new file mode 100644 index 00000000..8345aefb --- /dev/null +++ b/custom_addons/encoach_entity_onboard/__init__.py @@ -0,0 +1,2 @@ +from . import services +from . import controllers diff --git a/custom_addons/encoach_entity_onboard/__manifest__.py b/custom_addons/encoach_entity_onboard/__manifest__.py new file mode 100644 index 00000000..80572915 --- /dev/null +++ b/custom_addons/encoach_entity_onboard/__manifest__.py @@ -0,0 +1,15 @@ +{ + 'name': 'EnCoach Entity Onboarding', + 'version': '19.0.1.0', + 'category': 'Education', + 'summary': 'Entity student CSV bulk upload, account creation, credential management', + 'author': 'EnCoach', + 'license': 'LGPL-3', + 'depends': ['encoach_core'], + 'data': [ + 'security/ir.model.access.csv', + ], + 'installable': True, + 'application': False, + 'auto_install': False, +} diff --git a/custom_addons/encoach_entity_onboard/controllers/__init__.py b/custom_addons/encoach_entity_onboard/controllers/__init__.py new file mode 100644 index 00000000..6e6c5bf7 --- /dev/null +++ b/custom_addons/encoach_entity_onboard/controllers/__init__.py @@ -0,0 +1 @@ +from . import entity_onboard diff --git a/custom_addons/encoach_entity_onboard/controllers/entity_onboard.py b/custom_addons/encoach_entity_onboard/controllers/entity_onboard.py new file mode 100644 index 00000000..a271b4ad --- /dev/null +++ b/custom_addons/encoach_entity_onboard/controllers/entity_onboard.py @@ -0,0 +1,292 @@ +import json +import logging +import re +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__) + +EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$') + + +class EncoachEntityOnboardController(http.Controller): + + # ------------------------------------------------------------------ + # POST /api/entity/students/validate-csv + # ------------------------------------------------------------------ + @http.route('/api/entity/students/validate-csv', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def validate_csv(self, **kw): + try: + uploaded = request.httprequest.files.get('file') + if not uploaded: + return _error_response('CSV file is required (multipart field "file")', 400) + + raw = uploaded.read() + from odoo.addons.encoach_entity_onboard.services.csv_parser import CsvParser + parser = CsvParser() + result = parser.validate(raw) + + valid_rows = result['valid_rows'] + parse_errors = result['errors'] + + row_errors = [] + for err in parse_errors: + row_errors.append({'row': 0, 'field': '', 'message': err}) + + User = request.env['res.users'].sudo() + dedup_errors = [] + for idx, row in enumerate(valid_rows): + email = row.get('email', '') + if email and not EMAIL_RE.match(email): + row_errors.append({ + 'row': idx + 2, + 'field': 'email', + 'message': f"Invalid email format: {email}", + }) + continue + + if email: + existing = User.search([('login', '=', email)], limit=1) + if existing: + dedup_errors.append({ + 'row': idx + 2, + 'field': 'email', + 'message': f"Duplicate email already exists in DB: {email}", + }) + + all_errors = row_errors + dedup_errors + error_count = len(all_errors) + truly_valid = [ + r for i, r in enumerate(valid_rows) + if not any(e['row'] == i + 2 for e in all_errors) + ] + + preview = truly_valid[:5] + + return _json_response({ + 'valid_count': len(truly_valid), + 'error_count': error_count, + 'errors': all_errors, + 'preview': preview, + }) + + except Exception as e: + _logger.exception('validate_csv failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/entity/students/bulk-create + # ------------------------------------------------------------------ + @http.route('/api/entity/students/bulk-create', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def bulk_create(self, **kw): + try: + body = _get_json_body() + rows = body.get('rows', []) + entity_id = body.get('entity_id') + + if not rows: + return _error_response('rows array is required', 400) + if not entity_id: + return _error_response('entity_id is required', 400) + + entity_id = int(entity_id) + Entity = request.env['encoach.entity'].sudo() + entity = Entity.browse(entity_id) + if not entity.exists(): + return _error_response('Entity not found', 404) + + from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService + service = CredentialService() + created_users = service.create_accounts(request.env, rows, entity_id) + + return _json_response({ + 'created_count': len(created_users), + 'user_ids': created_users.ids, + }, 201) + + except Exception as e: + _logger.exception('bulk_create failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/entity/students/send-credentials + # ------------------------------------------------------------------ + @http.route('/api/entity/students/send-credentials', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def send_credentials(self, **kw): + try: + body = _get_json_body() + user_ids = body.get('user_ids', []) + if not user_ids: + return _error_response('user_ids array is required', 400) + + user_ids = [int(uid) for uid in user_ids] + + from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService + service = CredentialService() + service.send_credentials(request.env, user_ids) + + return _json_response({'sent_count': len(user_ids)}) + + except Exception as e: + _logger.exception('send_credentials failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/entity/students//resend-credentials + # ------------------------------------------------------------------ + @http.route('/api/entity/students//resend-credentials', + type='http', auth='none', methods=['POST'], csrf=False) + @jwt_required + def resend_credentials(self, student_id, **kw): + try: + User = request.env['res.users'].sudo() + user = User.browse(student_id) + if not user.exists(): + return _error_response('Student not found', 404) + + from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService + service = CredentialService() + service.resend_credentials(request.env, student_id) + + return _json_response({'sent': True}) + + except Exception as e: + _logger.exception('resend_credentials failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/entity/students/resend-all-pending + # ------------------------------------------------------------------ + @http.route('/api/entity/students/resend-all-pending', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def resend_all_pending(self, **kw): + try: + user = request.env.user.sudo() + domain = [ + ('first_login', '=', True), + ('account_source', '=', 'entity_bulk_upload'), + ] + + if user.entity_ids: + domain.append(('entity_ids', 'in', user.entity_ids.ids)) + + User = request.env['res.users'].sudo() + pending_users = User.search(domain) + + if not pending_users: + return _json_response({'sent_count': 0}) + + from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService + service = CredentialService() + service.send_credentials(request.env, pending_users.ids) + + return _json_response({'sent_count': len(pending_users)}) + + except Exception as e: + _logger.exception('resend_all_pending failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/entity/students/sis-import + # ------------------------------------------------------------------ + @http.route('/api/entity/students/sis-import', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def sis_import(self, **kw): + try: + sis_data = None + uploaded = request.httprequest.files.get('file') + if uploaded: + raw = uploaded.read() + try: + sis_data = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return _error_response('Invalid JSON file', 400) + else: + body = _get_json_body() + sis_data = body.get('sis_data', []) + + if not sis_data or not isinstance(sis_data, list): + return _error_response('sis_data must be a non-empty JSON array', 400) + + user = request.env.user.sudo() + entity_id = None + if user.entity_ids: + entity_id = user.entity_ids[0].id + + User = request.env['res.users'].sudo() + imported_count = 0 + updated_count = 0 + errors = [] + + for idx, record in enumerate(sis_data): + email = (record.get('email') or '').strip() + name = (record.get('name') or '').strip() + national_id = (record.get('national_id') or '').strip() + phone = (record.get('phone') or '').strip() + + if not email or not name: + errors.append({ + 'index': idx, + 'message': 'name and email are required', + }) + continue + + if not EMAIL_RE.match(email): + errors.append({ + 'index': idx, + 'message': f'Invalid email: {email}', + }) + continue + + try: + existing = User.search([('login', '=', email)], limit=1) + if existing: + update_vals = {'name': name} + if phone: + update_vals['phone'] = phone + existing.write(update_vals) + if entity_id and hasattr(existing, 'entity_ids'): + if entity_id not in existing.entity_ids.ids: + existing.write({'entity_ids': [(4, entity_id)]}) + updated_count += 1 + else: + create_vals = { + 'name': name, + 'login': email, + 'email': email, + 'phone': phone, + 'password': national_id or email.split('@')[0], + 'account_source': 'entity_bulk_upload', + 'account_status': 'activated', + } + if entity_id: + create_vals['entity_id'] = entity_id + User.create(create_vals) + imported_count += 1 + except Exception as rec_err: + errors.append({ + 'index': idx, + 'message': str(rec_err), + }) + _logger.warning('SIS import error for row %d: %s', idx, rec_err) + + return _json_response({ + 'imported_count': imported_count, + 'updated_count': updated_count, + 'errors': errors, + }) + + except Exception as e: + _logger.exception('sis_import failed') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_entity_onboard/security/ir.model.access.csv b/custom_addons/encoach_entity_onboard/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/custom_addons/encoach_entity_onboard/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/custom_addons/encoach_entity_onboard/services/__init__.py b/custom_addons/encoach_entity_onboard/services/__init__.py new file mode 100644 index 00000000..dd61771c --- /dev/null +++ b/custom_addons/encoach_entity_onboard/services/__init__.py @@ -0,0 +1,2 @@ +from . import csv_parser +from . import credential_service diff --git a/custom_addons/encoach_entity_onboard/services/credential_service.py b/custom_addons/encoach_entity_onboard/services/credential_service.py new file mode 100644 index 00000000..979fa23b --- /dev/null +++ b/custom_addons/encoach_entity_onboard/services/credential_service.py @@ -0,0 +1,72 @@ +import logging + +_logger = logging.getLogger(__name__) + + +class CredentialService: + + def create_accounts(self, env, valid_rows, entity_id): + """Create res.users records from validated CSV rows. + + Each user gets password=national_id, account_source='entity_bulk_upload', + and account_status='activated'. + + Args: + env: Odoo environment. + valid_rows: list of dicts with keys name, email, national_id, phone. + entity_id: int, the entity that owns these students. + + Returns: + recordset of created res.users. + """ + User = env['res.users'] + created_users = User + for row in valid_rows: + try: + user = User.sudo().create({ + 'name': row['name'], + 'login': row['email'], + 'email': row['email'], + 'phone': row.get('phone', ''), + 'password': row['national_id'], + 'account_source': 'entity_bulk_upload', + 'account_status': 'activated', + 'entity_id': entity_id, + }) + created_users |= user + except Exception: + _logger.exception("Failed to create account for %s", row.get('email')) + return created_users + + def send_credentials(self, env, user_ids): + """Send credential emails to newly created users. + + Args: + env: Odoo environment. + user_ids: list of int, res.users IDs. + """ + Mail = env['mail.mail'] + for user in env['res.users'].sudo().browse(user_ids): + mail_values = { + 'subject': 'Your EnCoach Account Credentials', + 'email_from': env.company.email or 'noreply@encoach.com', + 'email_to': user.email, + 'body_html': ( + f'

Dear {user.name},

' + f'

Your EnCoach account has been created.

' + f'

Login: {user.login}

' + f'

Password: Your National ID

' + f'

Please change your password after first login.

' + ), + } + mail = Mail.sudo().create(mail_values) + mail.send() + + def resend_credentials(self, env, user_id): + """Resend credential email for a single user. + + Args: + env: Odoo environment. + user_id: int, res.users ID. + """ + self.send_credentials(env, [user_id]) diff --git a/custom_addons/encoach_entity_onboard/services/csv_parser.py b/custom_addons/encoach_entity_onboard/services/csv_parser.py new file mode 100644 index 00000000..44d7d577 --- /dev/null +++ b/custom_addons/encoach_entity_onboard/services/csv_parser.py @@ -0,0 +1,69 @@ +import csv +import io + + +REQUIRED_COLUMNS = {'name', 'email', 'national_id', 'phone'} + + +class CsvParser: + + def validate(self, file_content): + """Parse CSV content and validate required columns. + + Args: + file_content: raw CSV string or bytes. + + Returns: + dict with 'valid_rows' (list of dicts) and 'errors' (list of str). + """ + if isinstance(file_content, bytes): + file_content = file_content.decode('utf-8-sig') + + errors = [] + valid_rows = [] + + reader = csv.DictReader(io.StringIO(file_content)) + + if not reader.fieldnames: + return {'valid_rows': [], 'errors': ['Empty CSV file or missing header row']} + + header_set = {col.strip().lower() for col in reader.fieldnames} + missing = REQUIRED_COLUMNS - header_set + if missing: + return { + 'valid_rows': [], + 'errors': [f"Missing required columns: {', '.join(sorted(missing))}"], + } + + for idx, row in enumerate(reader, start=2): + parsed = self.parse_row(row) + row_errors = [] + + if not parsed.get('name'): + row_errors.append(f"Row {idx}: 'name' is required") + if not parsed.get('email'): + row_errors.append(f"Row {idx}: 'email' is required") + if not parsed.get('national_id'): + row_errors.append(f"Row {idx}: 'national_id' is required") + + if row_errors: + errors.extend(row_errors) + else: + valid_rows.append(parsed) + + return {'valid_rows': valid_rows, 'errors': errors} + + def parse_row(self, row): + """Normalize a single CSV row. + + Args: + row: dict from csv.DictReader. + + Returns: + dict with trimmed, normalized values. + """ + normalized = {} + for key, value in row.items(): + clean_key = key.strip().lower() + normalized[clean_key] = value.strip() if value else '' + return normalized diff --git a/custom_addons/encoach_exam_session/__init__.py b/custom_addons/encoach_exam_session/__init__.py new file mode 100644 index 00000000..e046e49f --- /dev/null +++ b/custom_addons/encoach_exam_session/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/custom_addons/encoach_exam_session/__manifest__.py b/custom_addons/encoach_exam_session/__manifest__.py new file mode 100644 index 00000000..793b2957 --- /dev/null +++ b/custom_addons/encoach_exam_session/__manifest__.py @@ -0,0 +1,34 @@ +{ + 'name': 'EnCoach Exam Session', + 'version': '19.0.1.0', + 'category': 'Education', + 'summary': 'POS-inspired full-screen exam delivery with timer, auto-save, and question renderers', + 'author': 'EnCoach', + 'license': 'LGPL-3', + 'depends': [ + 'base', + 'web', + 'encoach_core', + 'encoach_scoring', + 'encoach_exam_template', + 'encoach_placement', + ], + 'data': [ + 'security/ir.model.access.csv', + 'views/exam_session_templates.xml', + ], + 'assets': { + 'web.assets_frontend': [ + 'encoach_exam_session/static/src/css/exam_session.css', + 'encoach_exam_session/static/src/js/question_renderers.js', + 'encoach_exam_session/static/src/js/exam_app.js', + 'encoach_exam_session/static/src/js/placement_app.js', + 'encoach_exam_session/static/src/xml/question_renderers.xml', + 'encoach_exam_session/static/src/xml/exam_app.xml', + 'encoach_exam_session/static/src/xml/placement_app.xml', + ], + }, + 'installable': True, + 'application': False, + 'auto_install': False, +} diff --git a/custom_addons/encoach_exam_session/controllers/__init__.py b/custom_addons/encoach_exam_session/controllers/__init__.py new file mode 100644 index 00000000..2ae3f29d --- /dev/null +++ b/custom_addons/encoach_exam_session/controllers/__init__.py @@ -0,0 +1 @@ +from . import exam_session diff --git a/custom_addons/encoach_exam_session/controllers/exam_session.py b/custom_addons/encoach_exam_session/controllers/exam_session.py new file mode 100644 index 00000000..603d00fd --- /dev/null +++ b/custom_addons/encoach_exam_session/controllers/exam_session.py @@ -0,0 +1,43 @@ +import logging +import time + +import jwt as pyjwt + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class ExamSessionController(http.Controller): + + def _generate_exam_jwt(self, user_id): + secret = request.env['ir.config_parameter'].sudo().get_param('encoach.jwt_secret') + if not secret: + return '' + return pyjwt.encode( + {'user_id': user_id, 'exp': int(time.time()) + 7200}, + secret, algorithm='HS256', + ) + + @http.route('/exam/session/', type='http', + auth='user', website=False) + def exam_session(self, attempt_id, **kw): + attempt = request.env['encoach.student.attempt'].sudo().browse(attempt_id) + if not attempt.exists() or attempt.student_id.id != request.env.user.id: + return request.not_found() + token = self._generate_exam_jwt(request.env.user.id) + return request.render('encoach_exam_session.exam_session_page', { + 'attempt': attempt, + 'session_token': token, + }) + + @http.route('/placement/test/', type='http', + auth='user', website=True) + def placement_test(self, session_id, **kw): + session = request.env['encoach.cat.session'].sudo().browse(session_id) + if not session.exists() or session.student_id.id != request.env.user.id: + return request.not_found() + return request.render('encoach_exam_session.placement_session_page', { + 'cat_session': session, + }) diff --git a/custom_addons/encoach_exam_session/security/ir.model.access.csv b/custom_addons/encoach_exam_session/security/ir.model.access.csv new file mode 100644 index 00000000..97dd8b91 --- /dev/null +++ b/custom_addons/encoach_exam_session/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/custom_addons/encoach_exam_session/static/src/css/exam_session.css b/custom_addons/encoach_exam_session/static/src/css/exam_session.css new file mode 100644 index 00000000..95ea4327 --- /dev/null +++ b/custom_addons/encoach_exam_session/static/src/css/exam_session.css @@ -0,0 +1,1241 @@ +/* ═══════════════════════════════════════════════════════════════ + EnCoach Exam Session — Full-screen POS-inspired styling + ═══════════════════════════════════════════════════════════════ */ + +:root { + --ec-primary: #1b6ef3; + --ec-primary-dark: #1557c0; + --ec-danger: #e74c3c; + --ec-danger-dark: #c0392b; + --ec-success: #27ae60; + --ec-warning: #f39c12; + --ec-gray-50: #f8f9fa; + --ec-gray-100: #f0f1f3; + --ec-gray-200: #e2e4e8; + --ec-gray-300: #ccd0d5; + --ec-gray-500: #8b919a; + --ec-gray-700: #495057; + --ec-gray-900: #1a1d23; + --ec-topbar-bg: #1a1d23; + --ec-topbar-height: 56px; + --ec-bottombar-height: 64px; + --ec-radius: 8px; + --ec-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + --ec-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12); +} + +/* ── Fullscreen Base ──────────────────────────────────────────── */ + +.ec-exam-fullscreen { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + flex-direction: column; + background: var(--ec-gray-50); + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + overflow: hidden; +} + +.ec-exam-fullscreen * { + box-sizing: border-box; +} + +/* ── Loading Spinner ──────────────────────────────────────────── */ + +.ec-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + gap: 16px; +} + +.ec-spinner { + width: 48px; + height: 48px; + border: 4px solid var(--ec-gray-200); + border-top-color: var(--ec-primary); + border-radius: 50%; + animation: ec-spin 0.8s linear infinite; +} + +@keyframes ec-spin { + to { transform: rotate(360deg); } +} + +.ec-loading-text { + font-size: 16px; + color: var(--ec-gray-500); + font-weight: 500; +} + +/* ── Submitted Overlay ────────────────────────────────────────── */ + +.ec-submitted-overlay { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + gap: 20px; + background: var(--ec-gray-50); +} + +.ec-submitted-icon { + width: 80px; + height: 80px; + border-radius: 50%; + background: var(--ec-success); + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + color: #fff; + animation: ec-pop 0.4s ease; +} + +@keyframes ec-pop { + 0% { transform: scale(0); } + 70% { transform: scale(1.15); } + 100% { transform: scale(1); } +} + +.ec-submitted-text { + font-size: 20px; + color: var(--ec-gray-700); + font-weight: 600; +} + +/* ── Top Bar ──────────────────────────────────────────────────── */ + +.ec-topbar { + height: var(--ec-topbar-height); + background: var(--ec-topbar-bg); + display: flex; + align-items: center; + padding: 0 20px; + gap: 16px; + flex-shrink: 0; + z-index: 100; +} + +.ec-topbar-title { + color: #fff; + font-size: 16px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 280px; +} + +.ec-section-tabs { + display: flex; + gap: 4px; + margin-left: 16px; +} + +.ec-section-tab { + padding: 6px 16px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.6); + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.15); + cursor: pointer; + transition: all 0.2s; +} + +.ec-section-tab:hover { + color: #fff; + border-color: rgba(255, 255, 255, 0.3); +} + +.ec-section-tab--active { + color: #fff; + background: var(--ec-primary); + border-color: var(--ec-primary); +} + +.ec-topbar-spacer { + flex: 1; +} + +/* ── Timer ────────────────────────────────────────────────────── */ + +.ec-timer { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); + color: #fff; + font-size: 20px; + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: 1px; + transition: all 0.3s; +} + +.ec-timer-icon { + font-size: 18px; + opacity: 0.7; +} + +.ec-timer--warning { + background: rgba(243, 156, 18, 0.2); + color: var(--ec-warning); + animation: ec-pulse 1.5s ease-in-out infinite; +} + +.ec-timer--critical { + background: rgba(231, 76, 60, 0.25); + color: var(--ec-danger); + animation: ec-pulse 0.6s ease-in-out infinite; +} + +@keyframes ec-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.ec-progress-info { + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + font-weight: 500; + white-space: nowrap; +} + +/* ── Main Content Area ────────────────────────────────────────── */ + +.ec-content { + flex: 1; + display: flex; + overflow: hidden; +} + +.ec-passage-panel { + width: 45%; + background: #fff; + border-right: 1px solid var(--ec-gray-200); + overflow-y: auto; + padding: 32px; +} + +.ec-passage-title { + font-size: 18px; + font-weight: 700; + color: var(--ec-gray-900); + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 2px solid var(--ec-primary); +} + +.ec-passage-body { + font-size: 15px; + line-height: 1.8; + color: var(--ec-gray-700); + white-space: pre-wrap; +} + +.ec-question-panel { + flex: 1; + overflow-y: auto; + padding: 32px 40px; +} + +.ec-question-panel--full { + width: 100%; + max-width: 800px; + margin: 0 auto; +} + +/* ── Question Card ────────────────────────────────────────────── */ + +.ec-question-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; +} + +.ec-question-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--ec-primary); + color: #fff; + font-size: 14px; + font-weight: 700; + flex-shrink: 0; +} + +.ec-question-type { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--ec-gray-500); + background: var(--ec-gray-100); + padding: 4px 10px; + border-radius: 4px; +} + +.ec-question-stem { + font-size: 17px; + line-height: 1.7; + color: var(--ec-gray-900); + margin-bottom: 24px; + font-weight: 500; +} + +/* ── MCQ Options ──────────────────────────────────────────────── */ + +.ec-option-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.ec-option { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; + border: 2px solid var(--ec-gray-200); + border-radius: var(--ec-radius); + cursor: pointer; + transition: all 0.15s; + background: #fff; +} + +.ec-option:hover { + border-color: var(--ec-primary); + background: rgba(27, 110, 243, 0.03); +} + +.ec-option--selected { + border-color: var(--ec-primary); + background: rgba(27, 110, 243, 0.06); +} + +.ec-option-radio, +.ec-option-checkbox { + width: 22px; + height: 22px; + border: 2px solid var(--ec-gray-300); + border-radius: 50%; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} + +.ec-option-checkbox { + border-radius: 4px; +} + +.ec-option--selected .ec-option-radio { + border-color: var(--ec-primary); + background: var(--ec-primary); +} + +.ec-option--selected .ec-option-radio::after { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: #fff; +} + +.ec-option--selected .ec-option-checkbox { + border-color: var(--ec-primary); + background: var(--ec-primary); +} + +.ec-option--selected .ec-option-checkbox::after { + content: "✓"; + color: #fff; + font-size: 14px; + font-weight: 700; +} + +.ec-option-key { + font-weight: 700; + color: var(--ec-gray-500); + font-size: 14px; + min-width: 20px; +} + +.ec-option-label { + font-size: 15px; + color: var(--ec-gray-700); + line-height: 1.5; +} + +/* ── TFNG Selector ────────────────────────────────────────────── */ + +.ec-tfng-options { + display: flex; + gap: 12px; +} + +.ec-tfng-btn { + flex: 1; + padding: 16px; + border: 2px solid var(--ec-gray-200); + border-radius: var(--ec-radius); + text-align: center; + cursor: pointer; + font-size: 15px; + font-weight: 600; + color: var(--ec-gray-700); + background: #fff; + transition: all 0.15s; +} + +.ec-tfng-btn:hover { + border-color: var(--ec-primary); +} + +.ec-tfng-btn--selected { + border-color: var(--ec-primary); + background: var(--ec-primary); + color: #fff; +} + +/* ── Gap Fill ─────────────────────────────────────────────────── */ + +.ec-gap-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.ec-gap-label { + font-weight: 600; + color: var(--ec-gray-500); + font-size: 14px; + min-width: 32px; +} + +.ec-gap-input { + flex: 1; + padding: 10px 14px; + border: 2px solid var(--ec-gray-200); + border-radius: 6px; + font-size: 15px; + outline: none; + transition: border-color 0.2s; +} + +.ec-gap-input:focus { + border-color: var(--ec-primary); +} + +/* ── Audio Recorder ───────────────────────────────────────────── */ + +.ec-audio-recorder { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 32px; + background: var(--ec-gray-50); + border-radius: var(--ec-radius); + border: 2px dashed var(--ec-gray-200); +} + +.ec-record-btn { + width: 72px; + height: 72px; + border-radius: 50%; + border: none; + cursor: pointer; + font-size: 28px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + color: #fff; +} + +.ec-record-btn--start { + background: var(--ec-danger); +} + +.ec-record-btn--start:hover { + background: var(--ec-danger-dark); + transform: scale(1.05); +} + +.ec-record-btn--stop { + background: var(--ec-gray-700); + animation: ec-recording-pulse 1s ease-in-out infinite; +} + +@keyframes ec-recording-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.4); } + 50% { box-shadow: 0 0 0 16px rgba(231, 76, 60, 0); } +} + +.ec-audio-duration { + font-size: 28px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--ec-gray-900); +} + +.ec-audio-hint { + font-size: 13px; + color: var(--ec-gray-500); +} + +.ec-audio-playback { + width: 100%; + max-width: 360px; +} + +.ec-audio-error { + color: var(--ec-danger); + font-size: 14px; + font-weight: 500; +} + +/* ── Rich Editor / Writing ────────────────────────────────────── */ + +.ec-rich-editor { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ec-editor-textarea { + width: 100%; + min-height: 300px; + padding: 16px; + border: 2px solid var(--ec-gray-200); + border-radius: var(--ec-radius); + font-size: 15px; + line-height: 1.7; + resize: vertical; + outline: none; + font-family: inherit; + transition: border-color 0.2s; +} + +.ec-editor-textarea:focus { + border-color: var(--ec-primary); +} + +.ec-editor-footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.ec-word-count { + font-size: 13px; + color: var(--ec-gray-500); + font-weight: 500; +} + +.ec-word-count--low { + color: var(--ec-warning); +} + +.ec-word-count--ok { + color: var(--ec-success); +} + +.ec-word-count--over { + color: var(--ec-danger); +} + +.ec-word-range { + font-size: 12px; + color: var(--ec-gray-500); +} + +/* ── Numerical Input ──────────────────────────────────────────── */ + +.ec-numerical-group { + display: flex; + align-items: center; + gap: 12px; +} + +.ec-numerical-input { + width: 200px; + padding: 12px 16px; + border: 2px solid var(--ec-gray-200); + border-radius: 6px; + font-size: 18px; + font-weight: 600; + text-align: center; + outline: none; + transition: border-color 0.2s; +} + +.ec-numerical-input:focus { + border-color: var(--ec-primary); +} + +.ec-numerical-unit { + font-size: 15px; + color: var(--ec-gray-500); + font-weight: 500; +} + +.ec-numerical-tolerance { + font-size: 12px; + color: var(--ec-gray-500); + margin-top: 8px; +} + +/* ── Code Editor ──────────────────────────────────────────────── */ + +.ec-code-editor { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ec-code-lang { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + color: var(--ec-gray-500); + background: var(--ec-gray-100); + padding: 4px 10px; + border-radius: 4px; + display: inline-block; + width: fit-content; +} + +.ec-code-textarea { + width: 100%; + min-height: 280px; + padding: 16px; + border: 2px solid var(--ec-gray-200); + border-radius: var(--ec-radius); + font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Consolas", monospace; + font-size: 14px; + line-height: 1.6; + resize: vertical; + outline: none; + background: #1e1e2e; + color: #cdd6f4; + tab-size: 4; + transition: border-color 0.2s; +} + +.ec-code-textarea:focus { + border-color: var(--ec-primary); +} + +/* ── Bottom Bar ───────────────────────────────────────────────── */ + +.ec-bottombar { + height: var(--ec-bottombar-height); + background: #fff; + border-top: 1px solid var(--ec-gray-200); + display: flex; + align-items: center; + padding: 0 20px; + gap: 12px; + flex-shrink: 0; + z-index: 100; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04); +} + +.ec-nav-btn { + padding: 8px 20px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: 2px solid var(--ec-gray-200); + background: #fff; + color: var(--ec-gray-700); + transition: all 0.15s; +} + +.ec-nav-btn:hover:not(:disabled) { + border-color: var(--ec-primary); + color: var(--ec-primary); +} + +.ec-nav-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.ec-flag-btn { + padding: 8px 16px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: 2px solid var(--ec-gray-200); + background: #fff; + color: var(--ec-gray-500); + transition: all 0.15s; + display: flex; + align-items: center; + gap: 6px; +} + +.ec-flag-btn--active { + border-color: var(--ec-warning); + background: rgba(243, 156, 18, 0.08); + color: var(--ec-warning); +} + +.ec-bottombar-spacer { + flex: 1; +} + +/* ── Question Navigator Dots ──────────────────────────────────── */ + +.ec-dots { + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; + justify-content: center; + max-width: 480px; +} + +.ec-dot { + width: 28px; + height: 28px; + border-radius: 50%; + font-size: 11px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: 2px solid var(--ec-gray-200); + background: #fff; + color: var(--ec-gray-500); + transition: all 0.15s; +} + +.ec-dot:hover { + border-color: var(--ec-primary); +} + +.ec-dot--current { + border-color: var(--ec-primary); + background: var(--ec-primary); + color: #fff; + transform: scale(1.15); +} + +.ec-dot--answered { + border-color: var(--ec-success); + background: var(--ec-success); + color: #fff; +} + +.ec-dot--flagged { + border-color: var(--ec-warning); + background: var(--ec-warning); + color: #fff; +} + +.ec-dot--answered.ec-dot--flagged { + background: var(--ec-warning); + border-color: var(--ec-warning); +} + +/* ── Review / Submit Buttons ──────────────────────────────────── */ + +.ec-review-btn { + padding: 8px 20px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: 2px solid var(--ec-primary); + background: #fff; + color: var(--ec-primary); + transition: all 0.15s; +} + +.ec-review-btn:hover { + background: var(--ec-primary); + color: #fff; +} + +.ec-submit-btn { + padding: 8px 24px; + border-radius: 6px; + font-size: 14px; + font-weight: 700; + cursor: pointer; + border: none; + background: var(--ec-success); + color: #fff; + transition: all 0.15s; +} + +.ec-submit-btn:hover { + background: #219150; +} + +/* ── Review Overlay ───────────────────────────────────────────── */ + +.ec-review-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + animation: ec-fade-in 0.2s ease; +} + +@keyframes ec-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.ec-review-panel { + background: #fff; + border-radius: 16px; + width: 90%; + max-width: 680px; + max-height: 80vh; + overflow-y: auto; + box-shadow: var(--ec-shadow-lg); + animation: ec-slide-up 0.25s ease; +} + +@keyframes ec-slide-up { + from { transform: translateY(24px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.ec-review-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid var(--ec-gray-200); +} + +.ec-review-title { + font-size: 18px; + font-weight: 700; + color: var(--ec-gray-900); +} + +.ec-review-close { + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + background: var(--ec-gray-100); + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--ec-gray-700); + transition: background 0.15s; +} + +.ec-review-close:hover { + background: var(--ec-gray-200); +} + +.ec-review-stats { + display: flex; + gap: 24px; + padding: 16px 24px; + background: var(--ec-gray-50); +} + +.ec-review-stat { + text-align: center; +} + +.ec-review-stat-val { + font-size: 24px; + font-weight: 700; + color: var(--ec-gray-900); +} + +.ec-review-stat-label { + font-size: 12px; + color: var(--ec-gray-500); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.ec-review-section { + padding: 16px 24px; +} + +.ec-review-section-title { + font-size: 14px; + font-weight: 700; + color: var(--ec-gray-700); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.ec-review-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); + gap: 8px; +} + +.ec-review-cell { + width: 44px; + height: 44px; + border-radius: 8px; + font-size: 13px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: 2px solid var(--ec-gray-200); + background: #fff; + color: var(--ec-gray-500); + transition: all 0.15s; +} + +.ec-review-cell:hover { + transform: scale(1.08); +} + +.ec-review-cell--answered { + border-color: var(--ec-success); + background: rgba(39, 174, 96, 0.08); + color: var(--ec-success); +} + +.ec-review-cell--flagged { + border-color: var(--ec-warning); + background: rgba(243, 156, 18, 0.08); + color: var(--ec-warning); +} + +.ec-review-cell--unanswered { + border-color: var(--ec-gray-200); + background: var(--ec-gray-50); + color: var(--ec-gray-500); +} + +.ec-review-footer { + display: flex; + justify-content: flex-end; + padding: 16px 24px; + border-top: 1px solid var(--ec-gray-200); + gap: 12px; +} + +.ec-review-legend { + display: flex; + gap: 16px; + padding: 0 24px 16px; +} + +.ec-legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--ec-gray-500); + font-weight: 500; +} + +.ec-legend-dot { + width: 12px; + height: 12px; + border-radius: 3px; +} + +.ec-legend-dot--answered { + background: var(--ec-success); +} + +.ec-legend-dot--flagged { + background: var(--ec-warning); +} + +.ec-legend-dot--unanswered { + background: var(--ec-gray-200); +} + +/* ── Confirm Modal ────────────────────────────────────────────── */ + +.ec-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + animation: ec-fade-in 0.15s ease; +} + +.ec-modal { + background: #fff; + border-radius: 16px; + padding: 32px; + max-width: 420px; + width: 90%; + box-shadow: var(--ec-shadow-lg); + text-align: center; + animation: ec-slide-up 0.2s ease; +} + +.ec-modal-icon { + width: 56px; + height: 56px; + margin: 0 auto 16px; + border-radius: 50%; + background: rgba(39, 174, 96, 0.1); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; +} + +.ec-modal-title { + font-size: 20px; + font-weight: 700; + color: var(--ec-gray-900); + margin-bottom: 8px; +} + +.ec-modal-body { + font-size: 15px; + color: var(--ec-gray-500); + line-height: 1.6; + margin-bottom: 24px; +} + +.ec-modal-actions { + display: flex; + gap: 12px; + justify-content: center; +} + +.ec-modal-cancel { + padding: 10px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: 2px solid var(--ec-gray-200); + background: #fff; + color: var(--ec-gray-700); + transition: all 0.15s; +} + +.ec-modal-cancel:hover { + border-color: var(--ec-gray-300); +} + +.ec-modal-confirm { + padding: 10px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 700; + cursor: pointer; + border: none; + background: var(--ec-success); + color: #fff; + transition: all 0.15s; +} + +.ec-modal-confirm:hover { + background: #219150; +} + +/* ── Placement-specific styles ────────────────────────────────── */ + +.ec-placement-progress { + width: 100%; + padding: 0 20px; + margin: 0; +} + +.ec-progress-bar-container { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; + overflow: hidden; +} + +.ec-progress-bar-fill { + height: 100%; + background: var(--ec-success); + border-radius: 3px; + transition: width 0.5s ease; +} + +.ec-placement-info { + display: flex; + align-items: center; + gap: 8px; + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + font-weight: 500; +} + +.ec-placement-center { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 40px; +} + +.ec-placement-card { + width: 100%; + max-width: 700px; + background: #fff; + border-radius: 16px; + box-shadow: var(--ec-shadow-lg); + overflow: hidden; +} + +.ec-placement-card-header { + padding: 20px 28px; + background: var(--ec-gray-50); + border-bottom: 1px solid var(--ec-gray-200); + display: flex; + align-items: center; + justify-content: space-between; +} + +.ec-placement-card-body { + padding: 28px; +} + +.ec-placement-actions { + display: flex; + justify-content: flex-end; + padding: 20px 28px; + border-top: 1px solid var(--ec-gray-200); +} + +.ec-placement-next { + padding: 12px 32px; + border-radius: 8px; + font-size: 15px; + font-weight: 700; + cursor: pointer; + border: none; + background: var(--ec-primary); + color: #fff; + transition: all 0.15s; +} + +.ec-placement-next:hover { + background: var(--ec-primary-dark); +} + +.ec-placement-next:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ── Placement Results ────────────────────────────────────────── */ + +.ec-placement-results { + text-align: center; + padding: 60px 40px; +} + +.ec-placement-results-icon { + width: 80px; + height: 80px; + border-radius: 50%; + background: var(--ec-success); + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + color: #fff; + margin: 0 auto 24px; + animation: ec-pop 0.4s ease; +} + +.ec-placement-results-title { + font-size: 24px; + font-weight: 700; + color: var(--ec-gray-900); + margin-bottom: 8px; +} + +.ec-placement-results-level { + font-size: 48px; + font-weight: 800; + color: var(--ec-primary); + margin-bottom: 8px; +} + +.ec-placement-results-desc { + font-size: 15px; + color: var(--ec-gray-500); + margin-bottom: 32px; +} + +.ec-placement-results-btn { + padding: 12px 32px; + border-radius: 8px; + font-size: 15px; + font-weight: 700; + cursor: pointer; + border: none; + background: var(--ec-primary); + color: #fff; +} + +/* ── Responsive ───────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .ec-passage-panel { + display: none; + } + + .ec-question-panel { + padding: 20px; + } + + .ec-section-tabs { + display: none; + } + + .ec-dots { + max-width: 200px; + } + + .ec-review-panel { + width: 95%; + max-height: 90vh; + } +} diff --git a/custom_addons/encoach_exam_session/static/src/js/exam_app.js b/custom_addons/encoach_exam_session/static/src/js/exam_app.js new file mode 100644 index 00000000..f1a76364 --- /dev/null +++ b/custom_addons/encoach_exam_session/static/src/js/exam_app.js @@ -0,0 +1,423 @@ +/** @odoo-module */ + +import { Component, onMounted, onWillStart, onWillUnmount, useState, useRef } from "@odoo/owl"; +import { registry } from "@web/core/registry"; + +const AUTOSAVE_INTERVAL = 10000; + +export class ExamApp extends Component { + static template = "encoach_exam_session.ExamApp"; + static props = {}; + + setup() { + this.rootRef = useRef("root"); + this.state = useState({ + loading: true, + examData: null, + currentSectionIdx: 0, + currentQuestionIdx: 0, + answers: {}, + flagged: {}, + timeRemaining: 0, + totalTimeRemaining: 0, + submitted: false, + showReview: false, + showConfirmSubmit: false, + submitMessage: "", + }); + + this.timerInterval = null; + this.autosaveInterval = null; + + onWillStart(async () => { + await this.loadExamData(); + }); + + onMounted(() => { + this.startTimers(); + this.enterFullscreen(); + this.loadFromLocalStorage(); + document.addEventListener("visibilitychange", this._onVisibility); + }); + + onWillUnmount(() => { + this.stopTimers(); + document.removeEventListener("visibilitychange", this._onVisibility); + }); + } + + get attemptId() { + const el = document.getElementById("exam_session_app"); + return el ? parseInt(el.dataset.attemptId) : 0; + } + + get examId() { + const el = document.getElementById("exam_session_app"); + return el ? parseInt(el.dataset.examId) : 0; + } + + getToken() { + return ( + document.cookie + .split(";") + .find((c) => c.trim().startsWith("session_id=")) + ?.split("=")[1] || "" + ); + } + + // ── Data Loading ────────────────────────────────────────────────── + + async loadExamData() { + try { + const response = await fetch(`/api/exam/${this.examId}/session`, { + headers: { Authorization: `Bearer ${this.getToken()}` }, + }); + const data = await response.json(); + this.state.examData = data; + this.state.timeRemaining = + data.sections?.[0]?.time_limit_sec || 3600; + this.state.totalTimeRemaining = data.total_time_sec || 7200; + if (data.saved_answers) { + for (const ans of data.saved_answers) { + this.state.answers[ans.question_id] = ans.answer; + } + } + this.state.loading = false; + } catch (e) { + console.error("Failed to load exam data:", e); + } + } + + // ── Timers ──────────────────────────────────────────────────────── + + startTimers() { + this.timerInterval = setInterval(() => { + if (this.state.timeRemaining > 0) { + this.state.timeRemaining--; + this.state.totalTimeRemaining--; + } else { + this.autoSubmitSection(); + } + }, 1000); + this.autosaveInterval = setInterval( + () => this.autosave(), + AUTOSAVE_INTERVAL + ); + } + + stopTimers() { + if (this.timerInterval) clearInterval(this.timerInterval); + if (this.autosaveInterval) clearInterval(this.autosaveInterval); + } + + enterFullscreen() { + const el = document.documentElement; + if (el.requestFullscreen) { + el.requestFullscreen().catch(() => {}); + } + } + + formatTime(seconds) { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + } + + get timerClass() { + if (this.state.timeRemaining < 60) return "ec-timer ec-timer--critical"; + if (this.state.timeRemaining < 300) return "ec-timer ec-timer--warning"; + return "ec-timer"; + } + + // ── Section / Question Navigation ───────────────────────────────── + + get currentSection() { + return this.state.examData?.sections?.[this.state.currentSectionIdx]; + } + + get currentQuestion() { + return this.currentSection?.questions?.[this.state.currentQuestionIdx]; + } + + get totalQuestions() { + return this.currentSection?.questions?.length || 0; + } + + get answeredCount() { + const section = this.currentSection; + if (!section) return 0; + return section.questions.filter( + (q) => this.state.answers[q.id] !== undefined + ).length; + } + + get sections() { + return this.state.examData?.sections || []; + } + + get questionDots() { + const section = this.currentSection; + if (!section) return []; + return section.questions.map((q, idx) => ({ + idx, + id: q.id, + answered: this.state.answers[q.id] !== undefined, + flagged: !!this.state.flagged[q.id], + current: idx === this.state.currentQuestionIdx, + })); + } + + get hasPrev() { + return this.state.currentQuestionIdx > 0; + } + + get hasNext() { + return this.state.currentQuestionIdx < this.totalQuestions - 1; + } + + get isLastSection() { + return ( + this.state.currentSectionIdx >= + (this.state.examData?.sections?.length || 1) - 1 + ); + } + + nextQuestion() { + if (this.state.currentQuestionIdx < this.totalQuestions - 1) { + this.state.currentQuestionIdx++; + } + } + + prevQuestion() { + if (this.state.currentQuestionIdx > 0) { + this.state.currentQuestionIdx--; + } + } + + goToQuestion(idx) { + this.state.currentQuestionIdx = idx; + this.state.showReview = false; + } + + switchSection(idx) { + this.state.currentSectionIdx = idx; + this.state.currentQuestionIdx = 0; + const section = this.state.examData?.sections?.[idx]; + if (section?.time_limit_sec) { + this.state.timeRemaining = section.time_limit_sec; + } + } + + nextSection() { + const sections = this.state.examData?.sections || []; + if (this.state.currentSectionIdx < sections.length - 1) { + this.state.currentSectionIdx++; + this.state.currentQuestionIdx = 0; + this.state.timeRemaining = + this.currentSection?.time_limit_sec || 3600; + } + } + + // ── Answer Management ───────────────────────────────────────────── + + setAnswer(questionId, value) { + this.state.answers[questionId] = value; + this.saveToLocalStorage(); + } + + onAnswer(ev) { + if (ev.detail) { + this.setAnswer(ev.detail.questionId, ev.detail.value); + } + } + + toggleFlag() { + const q = this.currentQuestion; + if (!q) return; + this.state.flagged[q.id] = !this.state.flagged[q.id]; + } + + get isCurrentFlagged() { + const q = this.currentQuestion; + return q ? !!this.state.flagged[q.id] : false; + } + + // ── LocalStorage Persistence ────────────────────────────────────── + + saveToLocalStorage() { + try { + localStorage.setItem( + `exam_${this.attemptId}`, + JSON.stringify({ + answers: this.state.answers, + flagged: this.state.flagged, + currentSectionIdx: this.state.currentSectionIdx, + currentQuestionIdx: this.state.currentQuestionIdx, + timeRemaining: this.state.timeRemaining, + }) + ); + } catch (e) { + // storage full or unavailable + } + } + + loadFromLocalStorage() { + try { + const saved = localStorage.getItem(`exam_${this.attemptId}`); + if (saved) { + const data = JSON.parse(saved); + Object.assign(this.state.answers, data.answers || {}); + Object.assign(this.state.flagged, data.flagged || {}); + } + } catch (e) { + // corrupt data + } + } + + // ── Server Autosave ─────────────────────────────────────────────── + + async autosave() { + try { + const answers = Object.entries(this.state.answers).map( + ([qid, ans]) => ({ + question_id: parseInt(qid), + answer: ans, + }) + ); + await fetch(`/api/exam/${this.examId}/autosave`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + attempt_id: this.attemptId, + answers, + current_section: this.currentSection?.skill || "", + time_remaining_sec: this.state.timeRemaining, + }), + }); + } catch (e) { + console.warn("Autosave failed:", e); + } + } + + // ── Visibility tracking (tab-switch guard) ──────────────────────── + + _onVisibility = () => { + if (document.visibilityState === "visible") { + this.autosave(); + } + }; + + // ── Submit Flow ─────────────────────────────────────────────────── + + autoSubmitSection() { + const sections = this.state.examData?.sections || []; + if (this.state.currentSectionIdx < sections.length - 1) { + this.nextSection(); + } else { + this.submitExam(); + } + } + + showReviewPanel() { + this.state.showReview = true; + } + + hideReviewPanel() { + this.state.showReview = false; + } + + confirmSubmit() { + this.state.showConfirmSubmit = true; + } + + cancelSubmit() { + this.state.showConfirmSubmit = false; + } + + async submitExam() { + this.stopTimers(); + this.state.submitted = true; + this.state.showConfirmSubmit = false; + this.state.submitMessage = "Submitting your answers..."; + try { + const answers = Object.entries(this.state.answers).map( + ([qid, ans]) => ({ + question_id: parseInt(qid), + answer: ans, + }) + ); + const response = await fetch(`/api/exam/${this.examId}/submit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + attempt_id: this.attemptId, + answers, + }), + }); + await response.json(); + localStorage.removeItem(`exam_${this.attemptId}`); + this.state.submitMessage = + "Exam submitted successfully! Redirecting..."; + setTimeout(() => { + window.location.href = `/my/exam/${this.attemptId}/results`; + }, 2000); + } catch (e) { + console.error("Submit failed:", e); + this.state.submitMessage = + "Submission failed. Please try again."; + this.state.submitted = false; + } + } + + // ── Review summary helpers ──────────────────────────────────────── + + get reviewSections() { + const sections = this.state.examData?.sections || []; + return sections.map((section, sIdx) => ({ + ...section, + sIdx, + questions: section.questions.map((q, qIdx) => ({ + ...q, + qIdx, + sIdx, + answered: this.state.answers[q.id] !== undefined, + flagged: !!this.state.flagged[q.id], + })), + })); + } + + get totalAnswered() { + return Object.keys(this.state.answers).length; + } + + get totalAllQuestions() { + const sections = this.state.examData?.sections || []; + return sections.reduce((sum, s) => sum + (s.questions?.length || 0), 0); + } + + get totalFlagged() { + return Object.values(this.state.flagged).filter(Boolean).length; + } + + reviewGoTo(sIdx, qIdx) { + this.state.currentSectionIdx = sIdx; + this.state.currentQuestionIdx = qIdx; + this.state.showReview = false; + } +} + +// ── Auto-mount on page load ─────────────────────────────────────────── + +function mountExamApp() { + const target = document.getElementById("exam_session_app"); + if (target) { + const { mount } = owl; + mount(ExamApp, target); + } +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountExamApp); +} else { + mountExamApp(); +} diff --git a/custom_addons/encoach_exam_session/static/src/js/exam_delivery.js b/custom_addons/encoach_exam_session/static/src/js/exam_delivery.js new file mode 100644 index 00000000..1315bead --- /dev/null +++ b/custom_addons/encoach_exam_session/static/src/js/exam_delivery.js @@ -0,0 +1,368 @@ +(function () { + "use strict"; + + const app = document.getElementById("exam_session_app"); + if (!app) return; + + const ATTEMPT_ID = parseInt(app.dataset.attemptId); + const EXAM_ID = parseInt(app.dataset.examId); + const SESSION_TOKEN = app.dataset.sessionToken; + + let sections = []; + let currentSection = 0; + let currentQuestion = 0; + let answers = {}; + let totalTime = 0; + let timeLeft = 0; + let timerInterval = null; + + const sidebar = document.getElementById("sidebar"); + const mainContent = document.getElementById("mainContent"); + const timerEl = document.getElementById("timer"); + const progressBar = document.getElementById("progressBar"); + + async function fetchJSON(url, options) { + options = options || {}; + options.headers = Object.assign( + { "Content-Type": "application/json" }, + options.headers || {} + ); + const res = await fetch(url, options); + return res.json(); + } + + async function loadSession() { + const data = await fetchJSON("/api/exam/" + EXAM_ID + "/session", { + headers: { Authorization: "Bearer " + SESSION_TOKEN }, + }); + if (data.error) { + mainContent.innerHTML = + '

Error

' + + data.error + + "

"; + return; + } + sections = data.sections || []; + totalTime = (data.total_time_min || 30) * 60; + timeLeft = totalTime; + + sections.forEach(function (sec) { + (sec.questions || []).forEach(function (q) { + if (q.saved_answer) answers[q.id] = q.saved_answer; + }); + }); + + buildSidebar(); + renderQuestion(); + startTimer(); + } + + function buildSidebar() { + var html = ""; + sections.forEach(function (sec, si) { + html += '
' + sec.title + "
"; + (sec.questions || []).forEach(function (q, qi) { + var cls = answers[q.id] ? "answered" : ""; + var act = + si === currentSection && qi === currentQuestion + ? "active" + : ""; + html += + '"; + }); + html += "
"; + }); + sidebar.innerHTML = html; + sidebar.querySelectorAll(".q-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + jumpTo( + parseInt(this.dataset.si), + parseInt(this.dataset.qi) + ); + }); + }); + } + + function renderQuestion() { + var sec = sections[currentSection]; + if (!sec) return; + var q = sec.questions[currentQuestion]; + if (!q) return; + + var globalIdx = + sections + .slice(0, currentSection) + .reduce(function (a, s) { + return a + s.questions.length; + }, 0) + + currentQuestion + + 1; + var totalQ = sections.reduce(function (a, s) { + return a + s.questions.length; + }, 0); + + progressBar.style.width = (globalIdx / totalQ) * 100 + "%"; + + var optionsHtml = ""; + if (q.options && q.options.length > 0) { + q.options.forEach(function (opt) { + var key = opt.key || opt.text; + var sel = answers[q.id] === key ? "selected" : ""; + optionsHtml += + '"; + }); + } else { + var val = answers[q.id] || ""; + optionsHtml = + ''; + } + + var isFirst = currentSection === 0 && currentQuestion === 0; + var isLast = + currentSection === sections.length - 1 && + currentQuestion === sec.questions.length - 1; + + mainContent.innerHTML = + '
' + + '
' + + sec.title + + " \u2014 Question " + + (currentQuestion + 1) + + " of " + + sec.questions.length + + '' + + (q.difficulty || "") + + " \u2022 " + + (q.marks || 1) + + " mark" + + ((q.marks || 1) > 1 ? "s" : "") + + "
" + + '
' + + q.stem + + "
" + + optionsHtml + + "
" + + '
' + + '" + + (isLast + ? '' + : '') + + "
"; + + mainContent.querySelectorAll(".ec-option").forEach(function (el) { + el.addEventListener("click", function () { + selectAnswer( + parseInt(this.dataset.qid), + this.dataset.key, + this + ); + }); + }); + + var textInput = document.getElementById("textAnswer"); + if (textInput) { + textInput.addEventListener("change", function () { + answers[q.id] = this.value; + }); + } + + var btnPrev = document.getElementById("btnPrev"); + if (btnPrev) + btnPrev.addEventListener("click", function () { + navigate(-1); + }); + + var btnNext = document.getElementById("btnNext"); + if (btnNext) + btnNext.addEventListener("click", function () { + navigate(1); + }); + + var btnSubmit = document.getElementById("btnSubmit"); + if (btnSubmit) btnSubmit.addEventListener("click", submitExam); + + buildSidebar(); + } + + function selectAnswer(qid, key, el) { + answers[qid] = key; + el.closest(".ec-card") + .querySelectorAll(".ec-option") + .forEach(function (o) { + o.classList.remove("selected"); + }); + el.classList.add("selected"); + buildSidebar(); + } + + function navigate(dir) { + var sec = sections[currentSection]; + var next = currentQuestion + dir; + if (next >= 0 && next < sec.questions.length) { + currentQuestion = next; + } else if (dir > 0 && currentSection < sections.length - 1) { + currentSection++; + currentQuestion = 0; + } else if (dir < 0 && currentSection > 0) { + currentSection--; + currentQuestion = sections[currentSection].questions.length - 1; + } + renderQuestion(); + autoSave(); + } + + function jumpTo(si, qi) { + currentSection = si; + currentQuestion = qi; + renderQuestion(); + } + + function startTimer() { + timerInterval = setInterval(function () { + timeLeft--; + if (timeLeft <= 0) { + clearInterval(timerInterval); + submitExam(); + return; + } + var m = Math.floor(timeLeft / 60); + var s = timeLeft % 60; + timerEl.textContent = + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0"); + timerEl.className = + "ec-timer" + + (timeLeft < 120 + ? " danger" + : timeLeft < 300 + ? " warning" + : ""); + }, 1000); + } + + var saveTimeout; + function autoSave() { + clearTimeout(saveTimeout); + saveTimeout = setTimeout(function () { + var ansArr = Object.entries(answers).map(function (entry) { + return { question_id: parseInt(entry[0]), answer: entry[1] }; + }); + fetchJSON("/api/exam/" + EXAM_ID + "/autosave", { + method: "POST", + headers: { Authorization: "Bearer " + SESSION_TOKEN }, + body: JSON.stringify({ + attempt_id: ATTEMPT_ID, + answers: ansArr, + current_section: + sections[currentSection] + ? sections[currentSection].skill + : "", + time_remaining_sec: timeLeft, + }), + }); + }, 2000); + } + + function submitExam() { + if ( + !confirm( + "Are you sure you want to submit? You cannot change answers after submission." + ) + ) + return; + clearInterval(timerInterval); + mainContent.innerHTML = + '
' + + "

Submitting\u2026

" + + '
'; + + var ansArr = Object.entries(answers).map(function (entry) { + return { question_id: parseInt(entry[0]), answer: entry[1] }; + }); + fetchJSON("/api/exam/" + EXAM_ID + "/submit", { + method: "POST", + headers: { Authorization: "Bearer " + SESSION_TOKEN }, + body: JSON.stringify({ attempt_id: ATTEMPT_ID, answers: ansArr }), + }).then(function (result) { + if (result.scores) { + showResults(result.scores); + } else { + mainContent.innerHTML = + '
' + + "

Exam Submitted

" + + "

Status: " + + result.status + + "

" + + "

Your results will be released soon.

"; + } + sidebar.innerHTML = + '
Exam completed
'; + }); + } + + function showResults(scores) { + var skills = ["listening", "reading", "writing", "speaking"].filter( + function (s) { + return scores[s] > 0; + } + ); + var rows = skills + .map(function (s) { + return ( + '
' + + s + + "" + + scores[s].toFixed(1) + + "
" + ); + }) + .join(""); + mainContent.innerHTML = + '
' + + '

Exam Complete!

' + + '
' + + scores.overall.toFixed(1) + + "
" + + '
Overall Band Score \u2014 CEFR ' + + (scores.cefr || "").toUpperCase() + + "
" + + '
' + + rows + + "
" + + 'Return to Dashboard' + + "
"; + } + + loadSession(); +})(); diff --git a/custom_addons/encoach_exam_session/static/src/js/placement_app.js b/custom_addons/encoach_exam_session/static/src/js/placement_app.js new file mode 100644 index 00000000..539ee5c2 --- /dev/null +++ b/custom_addons/encoach_exam_session/static/src/js/placement_app.js @@ -0,0 +1,199 @@ +/** @odoo-module */ + +import { Component, onMounted, onWillStart, onWillUnmount, useState, useRef } from "@odoo/owl"; + +const SEM_THRESHOLD = 0.3; +const MAX_QUESTIONS = 40; + +export class PlacementApp extends Component { + static template = "encoach_exam_session.PlacementApp"; + static props = {}; + + setup() { + this.rootRef = useRef("root"); + this.state = useState({ + loading: true, + sessionId: 0, + currentQuestion: null, + answer: null, + questionsAnswered: 0, + theta: 0.0, + sem: 1.0, + completed: false, + cefrLevel: null, + submitting: false, + error: null, + }); + + onWillStart(async () => { + await this.startSession(); + }); + + onMounted(() => { + this.enterFullscreen(); + }); + } + + get sessionIdFromDom() { + const el = document.getElementById("placement_session_app"); + return el ? parseInt(el.dataset.sessionId) : 0; + } + + enterFullscreen() { + const el = document.documentElement; + if (el.requestFullscreen) { + el.requestFullscreen().catch(() => {}); + } + } + + // ── Session Start ───────────────────────────────────────────── + + async startSession() { + try { + const domSessionId = this.sessionIdFromDom; + if (domSessionId) { + this.state.sessionId = domSessionId; + await this.loadCurrentQuestion(); + } else { + const response = await fetch("/api/placement/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await response.json(); + this.state.sessionId = data.session_id; + this.state.currentQuestion = data.first_question; + } + this.state.loading = false; + } catch (e) { + this.state.error = "Failed to start placement test."; + this.state.loading = false; + } + } + + async loadCurrentQuestion() { + try { + const response = await fetch( + `/api/placement/current?session_id=${this.state.sessionId}` + ); + const data = await response.json(); + if (data.question) { + this.state.currentQuestion = data.question; + this.state.questionsAnswered = data.questions_answered || 0; + this.state.theta = data.theta || 0.0; + this.state.sem = data.sem || 1.0; + } + } catch (e) { + console.error("Failed to load current question:", e); + } + } + + // ── Answer Handling ─────────────────────────────────────────── + + setAnswer(questionId, value) { + this.state.answer = value; + } + + get hasAnswer() { + return this.state.answer !== null && this.state.answer !== undefined && this.state.answer !== ""; + } + + async submitAnswer() { + if (!this.hasAnswer || this.state.submitting) return; + this.state.submitting = true; + + try { + const response = await fetch("/api/placement/answer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session_id: this.state.sessionId, + question_id: this.state.currentQuestion.id, + answer: this.state.answer, + }), + }); + const data = await response.json(); + this.state.theta = data.new_theta; + this.state.sem = data.new_sem; + this.state.questionsAnswered++; + + if (data.completed) { + this.state.completed = true; + this.state.cefrLevel = data.cefr_level; + } else { + this.state.currentQuestion = data.next_question; + this.state.answer = null; + } + } catch (e) { + this.state.error = "Failed to submit answer. Please try again."; + } finally { + this.state.submitting = false; + } + } + + // ── Progress Helpers ────────────────────────────────────────── + + get progressPercent() { + const semProgress = Math.max(0, (1.0 - this.state.sem) / (1.0 - SEM_THRESHOLD)); + const questionProgress = this.state.questionsAnswered / MAX_QUESTIONS; + return Math.min(Math.max(semProgress, questionProgress) * 100, 100); + } + + get progressStyle() { + return `width: ${this.progressPercent}%`; + } + + get questionType() { + return this.state.currentQuestion?.question_type || "mcq"; + } + + get questionOptions() { + const opts = this.state.currentQuestion?.options; + if (Array.isArray(opts)) { + return opts.map((o, i) => ({ + key: o.key || String.fromCharCode(65 + i), + label: o.label || o.text || o, + })); + } + if (opts && typeof opts === "object") { + return Object.entries(opts).map(([key, label]) => ({ key, label })); + } + return []; + } + + selectOption(key) { + this.state.answer = key; + } + + get cefrLabel() { + const map = { + pre_a1: "Pre-A1", + a1: "A1", + a2: "A2", + b1: "B1", + b2: "B2", + c1: "C1", + c2: "C2", + }; + return map[this.state.cefrLevel] || this.state.cefrLevel?.toUpperCase() || ""; + } + + goToDashboard() { + window.location.href = "/my/dashboard"; + } +} + +// ── Auto-mount ──────────────────────────────────────────────────── + +function mountPlacementApp() { + const target = document.getElementById("placement_session_app"); + if (target) { + const { mount } = owl; + mount(PlacementApp, target); + } +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountPlacementApp); +} else { + mountPlacementApp(); +} diff --git a/custom_addons/encoach_exam_session/static/src/js/question_renderers.js b/custom_addons/encoach_exam_session/static/src/js/question_renderers.js new file mode 100644 index 00000000..7301d2e4 --- /dev/null +++ b/custom_addons/encoach_exam_session/static/src/js/question_renderers.js @@ -0,0 +1,345 @@ +/** @odoo-module */ + +import { Component, useState, useRef, onMounted, onWillUnmount } from "@odoo/owl"; + +// ═══════════════════════════════════════════════════════════════════════ +// ExamMCQ — Single-choice radio buttons +// ═══════════════════════════════════════════════════════════════════════ + +export class ExamMCQ extends Component { + static template = "encoach_exam_session.ExamMCQ"; + static props = { + question: Object, + answer: { type: [String, { value: undefined }], optional: true }, + onAnswer: Function, + }; + + selectOption(optionKey) { + this.props.onAnswer(this.props.question.id, optionKey); + } + + get options() { + const opts = this.props.question.options; + if (Array.isArray(opts)) { + return opts.map((o, i) => ({ + key: o.key || String.fromCharCode(65 + i), + label: o.label || o.text || o, + })); + } + if (opts && typeof opts === "object") { + return Object.entries(opts).map(([key, label]) => ({ key, label })); + } + return []; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// ExamMCQMulti — Multi-choice checkboxes +// ═══════════════════════════════════════════════════════════════════════ + +export class ExamMCQMulti extends Component { + static template = "encoach_exam_session.ExamMCQMulti"; + static props = { + question: Object, + answer: { type: [Array, { value: undefined }], optional: true }, + onAnswer: Function, + }; + + toggleOption(optionKey) { + const current = Array.isArray(this.props.answer) + ? [...this.props.answer] + : []; + const idx = current.indexOf(optionKey); + if (idx >= 0) { + current.splice(idx, 1); + } else { + current.push(optionKey); + } + this.props.onAnswer(this.props.question.id, current); + } + + isChecked(optionKey) { + return Array.isArray(this.props.answer) && this.props.answer.includes(optionKey); + } + + get options() { + const opts = this.props.question.options; + if (Array.isArray(opts)) { + return opts.map((o, i) => ({ + key: o.key || String.fromCharCode(65 + i), + label: o.label || o.text || o, + })); + } + if (opts && typeof opts === "object") { + return Object.entries(opts).map(([key, label]) => ({ key, label })); + } + return []; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// ExamGapFill — Inline text inputs within a passage +// ═══════════════════════════════════════════════════════════════════════ + +export class ExamGapFill extends Component { + static template = "encoach_exam_session.ExamGapFill"; + static props = { + question: Object, + answer: { type: [Object, { value: undefined }], optional: true }, + onAnswer: Function, + }; + + onGapInput(gapIdx, ev) { + const current = this.props.answer ? { ...this.props.answer } : {}; + current[gapIdx] = ev.target.value; + this.props.onAnswer(this.props.question.id, current); + } + + get gaps() { + return this.props.question.gaps || []; + } + + gapValue(gapIdx) { + return this.props.answer?.[gapIdx] || ""; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// ExamTFNG — True / False / Not Given selector +// ═══════════════════════════════════════════════════════════════════════ + +export class ExamTFNG extends Component { + static template = "encoach_exam_session.ExamTFNG"; + static props = { + question: Object, + answer: { type: [String, { value: undefined }], optional: true }, + onAnswer: Function, + }; + + select(value) { + this.props.onAnswer(this.props.question.id, value); + } + + get choices() { + return [ + { key: "true", label: "True" }, + { key: "false", label: "False" }, + { key: "not_given", label: "Not Given" }, + ]; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// ExamAudioRecorder — Record audio (speaking tasks) +// ═══════════════════════════════════════════════════════════════════════ + +export class ExamAudioRecorder extends Component { + static template = "encoach_exam_session.ExamAudioRecorder"; + static props = { + question: Object, + answer: { type: [String, Object, { value: undefined }], optional: true }, + onAnswer: Function, + }; + + setup() { + this.state = useState({ + recording: false, + recordedUrl: null, + duration: 0, + error: null, + }); + this.mediaRecorder = null; + this.chunks = []; + this.durationInterval = null; + } + + async startRecording() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + this.mediaRecorder = new MediaRecorder(stream, { + mimeType: "audio/webm;codecs=opus", + }); + this.chunks = []; + this.mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) this.chunks.push(e.data); + }; + this.mediaRecorder.onstop = () => { + const blob = new Blob(this.chunks, { type: "audio/webm" }); + this.state.recordedUrl = URL.createObjectURL(blob); + this.props.onAnswer(this.props.question.id, { + type: "audio", + blob_url: this.state.recordedUrl, + duration: this.state.duration, + }); + stream.getTracks().forEach((t) => t.stop()); + }; + this.mediaRecorder.start(); + this.state.recording = true; + this.state.duration = 0; + this.durationInterval = setInterval(() => { + this.state.duration++; + }, 1000); + } catch (e) { + this.state.error = "Microphone access denied. Please allow microphone access."; + } + } + + stopRecording() { + if (this.mediaRecorder && this.state.recording) { + this.mediaRecorder.stop(); + this.state.recording = false; + if (this.durationInterval) clearInterval(this.durationInterval); + } + } + + formatDuration(seconds) { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// ExamRichEditor — Writing task with rich text +// ═══════════════════════════════════════════════════════════════════════ + +export class ExamRichEditor extends Component { + static template = "encoach_exam_session.ExamRichEditor"; + static props = { + question: Object, + answer: { type: [String, { value: undefined }], optional: true }, + onAnswer: Function, + }; + + setup() { + this.editorRef = useRef("editor"); + this.state = useState({ + wordCount: this._countWords(this.props.answer || ""), + }); + } + + onInput(ev) { + const text = ev.target.value || ""; + this.state.wordCount = this._countWords(text); + this.props.onAnswer(this.props.question.id, text); + } + + _countWords(text) { + return text + .trim() + .split(/\s+/) + .filter((w) => w.length > 0).length; + } + + get minWords() { + return this.props.question.min_words || 150; + } + + get maxWords() { + return this.props.question.max_words || 300; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// ExamReadingPassage — Scrollable passage display +// ═══════════════════════════════════════════════════════════════════════ + +export class ExamReadingPassage extends Component { + static template = "encoach_exam_session.ExamReadingPassage"; + static props = { + passage: { type: [Object, String, { value: undefined }], optional: true }, + }; + + get passageTitle() { + if (typeof this.props.passage === "object") { + return this.props.passage?.title || "Reading Passage"; + } + return "Reading Passage"; + } + + get passageContent() { + if (typeof this.props.passage === "object") { + return this.props.passage?.content || ""; + } + return this.props.passage || ""; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// ExamNumericalInput — Number input with optional tolerance +// ═══════════════════════════════════════════════════════════════════════ + +export class ExamNumericalInput extends Component { + static template = "encoach_exam_session.ExamNumericalInput"; + static props = { + question: Object, + answer: { type: [Number, String, { value: undefined }], optional: true }, + onAnswer: Function, + }; + + onInput(ev) { + const val = ev.target.value; + this.props.onAnswer(this.props.question.id, val); + } + + get tolerance() { + return this.props.question.tolerance; + } + + get unit() { + return this.props.question.unit || ""; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// ExamCodeEditor — Code editing textarea +// ═══════════════════════════════════════════════════════════════════════ + +export class ExamCodeEditor extends Component { + static template = "encoach_exam_session.ExamCodeEditor"; + static props = { + question: Object, + answer: { type: [String, { value: undefined }], optional: true }, + onAnswer: Function, + }; + + setup() { + this.editorRef = useRef("codeEditor"); + + onMounted(() => { + if (this.editorRef.el) { + this.editorRef.el.addEventListener("keydown", this._onKeydown); + } + }); + + onWillUnmount(() => { + if (this.editorRef.el) { + this.editorRef.el.removeEventListener("keydown", this._onKeydown); + } + }); + } + + _onKeydown = (ev) => { + if (ev.key === "Tab") { + ev.preventDefault(); + const ta = ev.target; + const start = ta.selectionStart; + const end = ta.selectionEnd; + ta.value = ta.value.substring(0, start) + " " + ta.value.substring(end); + ta.selectionStart = ta.selectionEnd = start + 4; + this.props.onAnswer(this.props.question.id, ta.value); + } + }; + + onInput(ev) { + this.props.onAnswer(this.props.question.id, ev.target.value); + } + + get language() { + return this.props.question.language || "python"; + } + + get starterCode() { + return this.props.question.starter_code || ""; + } +} diff --git a/custom_addons/encoach_exam_session/static/src/xml/exam_app.xml b/custom_addons/encoach_exam_session/static/src/xml/exam_app.xml new file mode 100644 index 00000000..ba5473d0 --- /dev/null +++ b/custom_addons/encoach_exam_session/static/src/xml/exam_app.xml @@ -0,0 +1,302 @@ + + + + + + +
+ + + +
+
+
Loading your exam...
+
+ + + + +