commit 907a5c0e92695cb4a17c3b04ddd3e8ca4df34650 Author: Yamen Ahmad Date: Fri Apr 10 17:26:42 2026 +0400 feat(v3): restructure project + add complete frontend - Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor 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..c9bd7adb --- /dev/null +++ b/custom_addons/encoach_adaptive/controllers/adaptive.py @@ -0,0 +1,293 @@ +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 +) +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//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_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..7475023e --- /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'], + '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..098d2c0a --- /dev/null +++ b/custom_addons/encoach_ai_course/controllers/ai_course.py @@ -0,0 +1,396 @@ +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/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...
+
+ + + + +