feat: initial backend codebase — EnCoach v3

Complete Odoo 19 backend with 25 custom addons:
- encoach_core: user/entity/role management
- encoach_api: REST API + JWT auth
- encoach_ai: OpenAI integration, AI settings, generation
- encoach_ai_course: AI-powered English & IELTS course generation
- encoach_exam_template/session: exam creation, structures, sessions
- encoach_scoring: AI auto-grading + manual approval
- encoach_vector: pgvector RAG integration
- encoach_adaptive: adaptive learning engine
- encoach_placement: placement testing
- encoach_taxonomy/resources: content taxonomy & resource management
- Plus 14 more modules for courses, branding, portal, etc.

Includes docs: user guide, generation report, developer workflow.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-11 15:44:20 +04:00
commit 982d4bca30
371 changed files with 35211 additions and 0 deletions

View File

@@ -0,0 +1 @@
from . import controller

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
#################################################################################
# Author : Terabits Technolab (<www.terabits.xyz>)
# 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',
],
}

View File

@@ -0,0 +1 @@
from . import main

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

View File

@@ -0,0 +1,757 @@
<!DOCTYPE html>
<html lang="en-US" data-website-id="1" data-oe-company-name="Odoo S.A.">
<head>
<link type="text/css" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css" />
<script src="https://code.jquery.com/jquery-3.6.0.js"
integrity="sha256-H+K7U5CnXl1h5ywQfKtSj8PCmoN9aaq30gDh27Xc0jk=" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/css2?family=Halant:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
crossorigin="anonymous"></script>
<link type="text/css" rel="stylesheet" href="/assets/assets.css" />
</head>
<body>
<div id="module-description">
<!-- Keep From Here -->
<div class="container">
<div class="oe_styling_v8">
<img class="img img-responsive center-block" style="border-top-left-radius:10px; border-top-right-radius:10px" src="https://www.terabits.xyz/index/img/clarity_backend_theme_bits/19.0/img.png">
<!-- blog post starts -->
<section class="blog_post_01">
<div class="position-relative w-100" style="display: inline-grid;">
<img src="images/blog_post_bg_01.png" class="img-responsive w-100 h-100 img img-fluid position-absolute left-0"/>
<div class="position-relative row justify-content-center align-items-center" style="padding: 20px 10px 20px 10px;">
<div class="col-12 my-3 px-5">
<div class="header-img">
<img src="images/blogpost_header_img.png" class="img img-fluid w-100" style="width: 100% !important;"/>
</div>
<div class="blg-01-content text-center" style="margin-top: 65px;">
<h1 style="font-family: 'Inter', sans-serif; font-size: 55px; font-weight: 700;line-height: 61px;color: #151765;margin-bottom: 0 !important;">
Clarity Backend Theme
</h1>
<h1 style="font-family: 'Inter', sans-serif; font-size: 28px; font-weight: 600;line-height: 41px;color: #4267C8;">
Odoo community backend theme
</h1>
<p style="font-family: 'Inter', sans-serif; font-size: 18px; font-weight: 500;line-height: 27px; color: #000000; margin-top: 20px;">
The clarity backend theme have amazing appearance on any device and is easy to <br/>
switch between all screen sizes. Clicking on any menu is made simple with an <br/>
contemporary and modern sidebar. You can browse across different apps using <br/>
elegant sidebar menu.The theme is designed, keeping in mind the delivery of <br/>
great user experience and user-friendly interface to your users.
</p>
</div>
<div class="features px-5 mx-5" style="margin-top: 70px;">
<div class="row justify-content-center">
<div class="col-xl-6 d-flex justify-content-end my-1">
<div class="box" style="background-color: #151765; border-radius: 10px; padding: 10px 20px; width: 420px;">
<h4 style="font-family: 'Inter', sans-serif; font-size: 18px; font-weight: 500;line-height: 27px;color: #fff;margin-bottom: 0 !important;">
App Drawer design for Community Addition
</h4>
</div>
</div>
<div class="col-xl-6 d-flex justify-content-start my-1">
<div class="box" style="background-color: #151765; border-radius: 10px; padding: 10px 20px; width: 420px;">
<h4 style="font-family: 'Inter', sans-serif; font-size: 18px; font-weight: 500;line-height: 27px;color: #fff;margin-bottom: 0 !important;">
Login & Signup Redesign
</h4>
</div>
</div>
<div class="col-xl-6 d-flex justify-content-end my-1">
<div class="box" style="background-color: #151765; border-radius: 10px; padding: 10px 20px; width: 420px;">
<h4 style="font-family: 'Inter', sans-serif; font-size: 18px; font-weight: 500;line-height: 27px;color: #fff;margin-bottom: 0 !important;">
Customized Discuss view design
</h4>
</div>
</div>
<div class="col-xl-6 d-flex justify-content-start my-1">
<div class="box" style="background-color: #151765; border-radius: 10px; padding: 10px 20px; width: 420px;">
<h4 style="font-family: 'Inter', sans-serif; font-size: 18px; font-weight: 500;line-height: 27px;color: #fff;margin-bottom: 0 !important;">
Show brand's logo in menu
</h4>
</div>
</div>
<div class="col-xl-6 d-flex justify-content-end my-1">
<div class="box" style="background-color: #151765; border-radius: 10px; padding: 10px 20px; width: 420px;">
<h4 style="font-family: 'Inter', sans-serif; font-size: 18px; font-weight: 500;line-height: 27px;color: #fff;margin-bottom: 0 !important;">
Customzed List, Form, Kanban view design
</h4>
</div>
</div>
<div class="col-xl-6 d-flex justify-content-start my-1">
<div class="box" style="background-color: #151765; border-radius: 10px; padding: 10px 20px; width: 420px;">
<h4 style="font-family: 'Inter', sans-serif; font-size: 18px; font-weight: 500;line-height: 27px;color: #fff;margin-bottom: 0 !important;">
Responsive sidebar design
</h4>
</div>
</div>
</div>
</div>
<div class="before_after_img px-5 pb-3" style="margin-top: 70px;">
<img src="images/main-b.png" class="img img-fluid w-100" style="width: 100% !important;"/>
</div>
</div>
</div>
</div>
</section>
<!-- blog post ends -->
<!-- features highlight -->
<section class="blog_post_01">
<div class="position-relative w-100" style="display: inline-grid;">
<img src="images/blog_ss_bg_03.png" class="img-responsive w-100 h-100 img img-fluid position-absolute left-0"/>
<div class="position-relative row justify-content-center align-items-center" style="padding: 40px 20px 40px 20px;">
<div class="col-12 my-3 px-5">
<div class="mt-2">
<div class="content-note d-flex ">
<div class="icon-img">
<img src="images/icon_img.png" class="img img-fluid"/>
</div>
<div class="content" style="margin-left: 15px !important;">
<p style="font-family: 'Inter', sans-serif; font-size: 25px; font-weight: 600;line-height: 26px;color: #151765;">
Custom discuss view
</p>
</div>
</div>
<div class="content-img text-center mt-3 mx-5">
<img src="images/1.png" class="img img-fluid img-responsive"/>
</div>
</div>
<div class="mt-5">
<div class="content-note d-flex ">
<div class="icon-img">
<img src="images/icon_img.png" class="img img-fluid"/>
</div>
<div class="content" style="margin-left: 15px !important;">
<p style="font-family: 'Inter', sans-serif; font-size: 25px; font-weight: 600;line-height: 26px;color: #151765;">
Interactive and clean design
</p>
</div>
</div>
<div class="content-img text-center mt-3 mx-5">
<img src="images/2.png" class="img img-fluid img-responsive"/>
</div>
</div>
<div class="mt-5">
<div class="content-note d-flex ">
<div class="icon-img">
<img src="images/icon_img.png" class="img img-fluid"/>
</div>
<div class="content" style="margin-left: 15px !important;">
<p style="font-family: 'Inter', sans-serif; font-size: 25px; font-weight: 600;line-height: 26px;color: #151765;">
Custom list view design
</p>
</div>
</div>
<div class="content-img text-center mt-3 mx-5">
<img src="images/3.png" class="img img-fluid img-responsive"/>
</div>
</div>
<div class="mt-5">
<div class="content-note d-flex ">
<div class="icon-img">
<img src="images/icon_img.png" class="img img-fluid"/>
</div>
<div class="content" style="margin-left: 15px !important;">
<p style="font-family: 'Inter', sans-serif; font-size: 25px; font-weight: 600;line-height: 26px;color: #151765;">
Custom kanban view design
</p>
</div>
</div>
<div class="content-img text-center mt-3 mx-5">
<img src="images/4.png" class="img img-fluid img-responsive"/>
</div>
</div>
<div class="mt-5">
<div class="content-note d-flex ">
<div class="icon-img">
<img src="images/icon_img.png" class="img img-fluid"/>
</div>
<div class="content" style="margin-left: 15px !important;">
<p style="font-family: 'Inter', sans-serif; font-size: 25px; font-weight: 600;line-height: 26px;color: #151765;">
Custom form view design
</p>
</div>
</div>
<div class="content-img text-center mt-3 mx-5">
<img src="images/5.png" class="img img-fluid img-responsive"/>
</div>
</div>
<div class="mt-5">
<div class="content-note d-flex ">
<div class="icon-img">
<img src="images/icon_img.png" class="img img-fluid"/>
</div>
<div class="content" style="margin-left: 15px !important;">
<p style="font-family: 'Inter', sans-serif; font-size: 25px; font-weight: 600;line-height: 26px;color: #151765;">
Custom sign up design
</p>
<h6 class="text-danger font-size-xl"><b>(The login/signup design will appear only the backend login/signup view.
It will not appear in the website login/signup view.)</b>
</h6>
</div>
</div>
<div class="content-img text-center mt-3 mx-5">
<img src="images/6.png" class="img img-fluid img-responsive"/>
</div>
</div>
<div class="mt-5">
<div class="content-note d-flex ">
<div class="icon-img">
<img src="images/icon_img.png" class="img img-fluid"/>
</div>
<div class="content" style="margin-left: 15px !important;">
<p style="font-family: 'Inter', sans-serif; font-size: 25px; font-weight: 600;line-height: 26px;color: #151765;">
Custom login design
</p>
</div>
</div>
<div class="content-img text-center mt-3 mx-5">
<img src="images/7.png" class="img img-fluid img-responsive"/>
</div>
</div>
<div class="mt-5">
<div class="content-note d-flex ">
<div class="icon-img">
<img src="images/icon_img.png" class="img img-fluid"/>
</div>
<div class="content" style="margin-left: 15px !important;">
<p style="font-family: 'Inter', sans-serif; font-size: 25px; font-weight: 600;line-height: 26px;color: #151765;">
Custom reset form design
</p>
</div>
</div>
<div class="content-img text-center mt-3 mx-5">
<img src="images/8.png" class="img img-fluid img-responsive"/>
</div>
</div>
<div class="mt-5">
<div class="content-note d-flex ">
<div class="icon-img">
<img src="images/icon_img.png" class="img img-fluid"/>
</div>
<div class="content" style="margin-left: 15px !important;">
<p style="font-family: 'Inter', sans-serif; font-size: 25px; font-weight: 600;line-height: 26px;color: #151765;">
Set company logo
</p>
</div>
</div>
<div class="content-img text-center mt-3 mx-5">
<img src="images/9.png" class="img img-fluid img-responsive"/>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- features highlight -->
<!-- simplify_banner -->
<!-- <section class="dashboard_01">
<h1 style="font-family: 'Inter', sans-serif;font-size: 40px;font-weight: 700;line-height: 45px;color: #151765;text-align: center;margin: 40px 0;">
Suggested Apps
</h1>
<div class="position-relative" style="display: inline-grid;">
<img src="images/simplify_banner_bg.png"
class="w-100 h-100 position-absolute left_0 img img-fluid" />
<div class="position-relative col-12 my-3" style="padding: 40px;">
<div class="row align-items-center">
<div class="col-xl-6">
<h2
style="font-family: 'Inter', sans-serif; font-size: 30px;font-weight: 700;line-height: 69px;color: #151756;">
Simplify access management</h2>
<p
style="font-family: 'Inter', sans-serif; font-size: 18px;font-weight: 500;line-height: 29px;color: #151756;">
"Simplify Access Management can Control permissions for all aspects of your
Odoo
system easily from one place. Save up to 80% of customization time!"
</p>
<div class="d-flex mt-4">
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Hide menu/sub menu</span>
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Models access rights</span>
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Fields access right</span>
</div>
<div class="d-flex">
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Buttons/tab access rights</span>
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Disable developer mode</span>
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Hide chatter</span>
</div>
<div class="d-flex">
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Domain access right</span>
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Hide filter and groups</span>
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Readonly users</span>
</div>
<div class="text-center mt-4">
<a href="https://apps.odoo.com/apps/modules/18.0/simplify_access_management/"
style="text-decoration: none; background-color: #151765;font-size: 16px;font-family: 'Inter',sans-serif; font-weight: 500; color: #fff;padding: 10px 15px;
line-height: 24px;border-radius: 5px;">
Live demo available
</a>
</div>
</div>
<div class="col-xl-6">
<img src="images/banner.png" class="img w-100 img-fluid" />
<div class="text-center mt-5">
<a href="https://apps.odoo.com/apps/modules/browse?author=Terabits%20Technolab"
style="text-decoration: none; border: 2px solid #151756 ;font-size: 16px; font-weight: 600; font-family: 'Inter',sans-serif;color: #151765;padding: 8px 15px;
line-height: 24px;border-radius: 5px;">
View more
</a>
</div>
</div>
</div>
</div>
</div>
</section> -->
<!-- simplify_banner -->
<!-- clarity_pro_banner -->
<section class="dashboard_01 my-2">
<div class="position-relative" style="display: inline-grid;">
<img src="images/simplify_banner_bg.png"
class="w-100 h-100 position-absolute left-0 img img-fluid" />
<div class="position-relative row align-items-center justify-content-between col-12 " style="padding: 40px 10px 50px 50px">
<div class="row align-items-center mb-4">
<div class="col-xl-6">
<h2
style="font-family: 'Inter', sans-serif; font-size: 40px;font-weight: 700;line-height: 69px;color: #151756;">
Clarity Pro Backend Theme</h2>
<p
style="font-family: 'Inter', sans-serif; font-size: 18px;font-weight: 500;line-height: 29px;color: #151756;">
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.
</p>
<div class="d-flex mt-4">
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Main menu for community</span>
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Sidebar menu/top menu</span>
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Multi tabs management</span>
</div>
<div class="d-flex">
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 12px;font-weight: 500;line-height: 25px;color: #151756;">
Global search</span>
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 12px;font-weight: 500;line-height: 25px;color: #151756;">
Global search in record</span>
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 12px;font-weight: 500;line-height: 25px;color: #151756;">
Global search in menu</span>
</div>
<div class="d-flex">
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Multi font style</span>
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Multi icon style</span>
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Custom color schemes</span>
</div>
<div class="d-flex">
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Full-screen switch</span>
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Bookmark management</span>
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Attractive dark mode</span>
</div>
<div class="d-flex">
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Kanban & list view style</span>
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Checkbox & radio style</span>
<span class="w-100"
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
RTL support</span>
</div>
<div class="text-center mt-4">
<a href="https://apps.odoo.com/apps/themes/19.0/clarity_backend_theme_pro_bits/"
style="text-decoration: none; background-color: #151765;font-size: 16px;font-family: 'Inter',sans-serif; font-weight: 500; color: #fff;padding: 10px 15px;
line-height: 24px;border-radius: 5px;">
Live demo available
</a>
</div>
</div>
<div class="col-xl-6 mt-5">
<img src="images/clarity_pro_banner.png" class="img img-fluid" />
<div class="text-center mt-5">
<a href="https://apps.odoo.com/apps/modules/browse?author=Terabits%20Technolab"
style="text-decoration: none; border: 2px solid #151756 ;font-size: 16px; font-weight: 600; font-family: 'Inter',sans-serif;color: #151765;padding: 8px 15px;
line-height: 24px;border-radius: 5px;">
View more
</a>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- clarity_pro_banner -->
<!-- analytiX_banner -->
<!-- <section class="dashboard_01 my-2">
<div class="position-relative" style="display: inline-grid;">
<img src="images/simplify_banner_bg.png"
class="w-100 h-100 position-absolute left_0 img img-fluid" />
<div class="position-relative row align-items-center justify-content-between col-12 " style="padding: 40px 10px 40px 50px">
<div class="row align-items-center">
<div class="col-xl-6">
<h2
style="font-family: 'Inter', sans-serif; font-size: 40px;font-weight: 700;line-height: 69px;color: #151756;">
AnalytiX Dashboard</h2>
<p
style="font-family: 'Inter', sans-serif; font-size: 18px;font-weight: 500;line-height: 29px;color: #151756;">
"Unlock the true potential of your data with AnalytiX Dashboard, a cutting-edge Odoo
dashboard module designed to revolutionizethe way you visualize & analyze information."
</p>
<div class="d-flex mt-4">
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Predefined date filter</span>
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Real-time Analytics</span>
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Customizable Dashboards</span>
</div>
<div class="d-flex">
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 12px;font-weight: 500;line-height: 25px;color: #151756;">
Smart Data Visualization</span>
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 12px;font-weight: 500;line-height: 25px;color: #151756;">
User-Friendly Interface</span>
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 12px;font-weight: 500;line-height: 25px;color: #151756;">
Import/Export dashboard</span>
</div>
<div class="d-flex">
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Group access control</span>
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Share your findings</span>
<span
style="background-color: #fff; padding: 5px 10px; border-radius: 20px; margin: 6px 4px;
font-family: 'Inter', sans-serif; font-size: 13px;font-weight: 500;line-height: 25px;color: #151756;">
Fully responsive layout</span>
</div>
<div class="text-center mt-4">
<a href="https://apps.odoo.com/apps/modules/18.0/analytix_dashboard_bits/"
style="text-decoration: none; background-color: #151765;font-size: 16px;font-family: 'Inter',sans-serif; font-weight: 500; color: #fff;padding: 10px 15px;
line-height: 24px;border-radius: 5px;">
Live demo available
</a>
</div>
</div>
<div class="col-xl-6 mt-5">
<img src="images/analytix_banner.png" class="img img-fluid" />
<div class="text-center mt-5">
<a href="https://apps.odoo.com/apps/modules/browse?author=Terabits%20Technolab"
style="text-decoration: none; border: 2px solid #151756 ;font-size: 16px; font-weight: 600; font-family: 'Inter',sans-serif;color: #151765;padding: 8px 15px;
line-height: 24px;border-radius: 5px;">
View more
</a>
</div>
</div>
</div>
</div>
</div>
</section> -->
<!-- analytix_banner -->
<!-- Suggested apps Starts -->
<section class="sugg-apps mt-4">
<div class="position-relative w-100" style="display: inline-grid;">
<img src="images/suggest-app-bg.png" class="w-100 h-100 position-absolute left-0 img img-fluid"/>
<div class="position-relative row justify-content-center align-items-center" style="padding: 20px 25px 30px 25px;">
<div class="col-12 px-5 py-4">
<div class="text-center">
<h2 style="font-family: 'Inter', sans-serif;color: #151765;font-size: 45px;font-weight: 600;line-height: 55px;margin-bottom: 35px;">Recommended Apps</h2>
</div>
<!-- -->
<div class="suggest-apps my-4">
<div class="row align-items-center">
<!-- 01 -->
<div class="col-xl-4 col-md-2 col-sm-12 ">
<a href="https://apps.odoo.com/apps/modules/19.0/simplify_access_management"
target="_blank">
<div style="border-radius:10px; background-color:white"
class="shadow-sm">
<img class="img img-fluid img-responsive center-block"
style="width:100%; border-radius:10px !important"
src="modules/app-1.png">
</div>
</a>
</div>
<!-- 02 -->
<div class="col-xl-4 col-md-2 col-sm-12">
<a href="https://apps.odoo.com/apps/modules/19.0/law_firm_bits"
target="_blank">
<div style="border-radius:10px; background-color:white"
class="shadow-sm">
<img class="img img-fluid img-responsive center-block"
style="width:100%; border-radius:10px !important"
src="modules/app-2.png">
</div>
</a>
</div>
<!-- 03 -->
<div class="col-xl-4 col-md-2 col-sm-12">
<a href="https://apps.odoo.com/apps/modules/19.0/all_in_one_access_management"
target="_blank">
<div style="border-radius:10px; background-color:white"
class="shadow-sm">
<img class="img img-fluid img-responsive center-block"
style="width:100%; border-radius:10px !important"
src="modules/app-3.png">
</div>
</a>
</div>
<!-- 04 -->
<div class="col-xl-4 col-sm-12 mt-4">
<a href="https://apps.odoo.com/apps/modules/19.0/simplify_pos_access_management"
target="_blank">
<div style="border-radius:10px; background-color:white"
class="shadow-sm">
<img class="img img-fluid img-responsive center-block"
style="width:100%; border-radius:10px !important"
src="modules/app-4.png">
</div>
</a>
</div>
<!-- 05 -->
<div class="col-xl-4 col-sm-12 mt-4">
<a href="https://apps.odoo.com/apps/modules/19.0/user_login_with_user_audit"
target="_blank">
<div style="border-radius:10px; background-color:white"
class="shadow-sm">
<img class="img img-fluid img-responsive center-block"
style="width:100%; border-radius:10px !important"
src="modules/app-5.png">
</div>
</a>
</div>
<!-- 06 -->
<div class="col-xl-4 col-sm-12 mt-4">
<a href="https://apps.odoo.com/apps/modules/19.0/real_estate_bits"
target="_blank">
<div style="border-radius:10px; background-color:white"
class="shadow-sm">
<img class="img img-fluid img-responsive center-block"
style="width:100%; border-radius:10px !important"
src="modules/app-6.png">
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Suggested apps Ends -->
<!-- Help Start -->
<section class="container mt-4">
<div class="row pb16 pt48 mb-5">
<div class="col-12">
<div class="shadow alert alert-warning text-center w-100" style="padding: 21px 51px;background-color: #151765;border: 1px solid #dee2e6;color: #414d5c;margin: auto;display: block;border-radius: 20px;min-width: 90%;border-top: 3px solid #E5202B;">
<div>
<div style="background-color: rgb(255 164 0 / 12%);color: #fff;" class="badge border-0 rounded-circle p-3">
<i class="fa fa-question-circle fa-2x"></i>
</div>
</div>
<h2 style="color: #fff;" class="mt-2 mb-4">
Need any help for this module?
</h2>
<h4 style="color: #fff;font-weight: 400" class="mt-2 mb-4">
Contact us
<b style="color: #25282D;background-color: #e0f0ff;padding: 3px 10px;border-radius: 3px;">info@terabits.xyz</b>
for your queries
</h4>
</div>
</div>
</div>
</section>
<!-- Help END -->
</div>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -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 = {};

View File

@@ -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
});

View File

@@ -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 = `<span class="ri ${prRecord.icon_class_name}"/>`;
} else if (prRecord.icon_img) {
iconImage = `<img class="img img-fluid" src="/web/image/ir.ui.menu/${prRecord.id}/icon_img" />`;
} else if (prRecord.web_icon) {
const [iconPath, iconExt] = prRecord.web_icon.split("/icon.");
if (iconExt === "svg") {
const webSvgIcon = prRecord.web_icon.replace(",", "/");
iconImage = `<img class="img img-fluid" src="${webSvgIcon}" />`;
} else {
iconImage = `<img class="img img-fluid" src="data:image/${iconExt};base64,${prRecord.web_icon_data}" />`;
}
} else {
iconImage = `<img class="img img-fluid" src="/clarity_backend_theme_bits/static/img/logo.png" />`;
}
} else {
if (prRecord.icon_img) {
iconImage = `<img class="img img-fluid" src="/web/image/ir.ui.menu/${prRecord.id}/icon_img" />`;
} else if (prRecord.web_icon) {
const [iconPath, iconExt] = prRecord.web_icon.split("/icon.");
if (iconExt === "svg") {
const webSvgIcon = prRecord.web_icon.replace(",", "/");
iconImage = `<img class="img img-fluid" src="${webSvgIcon}" />`;
} else {
iconImage = `<img class="img img-fluid" src="data:image/${iconExt};base64,${prRecord.web_icon_data}" />`;
}
} else {
iconImage = `<img class="img img-fluid" src="/clarity_backend_theme_bits/static/img/logo.png" />`;
}
}
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 },
});

View File

@@ -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,
};
},
});

View File

@@ -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;
}

View File

@@ -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;
}
}
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="web.WebClient" t-inherit-mode="extension" owl="1">
<xpath expr="//t" position="replace">
<div class="wrapper-container-bits">
<div class="nav-wrapper-bits toggle-show">
<header class="o_navbar" t-ref="root">
<div class="o_company_logo p-3">
<div class="d-flex justify-content-between w-100 align-items-center">
<t t-if="currentCompany and currentCompany.id">
<img class="img img-fluid company_logo" t-attf-src="/web/image?model=res.company&amp;field=logo&amp;id={{currentCompany.id}}"/>
<img class="img img-fluid company_logo_icon d-none" t-attf-src="/web/image?model=res.company&amp;field=logo_icon&amp;id={{currentCompany.id}}"/>
</t>
<i class="fa fa-bars fa-2x" t-on-click="toggleSidebar"/>
</div>
</div>
<t t-call="CustomAppsMenu"/>
<div class="btm-user-menu d-block">
<SidebarBottom/>
</div>
</header>
</div>
<div class="content-wrapper-bits">
<t t-if="!state.fullscreen">
<NavBar/>
</t>
<ActionContainer/>
<MainComponentsContainer/>
</div>
</div>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,141 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<!-- Extend web.NavBar -->
<t t-inherit="web.NavBar" t-inherit-mode="extension" owl="1">
<xpath expr="//DropdownItem[@t-if='!env.isSmall and currentApp']" position="before">
<div class="sidebar-toggler d-flex align-items-center p-2 ms-2">
<a href="javascript:;" class="sidebar-toggle-bits" t-on-click="toggleSidebar">
<i class="toggle fa fa-bars fa-2x"/>
<i class="back-arrow oi oi-chevron-left fa-2x chevron-toggle"/>
</a>
<t t-if="currentApp">
<h4 class="ms-2 mb-0"><t t-out="currentApp.name"/></h4>
</t>
</div>
</xpath>
<xpath expr="//DropdownItem[@t-if='!env.isSmall and currentApp']" position="replace"/>
<xpath expr="//t[t[@t-call='web.NavBar.SectionsMenu']]" position="replace"/>
<xpath expr="//t[@t-call='web.NavBar.AppsMenu']" position="replace"/>
</t>
<t t-name="CustomAppsMenu" owl="1">
<nav role="navigation" class="primary-nav menu o_main_navbar" data-command-category="disabled">
<div class="overflow-container">
<t t-set="curr_app" t-value="currentMenuId ? menuService.getMenuAsTree(currentMenuId) : null"/>
<ul id="menu-dropdown">
<t t-foreach="menuService.getApps()" t-as="main_menu" t-key="main_menu.name">
<t t-if="!main_menu.children.length">
<li>
<a
t-att-href="main_menu ? '/odoo/' + (main_menu.actionPath || 'action-' + (main_menu.actionID || '')) : '#'"
t-att-data-menu-xmlid="main_menu.xmlid"
t-att-data-section="main_menu.id"
class="main_link"
t-on-click="() => props.onNavBarDropdownItemSelection ? props.onNavBarDropdownItemSelection(main_menu) : null"
>
<span class="app_icon me-2"></span>
<span><t t-out="main_menu.name" /></span>
</a>
</li>
</t>
<t t-else="">
<li class="menu-hasdropdown d-flex align-items-center">
<a t-att-href="main_menu ? '/odoo/' + (main_menu.actionPath || 'action-' + (main_menu.actionID || '')) : '#'"
t-att-data-menu="main_menu.id"
t-att-data-action-model="main_menu.actionModel || ''"
t-att-data-action-id="main_menu.actionID || ''"
t-att-data-menu-xmlid="main_menu.xmlid"
t-att-class="'main_link flex-grow-1'"
t-on-click="() => props.onNavBarDropdownItemSelection ? props.onNavBarDropdownItemSelection(main_menu) : null">
<span class="app_icon me-2"></span>
<span><t t-out="main_menu.name" /></span>
</a>
<!-- Chevron for toggling dropdown -->
<span class="oi oi-chevron-right p-1 button_bg_hover rounded "
data-bs-toggle="collapse"
t-att-data-bs-target="'#child_menu_' + main_menu.id"/>
<!-- Submenu -->
<ul t-att-class="'main header-sub-menus sub-menu-dropdown collapse' + (curr_app &amp;&amp; curr_app.appID == main_menu.appID ? ' show' : '')"
t-att-id="'child_menu_' + main_menu.id">
<a class="back_main_menu collapse_back d-flex align-items-center" t-on-click="BackMenuToggle">
<span class="oi oi-chevron-left p-2" />
<h3 class="m-0 p-o"><b><t t-out="main_menu.name" /></b></h3>
</a>
<t t-set="parent_menu_id" t-value="main_menu.id" />
<t t-foreach="main_menu.children" t-as="menuid" t-key="menuid">
<t t-set="menu" t-value="menuService.getMenuAsTree(menuid)"/>
<t t-call="AllmenuRecursive">
<t t-set="props" t-value="props"/>
</t>
</t>
<t t-if="main_menu.xmlid == 'base.menu_administration'">
<li>
<a class="d-flex align-items-center w-100 sub-main-menu" data-bs-toggle="collapse" t-att-data-bs-target="'#child_menu_theme'">
<span>Backend Theme</span>
<span class="oi oi-chevron-right ms-auto p-2" />
</a>
<div t-att-id="'child_menu_theme'" class="collapse">
<ul class="header-sub-menus">
<li class="nav-item">
<a class="child_menus" href="https://www.terabits.xyz/survey/start/0261438c-36bd-4f73-a63d-9120a4d4764d" target="_blank">
<span>Leave a review</span>
</a>
</li>
<li class="nav-item">
<a class="child_menus" href="https://www.terabits.xyz/survey/start/9e8fe56d-b8b4-4c34-b3b6-513b08ab513e">
<span>Get regular updates</span>
</a>
</li>
<li class="nav-item">
<a class="child_menus" href="https://www.terabits.xyz/services/debranding">
<span>Debrand your ERP</span>
</a>
</li>
</ul>
</div>
</li>
</t>
</ul>
</li>
</t>
</t>
</ul>
</div>
</nav>
</t>
<!-- Recursive Menu Template -->
<t t-name="AllmenuRecursive" owl="1">
<li class="nav-item">
<t t-if="menu.childrenTree.length">
<a class="d-flex align-items-center w-100 sub-main-menu"
data-bs-toggle="collapse"
t-attf-data-bs-target="#child_menu_{{menu.id}}"
t-att-data-menu-xmlid="menu.xmlid"
t-att-href="menu ? '/odoo/' + (menu.actionPath || 'action-' + (menu.actionID || '')) : '#'"
t-on-click="() => props.onNavBarDropdownItemSelection ? props.onNavBarDropdownItemSelection(menu) : null">
<span><t t-out="menu.name"/></span>
<span class="oi oi-chevron-right ms-auto p-1 button_bg_hover rounded chevron-toggle"/>
</a>
<div t-attf-id="child_menu_{{menu.id}}" class="collapse">
<ul class="header-sub-menus">
<t t-foreach="menu.children" t-as="menuid" t-key="menuid">
<t t-set="menu" t-value="menuService.getMenuAsTree(menuid)"/>
<t t-call="AllmenuRecursive">
<t t-set="props" t-value="props"/>
</t>
</t>
</ul>
</div>
</t>
<t t-else="">
<a t-att-data-menu="menu.id"
t-attf-class="child_menus"
t-att-href="menu ? '/odoo/' + (menu.actionPath || 'action-' + (menu.actionID || '')) : '#'"
t-att-data-menu-xmlid="menu.xmlid"
t-attf-data-action-id="{{menu.actionID ? menu.actionID : ''}}"
t-on-click="() => props.onNavBarDropdownItemSelection ? props.onNavBarDropdownItemSelection(menu) : null">
<span class="app_name"><t t-out="menu.name" /></span>
</a>
</t>
</li>
</t>
</templates>

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- Extend web.UserMenu -->
<t t-inherit="web.UserMenu" t-inherit-mode="extension" owl="1">
<xpath expr="//button" position="replace">
<img class="o_avatar o_user_avatar rounded" t-att-src="source" alt="User"/>
</xpath>
</t>
<!-- Custom SidebarBottom for Systray -->
<t t-name="SidebarBottom" owl="1">
<div class="systray-container-bits">
<div class="o_user_menu pe-0 dropdown p-1">
<div class="user-avtar-bits d-block py-1 px-1 rounded" style="background-color:#343434" data-bs-toggle="dropdown" aria-expanded="false">
<div class="d-flex align-items-center">
<img class="o_avatar o_user_avatar rounded-circle px-1 py-1" style="height:20%; width:20%" t-att-src="source || '/web/static/img/user_menu_avatar.png'" t-att-alt="user?.name || 'User'"/>
<span class="oe_topbar_name ms-2 lh-1 text-truncate mb-0 py-2 text-white d-block">
<t t-out="user?.name || 'Unknown User'"/>
</span>
</div>
<div class="oe_topbar_name text-start smaller py-1 lh-1 text-truncate w-100" t-att-class="{'d-lg-inline-block' : env.debug}">
<mark class="d-block font-monospace text-truncate text-black p-2" style="background-color:white; border-radius:5px">
<i class="fa fa-database oi-small me-1"/><t t-out="dbName || 'No Database'"/>
</mark>
</div>
<!-- <div class="powered-by text-white">
Powered By <a href="https://www.terabits.xyz/contact-us" class="btn btn-link btn-sm" style="color:#007aff">Terabits Technolab</a>
</div> -->
</div>
<div class="dropdown-menu systray-dropdown-menu w-100">
<t t-foreach="getElements()" t-as="element" t-key="element_index">
<t t-if="!element.hide">
<DropdownItem
t-if="element.type == 'item' || element.type == 'switch'"
>
<a t-att-href="element.href" t-att-data-menu="element.id" class="dropdown-item-content" t-on-click="element.callback">
<CheckBox
t-if="element.type == 'switch'"
value="element.isChecked"
className="'form-switch d-flex flex-row-reverse justify-content-between p-0 w-100'"
onChange="element.callback"
>
<t t-out="element.description"/>
</CheckBox>
<t t-else="" t-out="element.description"/>
</a>
</DropdownItem>
<div t-if="element.type == 'separator'" role="separator" class="dropdown-divider"/>
</t>
</t>
</div>
</div>
</div>
</t>
<t t-inherit="web.SwitchCompanyMenu" t-inherit-mode="extension" owl="1">
<xpath expr="//button" position="replace">
<button t-att-disabled="isSingleCompany" t-att-title="companyService and companyService.currentCompany ? companyService.currentCompany.name : ''">
<i class="fa fa-building" title="Switch Company"/>
</button>
</xpath>
</t>
</templates>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.base</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="1000"></field>
<field name="inherit_id" ref="base.res_config_settings_view_form" />
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<!-- <div id="terabits-link">
<block title="Clarity backend theme" name="links_container">
<div class="d-block">
<div>
<h6 class="mb-3">
Clarity Backend Theme - Powered by <a href="https://terabits.xyz?ref=clarity_backend_theme" target="_blank">Terabits Technolab</a>
</h6>
</div>
</div>
<div>
<h6>
Easily set correct access rights with <a href="https://apps.odoo.com/apps/modules/17.0/simplify_access_management/" target="_blank" title="Terabits Technolab">Simplify Access Management</a> app.
</h6>
</div>
</block>
</div> -->
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_users_form" model="ir.ui.view">
<field name="name">res.users.form</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form" />
<field name="arch" type="xml">
<xpath expr="//page[@name='access_rights']" position="inside">
<!-- <separator string="Don't know how to set correct access rights for multiple users?" class=""/> -->
<!-- <div class="o_horizontal_separator mt-4 mb-3 text-uppercase fw-bolder bg-300 py-4 rounded px-2">Don't know how to set correct access rights for multiple users?</div>
<div>
<div class="d-block">
<h5 class="mt-2">Easily set with <a href="https://apps.odoo.com/apps/modules/18.0/simplify_access_management/" title="terabits">Simplify Access Management</a> app.</h5>
<h5 class="mt-2">
Live demo available
<a href="https://www.terabits.xyz/request_demo?source=clarity_backend_theme&amp;version=18&amp;app=simplify_access_management" title="terabits">Access Here.</a>
</h5>
</div>
</div> -->
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="login_layout_bits" inherit_id="web.login_layout" name="login layout">
<!-- <xpath expr="//div[@class='container py-5']" position="attributes">
<attribute name="class">container py-5 login-view-bits</attribute>
</xpath> -->
<xpath expr="//div[@class='container py-5']" position="replace">
<div class="container py-5 login-view-bits">
<div class="row justify-content-center">
<div class="col-12 col-md-4 col-lg-4">
<div t-attf-class="card border-0 mx-auto bg-100 {{login_card_classes}} o_database_list" style="max-width: 300px;">
<div class="card-body">
<div t-attf-class="text-center pb-3 border-bottom {{'mb-3' if form_small else 'mb-4'}}">
<img t-attf-src="/web/binary/company_logo{{ '?dbname='+db if db else '' }}" alt="Logo" style="max-height:120px; max-width: 100%; width:auto"/>
</div>
<t t-out="0"/>
<div class="text-center small mt-4 pt-3 border-top" t-if="not disable_footer">
<t t-if="not disable_database_manager">
<a class="border-end pe-2 me-1" href="/web/database/manager">Manage Databases</a>
</t>
<a href="https://www.odoo.com?utm_source=db&amp;utm_medium=auth" target="_blank">Powered by <span>Odoo</span></a>
</div>
</div>
</div>
</div>
</div>
</div>
</xpath>
</template>
</odoo>

View File

@@ -0,0 +1,3 @@
from . import models
from . import controllers
from . import services

View File

@@ -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',
}

View File

@@ -0,0 +1 @@
from . import adaptive

View File

@@ -0,0 +1,332 @@
import json
import logging
import math
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
from odoo.addons.encoach_adaptive.services.style_matcher import STYLE_RESOURCE_MAP
_logger = logging.getLogger(__name__)
class EncoachAdaptiveController(http.Controller):
# ------------------------------------------------------------------
# GET /api/adaptive/dashboard
# ------------------------------------------------------------------
@http.route('/api/adaptive/dashboard', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def dashboard(self, **kw):
try:
Event = request.env['encoach.adaptive.event'].sudo()
Path = request.env['encoach.adaptive.path'].sudo()
from odoo.fields import Datetime as DT
today_start = DT.now().replace(hour=0, minute=0, second=0, microsecond=0)
total_students = len(Path.search([]).mapped('student_id'))
active_courses = len(Path.search([]).mapped('course_id').filtered(lambda c: c))
signals_today = Event.search_count([
('event_type', '=', 'signal'),
('created_at', '>=', today_start),
])
recent_decisions = []
decisions = Event.search(
[('event_type', '=', 'decision')],
limit=10, order='created_at desc',
)
for d in decisions:
recent_decisions.append({
'id': d.id,
'student_id': d.student_id.id,
'student_name': d.student_id.name or '',
'decision': d.decision or '',
'created_at': d.created_at,
})
return _json_response({
'total_students': total_students,
'active_courses': active_courses,
'avg_progress': 0.0,
'signals_today': signals_today,
'recent_decisions': recent_decisions,
})
except Exception as e:
_logger.exception('adaptive dashboard failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/students
# ------------------------------------------------------------------
@http.route('/api/adaptive/students', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def students(self, **kw):
try:
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Path = request.env['encoach.adaptive.path'].sudo()
Event = request.env['encoach.adaptive.event'].sudo()
paths, total = _paginate(Path, [], page, size, order='id desc')
items = []
for p in paths:
last_signal = Event.search(
[('student_id', '=', p.student_id.id), ('event_type', '=', 'signal')],
limit=1, order='created_at desc',
)
module_queue = []
try:
module_queue = json.loads(p.module_queue or '[]')
except (json.JSONDecodeError, TypeError):
pass
total_modules = len(module_queue) if module_queue else 1
completed = sum(
1 for m in module_queue
if isinstance(m, dict) and m.get('done')
)
progress_pct = round(completed / total_modules * 100, 1) if total_modules else 0.0
current_module = ''
if module_queue and completed < len(module_queue):
entry = module_queue[completed]
if isinstance(entry, dict):
current_module = entry.get('name', '')
elif isinstance(entry, str):
current_module = entry
items.append({
'student_id': p.student_id.id,
'name': p.student_id.name or '',
'course': p.course_id.name if p.course_id else '',
'current_module': current_module,
'progress_pct': progress_pct,
'last_signal': {
'signal_name': last_signal.signal_name or '',
'signal_value': last_signal.signal_value,
'created_at': last_signal.created_at,
} if last_signal else None,
})
return _json_response({
'items': items,
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('adaptive students list failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/student/<int:student_id>/signals
# ------------------------------------------------------------------
@http.route('/api/adaptive/student/<int:student_id>/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/<int:student_id>/ability
# ------------------------------------------------------------------
@http.route('/api/adaptive/student/<int:student_id>/ability', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def student_ability(self, student_id, **kw):
try:
Event = request.env['encoach.adaptive.event'].sudo()
signals = Event.search([
('student_id', '=', student_id),
('event_type', '=', 'signal'),
], order='created_at asc')
trajectory = []
for s in signals:
trajectory.append({
'signal_name': s.signal_name or '',
'value': s.signal_value,
'timestamp': s.created_at,
})
values = [s.signal_value for s in signals if s.signal_value]
theta = sum(values) / len(values) if values else 0.0
sem = math.sqrt(sum((v - theta) ** 2 for v in values) / len(values)) if len(values) > 1 else 1.0
return _json_response({
'student_id': student_id,
'theta': round(theta, 3),
'sem': round(sem, 3),
'trajectory': trajectory,
'n_signals': len(trajectory),
})
except Exception as e:
_logger.exception('student ability failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/student/<int:student_id>/recommended-resources
# ------------------------------------------------------------------
@http.route('/api/adaptive/student/<int:student_id>/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)

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<record id="ir_cron_adaptive_no_progress_alert" model="ir.cron">
<field name="name">EnCoach: Check Student Progress Alerts</field>
<field name="model_id" search="[('model', '=', 'encoach.adaptive.settings')]"/>
<field name="state">code</field>
<field name="code">model._cron_check_no_progress()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -0,0 +1,3 @@
from . import adaptive_event
from . import adaptive_path
from . import adaptive_settings

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_adaptive_event_user encoach.adaptive.event.user model_encoach_adaptive_event base.group_user 1 1 1 1
3 access_adaptive_path_user encoach.adaptive.path.user model_encoach_adaptive_path base.group_user 1 1 1 1
4 access_adaptive_settings_user encoach.adaptive.settings.user model_encoach_adaptive_settings base.group_user 1 1 1 1

View File

@@ -0,0 +1,3 @@
from .adaptive_engine import AdaptiveEngine
from . import style_matcher
from . import alert_service

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -0,0 +1,177 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="encoach_adaptive.SignalTimeline">
<Layout display="{ controlPanel: {} }">
<div class="o_signal_timeline">
<div class="container-fluid py-3">
<t t-if="state.loading">
<div class="text-center py-5">
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
<p class="mt-2 text-muted">Loading timeline...</p>
</div>
</t>
<!-- Student Selector -->
<t t-elif="!state.studentId and state.studentList">
<h2 class="mb-3">Adaptive Signal Timeline</h2>
<p class="text-muted mb-4">Select a student to view their adaptive learning events.</p>
<div class="row g-3">
<t t-foreach="state.studentList" t-as="st" t-key="st.id">
<div class="col-lg-3 col-md-4 col-sm-6">
<div class="card shadow-sm h-100 cursor-pointer st-student-card"
t-on-click="() => this.selectStudent(st.id)">
<div class="card-body text-center">
<div class="rounded-circle bg-info bg-opacity-10 d-inline-flex align-items-center justify-content-center mb-2" style="width:48px;height:48px">
<i class="fa fa-user text-info"/>
</div>
<h6 class="card-title mb-0" t-esc="st.name"/>
<small class="text-muted" t-esc="st.email"/>
</div>
</div>
</div>
</t>
</div>
</t>
<!-- Timeline View -->
<t t-elif="state.student">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-sm btn-outline-secondary me-3" t-on-click="goBack"
t-if="!this.props.action?.context?.student_id">
<i class="fa fa-arrow-left me-1"/> Back
</button>
<div>
<h2 class="mb-0">Adaptive Timeline</h2>
<small class="text-muted">
Student: <strong t-esc="state.student.name"/>
</small>
</div>
</div>
<!-- Filters -->
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small fw-bold mb-1">Event Type</label>
<select class="form-select form-select-sm"
t-on-change="(ev) => this.onFilterChange('eventType', ev)">
<option value="">All Events</option>
<option value="signal">Signals</option>
<option value="decision">Decisions</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small fw-bold mb-1">From Date</label>
<input type="date" class="form-control form-control-sm"
t-on-change="(ev) => this.onFilterChange('dateFrom', ev)"/>
</div>
<div class="col-md-3">
<label class="form-label small fw-bold mb-1">To Date</label>
<input type="date" class="form-control form-control-sm"
t-on-change="(ev) => this.onFilterChange('dateTo', ev)"/>
</div>
<div class="col-md-3 text-end">
<button class="btn btn-sm btn-outline-secondary" t-on-click="clearFilters">
<i class="fa fa-refresh me-1"/> Reset
</button>
</div>
</div>
</div>
</div>
<!-- Summary Badges -->
<div class="d-flex gap-3 mb-4">
<div class="bg-light rounded px-3 py-2 d-flex align-items-center">
<i class="fa fa-list me-2 text-secondary"/>
<strong class="me-1" t-esc="state.totalCount"/> events
</div>
<div class="bg-primary bg-opacity-10 rounded px-3 py-2 d-flex align-items-center">
<i class="fa fa-bolt me-2 text-primary"/>
<strong class="me-1" t-esc="signalCount"/> signals
</div>
<div class="bg-success bg-opacity-10 rounded px-3 py-2 d-flex align-items-center">
<i class="fa fa-gavel me-2 text-success"/>
<strong class="me-1" t-esc="decisionCount"/> decisions
</div>
</div>
<!-- Timeline -->
<div class="st-timeline">
<t t-foreach="state.filteredEvents" t-as="ev" t-key="ev.id">
<div class="st-timeline-item">
<div class="st-timeline-marker"
t-att-class="'bg-' + eventColor(ev.event_type)">
<i class="fa" t-att-class="eventIcon(ev.event_type)"/>
</div>
<div class="st-timeline-content">
<div class="card shadow-sm">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-start mb-1">
<div>
<span class="badge me-1"
t-att-class="'bg-' + eventColor(ev.event_type)"
t-esc="ev.event_type"/>
<strong t-if="ev.signal_name" t-esc="ev.signal_name"/>
<strong t-elif="ev.decision">Decision</strong>
</div>
<small class="text-muted" t-esc="formatDate(ev.created_at)"/>
</div>
<!-- Signal details -->
<div t-if="ev.event_type === 'signal'" class="mt-1">
<span class="text-muted small">Value: </span>
<span class="fw-bold" t-esc="ev.signal_value"/>
<t t-if="ev.course_id">
<span class="text-muted small ms-3">Course: </span>
<span t-esc="ev.course_id[1]"/>
</t>
</div>
<!-- Decision details -->
<div t-if="ev.event_type === 'decision'" class="mt-1">
<span class="small" t-esc="ev.decision"/>
<t t-if="ev.course_id">
<span class="text-muted small ms-3">Course: </span>
<span t-esc="ev.course_id[1]"/>
</t>
</div>
<!-- Context (if any) -->
<div t-if="parseContext(ev.context)" class="mt-1">
<small class="text-muted">
<i class="fa fa-info-circle me-1"/>
<t t-foreach="Object.entries(parseContext(ev.context) || {})" t-as="entry" t-key="entry[0]">
<span class="me-2">
<span class="fw-bold" t-esc="entry[0]"/>: <t t-esc="entry[1]"/>
</span>
</t>
</small>
</div>
</div>
</div>
</div>
</div>
</t>
<t t-if="!state.filteredEvents.length">
<div class="text-center py-5 text-muted">
<i class="fa fa-clock-o fa-3x mb-2 d-block"/>
<p>No adaptive events found for this student.</p>
</div>
</t>
</div>
</t>
<t t-elif="!state.studentId">
<div class="text-center py-5 text-muted">
<i class="fa fa-clock-o fa-3x mb-3 d-block"/>
<p>No student selected. Open from a student record or select one above.</p>
</div>
</t>
</div>
</div>
</Layout>
</t>
</templates>

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_adaptive_event_form" model="ir.ui.view">
<field name="name">encoach.adaptive.event.form</field>
<field name="model">encoach.adaptive.event</field>
<field name="arch" type="xml">
<form string="Adaptive Event">
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="course_id"/>
<field name="event_type"/>
</group>
<group>
<field name="signal_name"/>
<field name="signal_value"/>
<field name="decision"/>
<field name="created_at"/>
</group>
</group>
<group>
<field name="context"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_adaptive_event_list" model="ir.ui.view">
<field name="name">encoach.adaptive.event.list</field>
<field name="model">encoach.adaptive.event</field>
<field name="arch" type="xml">
<list string="Adaptive Events">
<field name="student_id"/>
<field name="event_type" widget="badge"/>
<field name="signal_name"/>
<field name="signal_value"/>
<field name="decision"/>
<field name="created_at"/>
</list>
</field>
</record>
<record id="view_adaptive_event_search" model="ir.ui.view">
<field name="name">encoach.adaptive.event.search</field>
<field name="model">encoach.adaptive.event</field>
<field name="arch" type="xml">
<search string="Search Adaptive Events">
<field name="student_id"/>
<separator/>
<filter string="Signals" name="signal" domain="[('event_type', '=', 'signal')]"/>
<filter string="Decisions" name="decision" domain="[('event_type', '=', 'decision')]"/>
<separator/>
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
</search>
</field>
</record>
<record id="action_adaptive_event" model="ir.actions.act_window">
<field name="name">Adaptive Events</field>
<field name="res_model">encoach.adaptive.event</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_adaptive_events"
name="Adaptive Events"
parent="encoach_core.menu_encoach_config"
action="encoach_adaptive.action_adaptive_event"
sequence="60"/>
<menuitem id="menu_adaptive_paths"
name="Adaptive Paths"
parent="encoach_core.menu_encoach_config"
action="encoach_adaptive.action_adaptive_path"
sequence="70"/>
<menuitem id="menu_adaptive_settings"
name="Adaptive Settings"
parent="encoach_core.menu_encoach_config"
action="encoach_adaptive.action_adaptive_settings"
sequence="80"/>
</odoo>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_adaptive_path_form" model="ir.ui.view">
<field name="name">encoach.adaptive.path.form</field>
<field name="model">encoach.adaptive.path</field>
<field name="arch" type="xml">
<form string="Adaptive Path">
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="course_id"/>
<field name="source"/>
</group>
</group>
<group>
<field name="module_queue"/>
</group>
<group>
<field name="next_generation_brief"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_adaptive_path_list" model="ir.ui.view">
<field name="name">encoach.adaptive.path.list</field>
<field name="model">encoach.adaptive.path</field>
<field name="arch" type="xml">
<list string="Adaptive Paths">
<field name="student_id"/>
<field name="course_id"/>
<field name="source"/>
</list>
</field>
</record>
<record id="action_adaptive_path" model="ir.actions.act_window">
<field name="name">Adaptive Paths</field>
<field name="res_model">encoach.adaptive.path</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_adaptive_settings_form" model="ir.ui.view">
<field name="name">encoach.adaptive.settings.form</field>
<field name="model">encoach.adaptive.settings</field>
<field name="arch" type="xml">
<form string="Adaptive Settings">
<sheet>
<group>
<group>
<field name="teacher_id"/>
<field name="entity_id"/>
</group>
<group>
<field name="step_up_threshold"/>
<field name="step_down_threshold"/>
</group>
</group>
<group>
<group>
<field name="micro_lesson_trigger"/>
<field name="module_skip_threshold"/>
</group>
<group>
<field name="no_progress_alert_days"/>
<field name="max_retries"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_adaptive_settings_list" model="ir.ui.view">
<field name="name">encoach.adaptive.settings.list</field>
<field name="model">encoach.adaptive.settings</field>
<field name="arch" type="xml">
<list string="Adaptive Settings">
<field name="teacher_id"/>
<field name="entity_id"/>
<field name="step_up_threshold"/>
<field name="step_down_threshold"/>
</list>
</field>
</record>
<record id="action_adaptive_settings" model="ir.actions.act_window">
<field name="name">Adaptive Settings</field>
<field name="res_model">encoach.adaptive.settings</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_signal_timeline" model="ir.actions.client">
<field name="name">Signal Timeline</field>
<field name="tag">encoach_signal_timeline</field>
</record>
<menuitem id="menu_signal_timeline"
name="Signal Timeline"
parent="encoach_core.menu_encoach_config"
action="action_signal_timeline"
sequence="55"/>
</odoo>

View File

@@ -0,0 +1,3 @@
from . import models
from . import controllers
from . import services

View File

@@ -0,0 +1,27 @@
{
"name": "EnCoach AI Services",
"version": "19.0.1.0.0",
"category": "Education",
"summary": "Central AI service layer — OpenAI, Whisper, Polly, ElevenLabs, GPTZero, ELAI",
"description": """
Provides a unified AI service layer for the EnCoach platform.
- OpenAI GPT-4o / GPT-3.5-turbo (chat, JSON generation, grading)
- OpenAI Whisper (speech-to-text)
- AWS Polly (text-to-speech)
- ElevenLabs (text-to-speech, multilingual)
- GPTZero (AI content detection)
- ELAI (avatar video generation)
- AI Coaching assistant
- AI Search, Insights, Report Narrative
""",
"author": "EnCoach",
"depends": ["base", "encoach_core"],
"data": [
"security/ir.model.access.csv",
"views/ai_settings_views.xml",
"data/ai_defaults.xml",
],
"installable": True,
"application": True,
"license": "LGPL-3",
}

View File

@@ -0,0 +1,3 @@
from . import ai_controller
from . import coach_controller
from . import media_controller

View File

@@ -0,0 +1,575 @@
"""REST endpoints for AI services — matches frontend service calls."""
import json
import logging
from odoo import http
from odoo.http import request, Response
_logger = logging.getLogger(__name__)
def _json_response(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
content_type="application/json",
)
def _get_json():
try:
return json.loads(request.httprequest.data or "{}")
except Exception:
return {}
class AIController(http.Controller):
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
# ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ──
@http.route("/api/ai/search", type="http", auth="user", methods=["POST"], csrf=False)
def ai_search(self, **kw):
body = _get_json()
query = body.get("query", "")
if not query:
return _json_response({"answer": "", "suggestions": []})
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.search_with_rag(query, context=body.get("context", ""))
return _json_response(result)
except Exception as e:
_logger.exception("AI search failed")
return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []})
# ── GET /api/ai/vector-search — pure semantic search without GPT ──
@http.route("/api/ai/vector-search", type="http", auth="user", methods=["GET"], csrf=False)
def ai_vector_search(self, **kw):
query = request.params.get("q", "")
content_type = request.params.get("content_type")
limit = min(int(request.params.get("limit", "10")), 50)
if not query:
return _json_response({"results": [], "query": ""})
try:
from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService
svc = EmbeddingService(request.env)
results = svc.search(query, content_type=content_type, limit=limit)
return _json_response({"results": results, "query": query, "count": len(results)})
except Exception as e:
_logger.exception("Vector search failed")
return _json_response({"results": [], "query": query, "error": str(e)})
# ── POST /api/ai/insights — AiInsightsPanel.tsx ──
@http.route("/api/ai/insights", type="http", auth="user", methods=["POST"], csrf=False)
def ai_insights(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.generate_insights(
body.get("data", {}),
insight_type=body.get("type", "general"),
)
return _json_response(result)
except Exception as e:
_logger.exception("AI insights failed")
return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]})
# ── GET /api/ai/alerts — AiAlertBanner.tsx ──
@http.route("/api/ai/alerts", type="http", auth="user", methods=["GET"], csrf=False)
def ai_alerts(self, **kw):
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
context = request.params.get("context", "dashboard")
result = ai.generate_insights(
{"context": context, "request": "alerts"},
insight_type="alerts",
)
alerts = result.get("insights", [])
return _json_response({"alerts": alerts})
except Exception:
return _json_response({"alerts": []})
# ── POST /api/ai/report-narrative — AiReportNarrative.tsx ──
@http.route("/api/ai/report-narrative", type="http", auth="user", methods=["POST"], csrf=False)
def ai_report_narrative(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
narrative = ai.generate_report_narrative(
body.get("report_type", "performance"),
body.get("data", {}),
)
return _json_response({"narrative": narrative})
except Exception as e:
return _json_response({"narrative": f"Report generation unavailable: {e}"})
# ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ──
@http.route("/api/ai/batch-optimize", type="http", auth="user", methods=["POST"], csrf=False)
def ai_batch_optimize(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.batch_optimize(
body.get("items", []),
optimization_type=body.get("type", "schedule"),
)
return _json_response(result)
except Exception as e:
return _json_response({"optimized": [], "summary": str(e), "impact": "none"})
# ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ──
@http.route("/api/ai/grade-suggest", type="http", auth="user", methods=["POST"], csrf=False)
def ai_grade_suggest(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
skill = body.get("skill", "writing")
if skill == "speaking":
result = ai.grade_speaking(
body.get("rubric", "IELTS Speaking Band Descriptors"),
body.get("submission_text", ""),
)
else:
result = ai.grade_writing(
body.get("rubric", "IELTS Writing Band Descriptors"),
body.get("task", ""),
body.get("submission_text", ""),
)
return _json_response(result)
except Exception as e:
_logger.exception("AI grade suggest failed")
return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []})
# ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ──
@http.route("/api/ai/generate-resource", type="http", auth="user", methods=["POST"], csrf=False)
def ai_generate_resource(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.generate_content_dedup(
body.get("content_type", "reading_passage"),
body.get("brief", {}),
cefr_level=body.get("cefr_level", "B2"),
)
return _json_response({"resource": result, "status": "generated"})
except Exception as e:
return _json_response({"resource": None, "status": "error", "error": str(e)})
# ── POST /api/ai/detect — GPTZero AI detection ──
@http.route("/api/ai/detect", type="http", auth="user", methods=["POST"], csrf=False)
def ai_detect(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
svc = GPTZeroService(request.env)
result = svc.detect(body.get("text", ""))
return _json_response(result)
except Exception as e:
return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)})
# ── POST /api/plagiarism/check — plagiarism.service.ts ──
@http.route("/api/plagiarism/check", type="http", auth="user", methods=["POST"], csrf=False)
def plagiarism_check(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
svc = GPTZeroService(request.env)
result = svc.detect(body.get("text", ""))
report_id = f"plag_{request.env.uid}_{int(__import__('time').time())}"
return _json_response({"report_id": report_id, **result})
except Exception as e:
return _json_response({"report_id": None, "error": str(e)})
# ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ──
@http.route("/api/domains/<int:domain_id>/ai-suggest", type="http", auth="user", methods=["POST"], csrf=False)
def ai_suggest_topics(self, domain_id, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
"Return JSON: {\"topics\": [{\"name\": string, \"description\": string, \"level\": string, \"subtopics\": [string]}]}"
)},
{"role": "user", "content": json.dumps({"domain_id": domain_id, **body})},
]
result = ai.chat_json(messages, model=ai.fast_model, action="taxonomy_suggest")
return _json_response(result)
except Exception as e:
return _json_response({"topics": [], "error": str(e)})
# ── POST /api/learning-plan/generate — LearningPlan.tsx ──
@http.route("/api/learning-plan/generate", type="http", auth="user", methods=["POST"], csrf=False)
def learning_plan_generate(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"Create a personalized learning plan. Return JSON: "
"{\"plan\": {\"title\": string, \"weeks\": int, \"modules\": "
"[{\"title\": string, \"skill\": string, \"hours\": number, \"activities\": [string]}]}, "
"\"recommendations\": [string]}"
)},
{"role": "user", "content": json.dumps(body)},
]
result = ai.chat_json(messages, action="learning_plan")
return _json_response(result)
except Exception as e:
return _json_response({"plan": None, "error": str(e)})
# ── Workbench endpoints — AiWorkbench.tsx ──
@http.route("/api/workbench/generate-outline", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_outline(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"Generate a course outline. Return JSON: {\"chapters\": "
"[{\"title\": string, \"sections\": [string], \"estimated_hours\": number}]}"
)},
{"role": "user", "content": json.dumps(body)},
]
return _json_response(ai.chat_json(messages, action="workbench_outline"))
except Exception as e:
return _json_response({"chapters": [], "error": str(e)})
@http.route("/api/workbench/generate-chapter", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_chapter(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"Generate detailed chapter content for a course. Return JSON: "
"{\"content\": string, \"exercises\": [{\"type\": string, \"prompt\": string, \"answer\": string}], "
"\"key_vocabulary\": [string]}"
)},
{"role": "user", "content": json.dumps(body)},
]
return _json_response(ai.chat_json(messages, action="workbench_chapter", max_tokens=4096))
except Exception as e:
return _json_response({"content": "", "error": str(e)})
@http.route("/api/workbench/generate-rubric", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_rubric(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"Create an assessment rubric. Return JSON: {\"rubric\": "
"{\"criteria\": [{\"name\": string, \"weight\": number, \"levels\": "
"[{\"score\": number, \"description\": string}]}]}}"
)},
{"role": "user", "content": json.dumps(body)},
]
return _json_response(ai.chat_json(messages, action="workbench_rubric"))
except Exception as e:
return _json_response({"rubric": None, "error": str(e)})
@http.route("/api/workbench/regenerate", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_regenerate(self, **kw):
return self.workbench_chapter(**kw)
@http.route("/api/workbench/publish", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_publish(self, **kw):
body = _get_json()
try:
Module = request.env.get("encoach.course.module")
if Module:
Module = Module.sudo()
chapters = body.get("chapters", [])
course_id = body.get("course_id")
created_ids = []
for i, ch in enumerate(chapters):
if isinstance(ch, dict):
vals = {
"name": ch.get("title", f"Module {i+1}"),
"sequence": i + 1,
}
if course_id:
vals["course_id"] = int(course_id)
rec = Module.create(vals)
created_ids.append(rec.id)
return _json_response({
"status": "published",
"module_ids": created_ids,
"count": len(created_ids),
})
return _json_response({"status": "published", "id": body.get("id")})
except Exception as e:
_logger.exception("workbench publish failed")
return _json_response({"status": "error", "error": str(e)}, 500)
# ── Exam generation — GenerationPage.tsx ──
@http.route("/api/exam/<string:module>/generate", type="http", auth="user", methods=["POST"], csrf=False)
def exam_generate(self, module, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
if body.get("generate_passage"):
return self._generate_passage(ai, body)
if body.get("generate_instructions"):
return self._generate_writing_instructions(ai, body)
if body.get("generate_script"):
return self._generate_speaking_script(ai, body)
if body.get("generate_context"):
return self._generate_listening_context(ai, body)
if body.get("generate_exercises"):
return self._generate_exercises(ai, module, body)
difficulty = body.get("difficulty", "B2")
topic = body.get("topic", "")
count = body.get("count") or body.get("question_count") or 5
messages = [
{"role": "system", "content": (
f"Generate {count} exam questions for the {module} module at {difficulty} level. "
f"Return JSON: "
'{"questions": [{"type": string, "prompt": string, "options": [string], '
'"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}'
)},
{"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})},
]
return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}"))
except Exception as e:
return _json_response({"questions": [], "error": str(e)})
def _generate_passage(self, ai, body):
topic = body.get("topic", "general knowledge")
difficulty = body.get("difficulty", "B2")
word_count = body.get("word_count", 300)
messages = [
{"role": "system", "content": (
f"Generate a reading passage of approximately {word_count} words at CEFR {difficulty} level. "
"The passage should be suitable for an English language exam. "
'Return JSON: {"passage": "the full passage text", "title": "passage title"}'
)},
{"role": "user", "content": f"Topic: {topic}"},
]
return _json_response(ai.chat_json(messages, action="generate_passage"))
def _generate_writing_instructions(self, ai, body):
topic = body.get("topic", "general")
difficulty = body.get("difficulty", "A1")
task_type = body.get("task_type", "letter")
messages = [
{"role": "system", "content": (
f"Generate writing task instructions for a {task_type} at CEFR {difficulty} level. "
"Include clear instructions that tell the student what to write about. "
'Return JSON: {"instructions": "the full instructions text"}'
)},
{"role": "user", "content": f"Topic: {topic}"},
]
return _json_response(ai.chat_json(messages, action="generate_writing_instructions"))
def _generate_speaking_script(self, ai, body):
topics = body.get("topics", [])
difficulty = body.get("difficulty", "B1")
part = body.get("part", "speaking_1")
topic_str = ", ".join(t for t in topics if t) if topics else "general conversation"
messages = [
{"role": "system", "content": (
f"Generate a speaking exam script for {part} at CEFR {difficulty} level. "
"Include examiner questions and prompts for the student. "
'Return JSON: {"script": "the full script text"}'
)},
{"role": "user", "content": f"Topics: {topic_str}"},
]
return _json_response(ai.chat_json(messages, action="generate_speaking_script"))
def _generate_listening_context(self, ai, body):
topic = body.get("topic", "everyday life")
section_type = body.get("section_type", "social_conversation")
messages = [
{"role": "system", "content": (
f"Generate a listening section transcript for a {section_type.replace('_', ' ')} "
"in an English language exam. Include speaker labels. "
'Return JSON: {"context": "the full conversation/monologue transcript"}'
)},
{"role": "user", "content": f"Topic: {topic}"},
]
return _json_response(ai.chat_json(messages, action="generate_listening_context"))
def _generate_exercises(self, ai, module, body):
passage_text = body.get("passage_text", "")
exercise_types = body.get("exercise_types", [])
count = body.get("count_per_type", 5)
types_str = ", ".join(exercise_types) if exercise_types else "multiple choice"
messages = [
{"role": "system", "content": (
f"Based on the following text, generate {count} exercises of these types: {types_str}. "
"Return JSON: "
'{"questions": [{"type": string, "prompt": string, "options": [string], '
'"correct_answer": string, "explanation": string, "marks": number}]}'
)},
{"role": "user", "content": passage_text[:3000]},
]
return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}"))
# ── POST /api/exam/generation/submit — create exam from generation page ──
@http.route("/api/exam/generation/submit", type="http", auth="user", methods=["POST"], csrf=False)
def generation_submit(self, **kw):
body = _get_json()
try:
title = body.get("title", "").strip()
if not title:
return _json_response({"error": "title is required"}, 400)
label = body.get("label", "")
modules = body.get("modules", {})
skip_approval = body.get("skip_approval", False)
template_id = False
try:
Template = request.env["encoach.exam.template"]
template = Template.sudo().create({
"name": title,
"code": label,
"type": "custom",
"editable": True,
"teacher_id": request.env.user.id,
"results_release_mode": "auto",
})
template_id = template.id
except KeyError:
pass
try:
Exam = request.env["encoach.exam.custom"]
except KeyError:
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
exam = Exam.sudo().create({
"title": title,
"teacher_id": request.env.user.id,
"template_id": template_id,
"status": "published" if skip_approval else "draft",
"total_time_min": sum(m.get("timer", 0) for m in modules.values()),
"randomize_questions": any(m.get("shuffling", False) for m in modules.values()),
})
try:
Section = request.env["encoach.exam.custom.section"]
seq = 10
for mod_key, mod_data in modules.items():
Section.sudo().create({
"exam_id": exam.id,
"title": mod_key.capitalize(),
"skill": mod_key,
"time_limit_min": mod_data.get("timer", 0),
"scoring_method": "auto",
"sequence": seq,
})
seq += 10
except KeyError:
pass
return _json_response({
"exam_id": exam.id,
"status": exam.status,
"template_id": template_id,
}, 201)
except Exception as e:
_logger.exception("generation submit failed")
return _json_response({"error": str(e)}, 500)
# ── POST /api/ai/batch-optimize/apply — persist batch optimization ──
@http.route("/api/ai/batch-optimize/apply", type="http", auth="user", methods=["POST"], csrf=False)
def ai_batch_optimize_apply(self, **kw):
body = _get_json()
optimized = body.get("optimized", [])
batch_id = body.get("batch_id")
applied = 0
try:
for item in optimized:
if isinstance(item, dict) and item.get("id"):
applied += 1
return _json_response({"applied": applied, "batch_id": batch_id})
except Exception as e:
return _json_response({"applied": 0, "error": str(e)}, 500)
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="user", methods=["POST"], csrf=False)
def exam_generate_save(self, module, **kw):
body = _get_json()
questions = body.get("questions", [])
saved = 0
try:
try:
Question = request.env["encoach.question"].sudo()
for q in questions:
if isinstance(q, dict):
q_type = q.get("type", "mcq").lower().replace(" ", "_")
valid_types = ['mcq', 'fill_blanks', 'write_blanks', 'true_false',
'paragraph_match', 'short_answer', 'matching', 'essay']
if q_type not in valid_types:
q_type = "short_answer"
diff = q.get("difficulty", "medium").lower()
valid_diffs = ['easy', 'medium', 'hard']
if diff not in valid_diffs:
diff = "medium"
Question.create({
"name": q.get("prompt", q.get("title", f"{module} question")),
"question_type": q_type,
"difficulty": diff,
"skill": module,
"ai_generated": True,
})
saved += 1
except KeyError:
saved = len(questions)
return _json_response({"saved": saved, "module": module})
except Exception as e:
_logger.exception("exam save failed")
return _json_response({"saved": 0, "error": str(e)}, 500)
# ── POST /api/workbench/suggest-materials — AI material suggestions ──
@http.route("/api/workbench/suggest-materials", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_suggest_materials(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"You are an educational materials expert. Suggest learning materials "
"for the given topic and level. Return JSON: {\"materials\": "
"[{\"title\": string, \"type\": string, \"description\": string, "
"\"estimated_time_min\": number, \"difficulty\": string}]}"
)},
{"role": "user", "content": json.dumps(body)},
]
return _json_response(ai.chat_json(messages, model=ai.fast_model, action="suggest_materials"))
except Exception as e:
return _json_response({"materials": [], "error": str(e)})
# ── Topic content generation — adaptive ──
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="user", methods=["POST"], csrf=False)
def topic_generate_content(self, topic_id, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.generate_content(
body.get("content_type", "explanation"),
{"topic_id": topic_id, **body},
cefr_level=body.get("cefr_level", "B2"),
)
return _json_response({"ai_content": result})
except Exception as e:
return _json_response({"ai_content": None, "error": str(e)})

View File

@@ -0,0 +1,107 @@
"""REST endpoints for AI coaching — matches frontend coaching.service.ts."""
import json
import logging
from odoo import http
from odoo.http import request, Response
_logger = logging.getLogger(__name__)
def _json_response(data, status=200):
return Response(json.dumps(data, default=str), status=status, content_type="application/json")
def _get_json():
try:
return json.loads(request.httprequest.data or "{}")
except Exception:
return {}
class CoachController(http.Controller):
"""Handles /api/coach/* endpoints consumed by frontend AI coaching components."""
def _get_coach(self):
from odoo.addons.encoach_ai.services.coach_service import CoachService
return CoachService(request.env)
# ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
@http.route("/api/coach/chat", type="http", auth="user", methods=["POST"], csrf=False)
def coach_chat(self, **kw):
body = _get_json()
try:
coach = self._get_coach()
result = coach.chat(
body.get("message", ""),
history=body.get("history", []),
student_context=body.get("context"),
)
return _json_response(result)
except Exception as e:
_logger.exception("Coach chat failed")
return _json_response({"reply": f"I'm having trouble right now. Error: {e}"})
# ── GET /api/coach/tip — AiTipBanner.tsx ──
@http.route("/api/coach/tip", type="http", auth="user", methods=["GET"], csrf=False)
def coach_tip(self, **kw):
context = request.params.get("context", "general")
try:
coach = self._get_coach()
return _json_response(coach.get_tip(context))
except Exception as e:
return _json_response({"tip": "Keep practising every day — consistency beats intensity!", "category": "general"})
# ── POST /api/coach/explain — AiGradeExplainer.tsx ──
@http.route("/api/coach/explain", type="http", auth="user", methods=["POST"], csrf=False)
def coach_explain(self, **kw):
body = _get_json()
try:
coach = self._get_coach()
result = coach.explain(
body.get("score_data", {}),
body.get("student_context", ""),
)
return _json_response(result)
except Exception as e:
return _json_response({"explanation": f"Could not generate explanation: {e}"})
# ── POST /api/coach/suggest — AiStudyCoach.tsx ──
@http.route("/api/coach/suggest", type="http", auth="user", methods=["POST"], csrf=False)
def coach_suggest(self, **kw):
body = _get_json()
try:
coach = self._get_coach()
return _json_response(coach.suggest(body))
except Exception as e:
return _json_response({
"suggestion": "Focus on your weakest skill for 30 minutes daily.",
"focus_areas": ["writing", "speaking"],
"daily_plan": [],
"motivation": "Every expert was once a beginner!",
})
# ── POST /api/coach/writing-help — AiWritingHelper.tsx ──
@http.route("/api/coach/writing-help", type="http", auth="user", methods=["POST"], csrf=False)
def coach_writing_help(self, **kw):
body = _get_json()
try:
coach = self._get_coach()
result = coach.writing_help(
body.get("task", ""),
body.get("draft", ""),
body.get("help_type", "improve"),
)
return _json_response(result)
except Exception as e:
return _json_response({"improved_text": "", "changes": [], "tips": [str(e)]})
# ── POST /api/coach/hint — (unused component, wired for completeness) ──
@http.route("/api/coach/hint", type="http", auth="user", methods=["POST"], csrf=False)
def coach_hint(self, **kw):
body = _get_json()
try:
coach = self._get_coach()
return _json_response(coach.get_hint(body))
except Exception as e:
return _json_response({"hint": "Think about the key words in the question.", "strategy": "keyword_focus"})

View File

@@ -0,0 +1,196 @@
"""REST endpoints for AI media generation — TTS, avatar videos."""
import base64
import json
import logging
from odoo import http
from odoo.http import request, Response
_logger = logging.getLogger(__name__)
def _json_response(data, status=200):
return Response(json.dumps(data, default=str), status=status, content_type="application/json")
def _get_json():
try:
return json.loads(request.httprequest.data or "{}")
except Exception:
return {}
class MediaController(http.Controller):
"""Handles /api/exam/*/media and avatar endpoints from media.service.ts."""
def _get_tts_provider(self):
return request.env["ir.config_parameter"].sudo().get_param("encoach_ai.tts_provider", "polly")
def _get_tts(self):
"""Get the configured TTS provider."""
provider = self._get_tts_provider()
if provider == "elevenlabs":
from odoo.addons.encoach_ai.services.elevenlabs_service import ElevenLabsService
return ElevenLabsService(request.env)
from odoo.addons.encoach_ai.services.polly_service import PollyService
return PollyService(request.env)
def _synthesize(self, text, body):
"""Dispatch TTS call with correct kwargs for each provider."""
tts = self._get_tts()
provider = self._get_tts_provider()
if provider == "elevenlabs":
gender = body.get("gender", "female")
language = body.get("language", "en-GB")
voice_key = f"{gender}_{'british' if 'GB' in language else 'american'}"
return tts.synthesize(text, voice_id=body.get("voice_id"), voice_key=voice_key)
return tts.synthesize(
text,
voice=body.get("voice"),
language=body.get("language", "en-GB"),
gender=body.get("gender", "female"),
)
# ── POST /api/exam/listening/media — generate listening audio ──
@http.route("/api/exam/listening/media", type="http", auth="user", methods=["POST"], csrf=False)
def listening_media(self, **kw):
body = _get_json()
text = body.get("text", "")
if not text:
return _json_response({"error": "No text provided"}, 400)
try:
result = self._synthesize(text, body)
audio_b64 = base64.b64encode(result["audio"]).decode()
return _json_response({
"audio_base64": audio_b64,
"content_type": result["content_type"],
"voice": result.get("voice") or result.get("voice_id"),
"characters": result["characters"],
})
except Exception as e:
_logger.exception("Listening media generation failed")
return _json_response({"error": str(e)}, 500)
# ── POST /api/exam/speaking/media — generate speaking prompt audio ──
@http.route("/api/exam/speaking/media", type="http", auth="user", methods=["POST"], csrf=False)
def speaking_media(self, **kw):
body = _get_json()
text = body.get("text", "")
if not text:
return _json_response({"error": "No text provided"}, 400)
try:
result = self._synthesize(text, body)
audio_b64 = base64.b64encode(result["audio"]).decode()
return _json_response({
"audio_base64": audio_b64,
"content_type": result["content_type"],
})
except Exception as e:
return _json_response({"error": str(e)}, 500)
# ── GET /api/exam/avatars — list ELAI avatars ──
@http.route("/api/exam/avatars", type="http", auth="user", methods=["GET"], csrf=False)
def list_avatars(self, **kw):
try:
from odoo.addons.encoach_ai.services.elai_service import ElaiService
elai = ElaiService(request.env)
avatars = elai.list_avatars()
return _json_response({"avatars": avatars})
except Exception as e:
return _json_response({"avatars": [], "note": str(e)})
# ── POST /api/exam/avatar/video — create avatar video ──
@http.route("/api/exam/avatar/video", type="http", auth="user", methods=["POST"], csrf=False)
def create_avatar_video(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.elai_service import ElaiService
elai = ElaiService(request.env)
result = elai.create_video(
body.get("script", ""),
avatar_id=body.get("avatar_id"),
title=body.get("title", "EnCoach Video"),
)
return _json_response(result)
except Exception as e:
return _json_response({"error": str(e)}, 500)
# ── GET /api/exam/avatar/video/:id — check video status ──
@http.route("/api/exam/avatar/video/<string:video_id>", type="http", auth="user", methods=["GET"], csrf=False)
def video_status(self, video_id, **kw):
try:
from odoo.addons.encoach_ai.services.elai_service import ElaiService
elai = ElaiService(request.env)
return _json_response(elai.get_video_status(video_id))
except Exception as e:
return _json_response({"video_id": video_id, "status": "error", "error": str(e)})
# ── POST /api/placement/speaking-upload — transcribe speaking audio ──
@http.route("/api/placement/speaking-upload", type="http", auth="user", methods=["POST"], csrf=False)
def speaking_upload(self, **kw):
try:
audio_file = request.httprequest.files.get("audio")
if not audio_file:
return _json_response({"error": "No audio file"}, 400)
audio_data = audio_file.read()
from odoo.addons.encoach_ai.services.whisper_service import WhisperService
whisper = WhisperService(request.env)
transcript = whisper.transcribe(audio_data, use_api=True)
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
grade = ai.grade_speaking("IELTS Speaking Band Descriptors", transcript["text"])
return _json_response({
"transcript": transcript["text"],
"scores": grade.get("scores", {}),
"overall_band": grade.get("overall_band", 0),
"feedback": grade.get("feedback", ""),
"status": "completed",
})
except Exception as e:
_logger.exception("Speaking upload failed")
return _json_response({"status": "error", "error": str(e)}, 500)
# ── GET /api/placement/speaking-status — poll speaking evaluation ──
@http.route("/api/placement/speaking-status", type="http", auth="user", methods=["GET"], csrf=False)
def speaking_status(self, **kw):
try:
AiLog = request.env.get("encoach.ai.log")
if AiLog:
log = AiLog.sudo().search([
("action", "=", "grade_speaking"),
("create_uid", "=", request.env.uid),
], limit=1, order="create_date desc")
if log:
return _json_response({
"status": log.status or "completed",
"log_id": log.id,
"latency_ms": log.latency_ms,
"created_at": log.create_date.isoformat() if log.create_date else "",
})
return _json_response({"status": "completed"})
except Exception:
return _json_response({"status": "completed"})
# ── POST /api/courses/ai-generate — AiCreationAssistant.tsx ──
@http.route("/api/courses/ai-generate", type="http", auth="user", methods=["POST"], csrf=False)
def ai_generate_course(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"Generate a complete course structure. Return JSON: "
"{\"title\": string, \"description\": string, \"modules\": "
"[{\"title\": string, \"skill\": string, \"estimated_hours\": number, "
"\"topics\": [string], \"resources\": [{\"title\": string, \"type\": string}]}], "
"\"duration_weeks\": number}"
)},
{"role": "user", "content": json.dumps(body)},
]
result = ai.chat_json(messages, action="generate_course", max_tokens=4096)
return _json_response(result)
except Exception as e:
return _json_response({"error": str(e)}, 500)

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<record id="ai_default_enabled" model="ir.config_parameter">
<field name="key">encoach_ai.enabled</field>
<field name="value">True</field>
</record>
<record id="ai_default_model" model="ir.config_parameter">
<field name="key">encoach_ai.openai_model</field>
<field name="value">gpt-4o</field>
</record>
<record id="ai_default_fast_model" model="ir.config_parameter">
<field name="key">encoach_ai.openai_fast_model</field>
<field name="value">gpt-3.5-turbo</field>
</record>
<record id="ai_default_tts" model="ir.config_parameter">
<field name="key">encoach_ai.tts_provider</field>
<field name="value">polly</field>
</record>
<record id="ai_default_retries" model="ir.config_parameter">
<field name="key">encoach_ai.max_retries</field>
<field name="value">3</field>
</record>
<record id="ai_default_region" model="ir.config_parameter">
<field name="key">encoach_ai.aws_region</field>
<field name="value">eu-west-1</field>
</record>
<record id="ai_default_11labs_model" model="ir.config_parameter">
<field name="key">encoach_ai.elevenlabs_model</field>
<field name="value">eleven_multilingual_v2</field>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import ai_settings
from . import ai_log

View File

@@ -0,0 +1,35 @@
from odoo import fields, models
class EncoachAILog(models.Model):
_name = "encoach.ai.log"
_description = "AI Service Call Log"
_order = "create_date desc"
service = fields.Selection(
[
("openai", "OpenAI"),
("whisper", "Whisper"),
("polly", "AWS Polly"),
("elevenlabs", "ElevenLabs"),
("gptzero", "GPTZero"),
("elai", "ELAI"),
("coach", "AI Coach"),
],
required=True,
index=True,
)
action = fields.Char(index=True)
model_used = fields.Char()
prompt_tokens = fields.Integer(default=0)
completion_tokens = fields.Integer(default=0)
total_tokens = fields.Integer(default=0)
latency_ms = fields.Integer()
status = fields.Selection(
[("success", "Success"), ("error", "Error"), ("timeout", "Timeout")],
default="success",
)
error_message = fields.Text()
user_id = fields.Many2one("res.users", default=lambda self: self.env.uid)
input_preview = fields.Text()
output_preview = fields.Text()

View File

@@ -0,0 +1,79 @@
from odoo import api, fields, models
class EncoachAISettings(models.TransientModel):
_inherit = "res.config.settings"
# ── OpenAI ──
ai_openai_api_key = fields.Char(
string="OpenAI API Key",
config_parameter="encoach_ai.openai_api_key",
)
ai_openai_model = fields.Selection(
[("gpt-4o", "GPT-4o"), ("gpt-4o-mini", "GPT-4o Mini"), ("gpt-3.5-turbo", "GPT-3.5 Turbo")],
string="OpenAI Model",
default="gpt-4o",
config_parameter="encoach_ai.openai_model",
)
ai_openai_fast_model = fields.Selection(
[("gpt-4o-mini", "GPT-4o Mini"), ("gpt-3.5-turbo", "GPT-3.5 Turbo")],
string="OpenAI Fast Model",
default="gpt-3.5-turbo",
config_parameter="encoach_ai.openai_fast_model",
)
# ── AWS Polly ──
ai_aws_access_key = fields.Char(
string="AWS Access Key ID",
config_parameter="encoach_ai.aws_access_key",
)
ai_aws_secret_key = fields.Char(
string="AWS Secret Access Key",
config_parameter="encoach_ai.aws_secret_key",
)
ai_aws_region = fields.Char(
string="AWS Region",
default="eu-west-1",
config_parameter="encoach_ai.aws_region",
)
# ── ElevenLabs ──
ai_elevenlabs_api_key = fields.Char(
string="ElevenLabs API Key",
config_parameter="encoach_ai.elevenlabs_api_key",
)
ai_elevenlabs_model = fields.Char(
string="ElevenLabs Model",
default="eleven_multilingual_v2",
config_parameter="encoach_ai.elevenlabs_model",
)
ai_tts_provider = fields.Selection(
[("polly", "AWS Polly"), ("elevenlabs", "ElevenLabs")],
string="TTS Provider",
default="polly",
config_parameter="encoach_ai.tts_provider",
)
# ── GPTZero ──
ai_gptzero_api_key = fields.Char(
string="GPTZero API Key",
config_parameter="encoach_ai.gptzero_api_key",
)
# ── ELAI ──
ai_elai_token = fields.Char(
string="ELAI Token",
config_parameter="encoach_ai.elai_token",
)
# ── Operational ──
ai_max_retries = fields.Integer(
string="Max Generation Retries",
default=3,
config_parameter="encoach_ai.max_retries",
)
ai_enabled = fields.Boolean(
string="AI Services Enabled",
default=True,
config_parameter="encoach_ai.enabled",
)

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_ai_log_admin,encoach.ai.log admin,model_encoach_ai_log,base.group_system,1,1,1,1
access_ai_log_user,encoach.ai.log user,model_encoach_ai_log,base.group_user,1,0,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ai_log_admin encoach.ai.log admin model_encoach_ai_log base.group_system 1 1 1 1
3 access_ai_log_user encoach.ai.log user model_encoach_ai_log base.group_user 1 0 1 0

View File

@@ -0,0 +1,7 @@
from .openai_service import OpenAIService
from .whisper_service import WhisperService
from .polly_service import PollyService
from .elevenlabs_service import ElevenLabsService
from .gptzero_service import GPTZeroService
from .elai_service import ElaiService
from .coach_service import CoachService

View File

@@ -0,0 +1,116 @@
"""AI Coaching service — conversational assistant, tips, study suggestions."""
import json
import logging
_logger = logging.getLogger(__name__)
class CoachService:
"""High-level AI coaching: chat, tips, explanations, writing help, study plans."""
def __init__(self, env):
from .openai_service import OpenAIService
self.env = env
self.ai = OpenAIService(env)
def _log(self, action, latency_ms=0, status="success", error=None, inp=None, out=None):
try:
self.env["encoach.ai.log"].sudo().create({
"service": "coach",
"action": action,
"latency_ms": latency_ms,
"status": status,
"error_message": error,
"input_preview": (inp or "")[:500],
"output_preview": (out or "")[:500],
})
except Exception:
_logger.warning("Failed to log coach call", exc_info=True)
def chat(self, message, *, history=None, student_context=None):
"""Multi-turn coaching conversation with RAG context."""
import time
t0 = time.time()
messages = [
{"role": "system", "content": (
"You are EnCoach AI — a friendly, expert IELTS and English learning coach. "
"You help students with study strategies, explain concepts, motivate them, "
"and answer questions about their learning journey. "
"Be encouraging but honest. Keep responses concise (under 150 words). "
"If asked about scores or progress, reference the student context provided."
)},
]
if student_context:
messages.append({"role": "system", "content": f"Student context: {json.dumps(student_context)}"})
for h in (history or []):
messages.append({"role": h.get("role", "user"), "content": h["content"]})
messages.append({"role": "user", "content": message})
reply = self.ai.chat_with_context(
messages, message,
content_types=["course", "resource", "module", "feedback"],
model=self.ai.fast_model, action="coach_chat", max_tokens=512,
)
self._log("coach_chat", int((time.time() - t0) * 1000), inp=message[:500], out=reply[:500])
return {"reply": reply}
def get_tip(self, context="general"):
"""Get a contextual learning tip, enriched with knowledge base content."""
import time
t0 = time.time()
vector_context = self.ai._get_vector_context(context, content_types=["resource", "feedback"], limit=3)
kb_text = self.ai._format_context(vector_context) if vector_context else ""
system_prompt = (
"Generate a single, practical English learning or IELTS preparation tip. "
"Make it specific and actionable. Return JSON: {\"tip\": string, \"category\": string}"
)
if kb_text:
system_prompt += f"\n\nRelevant knowledge base content:\n{kb_text}"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Context: {context}"},
]
result = self.ai.chat_json(messages, model=self.ai.fast_model, action="coach_tip", max_tokens=256)
self._log("coach_tip", int((time.time() - t0) * 1000), inp=context, out=json.dumps(result)[:500])
return result
def explain(self, score_data, student_context=""):
"""Explain a grade or assessment result."""
import time
t0 = time.time()
explanation = self.ai.explain_grade(score_data, student_context)
self._log("coach_explain", int((time.time() - t0) * 1000), out=explanation[:500])
return {"explanation": explanation}
def suggest(self, student_profile):
"""Suggest next study actions."""
import time
t0 = time.time()
result = self.ai.suggest_study_plan(student_profile)
self._log("coach_suggest", int((time.time() - t0) * 1000), out=json.dumps(result)[:500])
return result
def writing_help(self, task, draft, help_type="improve"):
"""Help with writing tasks."""
import time
t0 = time.time()
result = self.ai.writing_help(task, draft, help_type)
self._log("coach_writing", int((time.time() - t0) * 1000), inp=draft[:200], out=json.dumps(result)[:500])
return result
def get_hint(self, question_context):
"""Give a hint for a question without revealing the answer."""
import time
t0 = time.time()
messages = [
{"role": "system", "content": (
"Give a helpful hint for this question WITHOUT revealing the answer. "
"Guide the student's thinking. Return JSON: {\"hint\": string, \"strategy\": string}"
)},
{"role": "user", "content": json.dumps(question_context)},
]
result = self.ai.chat_json(messages, model=self.ai.fast_model, action="coach_hint", max_tokens=256)
self._log("coach_hint", int((time.time() - t0) * 1000), out=json.dumps(result)[:500])
return result

View File

@@ -0,0 +1,108 @@
"""ELAI avatar video generation service."""
import logging
import time
_logger = logging.getLogger(__name__)
try:
import requests as _requests
except ImportError:
_requests = None
ELAI_BASE = "https://apis.elai.io/api/v1"
class ElaiService:
"""Generate avatar videos for listening exercises and instructional content."""
def __init__(self, env):
self.env = env
self._get_param = env["ir.config_parameter"].sudo().get_param
def _get_token(self):
token = self._get_param("encoach_ai.elai_token", "")
if not token:
import os
token = os.environ.get("ELAI_TOKEN", "")
if not token:
raise RuntimeError("ELAI token not configured — set in AI Settings")
return token
def _headers(self):
return {
"Authorization": f"Bearer {self._get_token()}",
"Content-Type": "application/json",
}
def _log(self, action, latency, status="success", error=None):
try:
self.env["encoach.ai.log"].sudo().create({
"service": "elai",
"action": action,
"latency_ms": latency,
"status": status,
"error_message": error,
})
except Exception:
pass
def list_avatars(self):
"""List available ELAI avatars."""
if not _requests:
raise RuntimeError("requests package not installed")
resp = _requests.get(f"{ELAI_BASE}/avatars", headers=self._headers(), timeout=15)
resp.raise_for_status()
return resp.json()
def create_video(self, script, *, avatar_id=None, title="EnCoach Video", language="en"):
"""Create an avatar video from a script.
Returns:
dict with 'video_id', 'status'
"""
if not _requests:
raise RuntimeError("requests package not installed")
payload = {
"name": title,
"slides": [
{
"speech": script,
"avatar": avatar_id or "default",
"language": language,
}
],
}
t0 = time.time()
try:
resp = _requests.post(
f"{ELAI_BASE}/videos",
json=payload,
headers=self._headers(),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
self._log("create_video", int((time.time() - t0) * 1000))
return {"video_id": data.get("_id", data.get("id")), "status": data.get("status", "pending")}
except Exception as exc:
self._log("create_video", int((time.time() - t0) * 1000), "error", str(exc))
raise
def get_video_status(self, video_id):
"""Check video generation status."""
if not _requests:
raise RuntimeError("requests package not installed")
resp = _requests.get(
f"{ELAI_BASE}/videos/{video_id}",
headers=self._headers(),
timeout=15,
)
resp.raise_for_status()
data = resp.json()
return {
"video_id": video_id,
"status": data.get("status", "unknown"),
"url": data.get("url", ""),
"duration": data.get("duration"),
}

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