3 Commits

Author SHA1 Message Date
Yamen Ahmad
b02ee8b6b7 feat: Generation Page AI workflows + AI/Vector modules + exam session fixes
Generation Page (complete rebuild):
- Full production-parity exam generation wizard with 4 IELTS modules
- Reading: AI passage gen, 5 exercise types (MCQ, Fill, Write, T/F, Match)
- Listening: 4 section types, AI context gen, TTS audio gen (ElevenLabs)
- Writing: Task 1/2, AI instruction gen, word limits, marks
- Speaking: 3 parts, AI script gen, avatar video gen (7 avatars)
- Per-module config: timer, CEFR difficulty, access, approval, rubrics
- Exam submission workflow (draft/published)

Exam Structures:
- New encoach.exam.structure model + CRUD controller
- ExamStructuresPage wired to real API

AI Module (encoach_ai):
- OpenAI service, ElevenLabs TTS, AWS Polly, ELAI avatars
- AI settings model with Odoo config parameters
- 7 generation endpoints (passage, exercises, instructions, scripts, context)

Vector Module (encoach_vector):
- pgvector integration for RAG-based content search
- Embedding service with sentence-transformers

Exam Session Fixes:
- Fixed ExamSession.tsx field mapping (question_type→type, exam_title→title)
- Fixed submit payload to include attempt_id and answers
- Fixed normalizeType to handle null/undefined

Tested: 12/12 API tests passed, browser-verified with real OpenAI calls
Made-with: Cursor
2026-04-11 14:27:03 +04:00
Yamen Ahmad
140ca7408d feat(generation): rebuild Generation Page with full AI workflows
- Rebuild GenerationPage.tsx from static placeholder to production-parity
  exam generation wizard with all 4 IELTS modules (Reading, Listening,
  Writing, Speaking) plus Level and Industry
- Add per-module config: timer, CEFR difficulty tags, access type,
  entities, approval workflow, rubric, grading system, shuffling
- Reading: AI passage generation, 5 exercise types (MCQ, Fill Blanks,
  Write Blanks, True/False, Paragraph Match), categories/types
- Listening: 4 section types, AI context generation, TTS audio generation
- Writing: Task 1/2, AI instruction generation, word limits, marks
- Speaking: 3 parts, AI script generation, avatar video generation
  with 7 avatar options
- Wire ExamStructuresPage to real CRUD API (list/create/delete)
- Add backend exam_structure model and controller (/api/exam-structures)
- Enhance ai_controller with 5 specialized generation handlers
  (passage, exercises, writing instructions, speaking script,
  listening context)
- Add POST /api/exam/generation/submit for exam creation workflow
- Fix media.service avatar video endpoint alignment
- All 12 API tests passed, browser-verified with real OpenAI calls

Made-with: Cursor
2026-04-11 14:21:40 +04:00
Yamen Ahmad
f1c4953a63 feat(v3): restructure project + add complete frontend
- Restructure: move backend from new_project/ to backend/
- Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks)
- Add docs/ with SRS specs, user stories, and workflow documentation
- Update .gitignore for new directory layout

Workflows implemented:
  WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration,
  WF4 General English Exam, WF5 Course Generation,
  WF6 Entity Student Onboarding, AI Course Generation,
  Adaptive Learning Engine UI, White-Label Branding, Score Release

Made-with: Cursor
2026-04-10 17:26:42 +04:00
764 changed files with 71703 additions and 231 deletions

5
.gitignore vendored
View File

@@ -30,6 +30,10 @@ Thumbs.db
# Frontend repo (separate repository)
new_project/encoach_frontend_new_v1/
# Node modules
node_modules/
frontend/node_modules/
# Local dev artifacts
miniconda3/
pgdata/
@@ -50,6 +54,7 @@ new_project/enterprise-17/
# Third-party modules (downloaded separately)
new_project/openeducat_erp-19.0/
backend/openeducat_erp-19.0/
new_project/openeducat_erp-19.0.zip
new_project/openeducate_enterprise-17.zip
new_project/encoach_frontend_new_v1-main.zip

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

@@ -1,5 +1,6 @@
import json
import logging
import math
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
@@ -164,6 +165,44 @@ class EncoachAdaptiveController(http.Controller):
_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
# ------------------------------------------------------------------

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

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