7 Commits

Author SHA1 Message Date
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
Yamen Ahmad
a3e12f62fa feat: complete 42/42 user story coverage with tested demo data
- Registration: add role field (academic_student/self_learner/teacher),
  return user_id, fix auth='public' for Odoo 19, add company_id
- Onboarding: add learning_style as JSON array with validation,
  placement_decision field, JSON-RPC routes for OWL wizard
- Student profile: add placement_decision selection, get/set_learning_styles
  helpers for JSON array storage
- Portal templates: fix course.description, mod.description,
  profile.study_preference, profile.entity_id.entity_type field references

Made-with: Cursor
2026-04-10 00:26:37 +04:00
Yamen Ahmad
5ff9f12de7 feat: complete all 12 missing spec features from EnCoach v3.0 audit
- Add student_progress, course_section models and module_resources M2M
- Activate encoach_resources module with security, views, and ielts_certified field
- Add password strength indicator JS on registration page
- Rewrite onboarding wizard step 4 as placement test prompt with skip option
- Dynamic goal list fetched from exam templates in DB with fallback
- B1 default CEFR level assigned when placement test is skipped
- Teacher review dashboard with 2 API endpoints for AI content approval
- IELTS examiner review interface with review_status, examiner_notes fields
- Content source gate: mandatory review for AI, spot-check for certified
- Resource-to-learning-style matching in adaptive engine
- Daily cron job for teacher no-progress alerts via mail.activity
- Entity student placement redirect on dashboard access
- Fix Odoo 19 compat: search view groups, ir.cron fields, OWL xml templates

Made-with: Cursor
2026-04-08 03:12:00 +04:00
Yamen Ahmad
e30e52e21e feat: add full-screen exam delivery UI with JWT auth fix
- Fix jwt_required decorator to set request.env user context via
  update_env() instead of passing user as kwarg (fixes null student_id
  on attempt creation for auth='none' API routes)
- Build standalone exam session page with timer, question navigator,
  auto-save, and instant score display (no dependency on web.frontend_layout)
- Add exam_delivery.js for client-side exam interaction (MCQ selection,
  section navigation, countdown, autosave, submit & results)
- Generate per-session JWT in controller for API calls from the exam page

Made-with: Cursor
2026-04-07 23:49:45 +04:00
Yamen Ahmad
d98cd55b99 fix: resolve 9 critical bugs from full local testing
- Migrate 5 OWL components from deprecated useService("rpc") to useService("orm") for Odoo 19
- Fix _paginate() signature mismatch across 6 controllers (dual calling convention)
- Fix taxonomy API serializers referencing non-existent fields (icon, difficulty)
- Fix branding controller validate_token() called with wrong arguments
- Add .sudo() to exam template/question model access under auth='none'
- Restore missing encoach_core files (security groups, permissions seed, core models)
- Create encoach_api shared controller utilities module
- Add Entity list/form/search views and menu items
- Add Taxonomy (Subjects/Domains/Topics) views and menu items
- Fix deprecated XML group expand="0" patterns across 15 view files
- Remove stale model references from adaptive __init__.py and access CSV
- Update .gitignore to exclude local dev artifacts

Tested: 97% pass rate (64/66 tests) across UI navigation, CRUD, and API endpoints.
Made-with: Cursor
2026-04-07 03:43:48 +04:00
Yamen Ahmad
6c93c5d600 feat: EnCoach V2 — complete OWL refactor with 15 new modules
Full architectural refactor from React to Odoo OWL:

- 15 new Odoo modules: signup, placement, exam_template, scoring,
  course_gen, entity_onboard, ai_course, quality_gate,
  ielts_validation, pdf_report, verification, math, it, portal,
  exam_session
- 6 modified modules: core (new user fields), exam (template types),
  adaptive (events/paths/settings), branding (white-label),
  resources (CEFR/AI fields), taxonomy (Math+IT hierarchies)
- ~79 new REST API endpoints across all controller packages
- ~50 admin backend views (form/list/kanban/search/menu)
- 5 custom OWL components: dashboard, content pool, exam validation,
  gap analysis, adaptive timeline
- POS-inspired full-screen exam session with 9 question renderers
- Student portal with 12 website pages (QWeb + OWL)
- AI/ML services: IRT 3PL CAT engine, CEFR mapper, quality gates,
  IELTS validator, SymPy math scorer, speaking evaluator, adaptive engine
- Seed data: IELTS/TOEFL/STEP/IC3 templates, 30+ sample questions,
  English/Math/IT taxonomy trees with 50+ topics
- Updated requirements.txt with new dependencies

Made-with: Cursor
2026-04-07 01:53:06 +04:00
a4b9ab62cf Merge pull request 'test: verify CI/CD pipeline end-to-end' (#1) from feature/test-pipeline into main 2026-03-30 17:44:08 +02:00
729 changed files with 93292 additions and 0 deletions

38
.gitignore vendored
View File

@@ -26,3 +26,41 @@ Thumbs.db
# Docker # Docker
*.tar *.tar
# Frontend repo (separate repository)
new_project/encoach_frontend_new_v1/
# Node modules
node_modules/
frontend/node_modules/
# Local dev artifacts
miniconda3/
pgdata/
.conda-envs/
.conda-pkgs/
# Odoo core source (cloned separately)
odoo/
# Local Odoo config and data
odoo.conf
/data/
# Enterprise / extra addons (not part of this repo)
addons_enterprise/
addons_extra/
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
# Local tools
new_project/.tools/
# Large binary archives
*.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

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

View File

@@ -0,0 +1,26 @@
{
'name': 'EnCoach Adaptive Learning',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'Proficiency tracking, learning plans, diagnostics, content cache',
'author': 'EnCoach',
'depends': ['encoach_core', 'encoach_taxonomy', 'mail'],
'data': [
'security/ir.model.access.csv',
'data/ir_cron.xml',
'views/adaptive_event_views.xml',
'views/adaptive_path_views.xml',
'views/adaptive_settings_views.xml',
'views/signal_timeline_action.xml',
'views/adaptive_menus.xml',
],
'assets': {
'web.assets_backend': [
'encoach_adaptive/static/src/js/signal_timeline.js',
'encoach_adaptive/static/src/xml/signal_timeline.xml',
'encoach_adaptive/static/src/css/signal_timeline.css',
],
},
'installable': True,
'license': 'LGPL-3',
}

View File

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

View File

@@ -0,0 +1,293 @@
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
from odoo.addons.encoach_adaptive.services.style_matcher import STYLE_RESOURCE_MAP
_logger = logging.getLogger(__name__)
class EncoachAdaptiveController(http.Controller):
# ------------------------------------------------------------------
# GET /api/adaptive/dashboard
# ------------------------------------------------------------------
@http.route('/api/adaptive/dashboard', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def dashboard(self, **kw):
try:
Event = request.env['encoach.adaptive.event'].sudo()
Path = request.env['encoach.adaptive.path'].sudo()
from odoo.fields import Datetime as DT
today_start = DT.now().replace(hour=0, minute=0, second=0, microsecond=0)
total_students = len(Path.search([]).mapped('student_id'))
active_courses = len(Path.search([]).mapped('course_id').filtered(lambda c: c))
signals_today = Event.search_count([
('event_type', '=', 'signal'),
('created_at', '>=', today_start),
])
recent_decisions = []
decisions = Event.search(
[('event_type', '=', 'decision')],
limit=10, order='created_at desc',
)
for d in decisions:
recent_decisions.append({
'id': d.id,
'student_id': d.student_id.id,
'student_name': d.student_id.name or '',
'decision': d.decision or '',
'created_at': d.created_at,
})
return _json_response({
'total_students': total_students,
'active_courses': active_courses,
'avg_progress': 0.0,
'signals_today': signals_today,
'recent_decisions': recent_decisions,
})
except Exception as e:
_logger.exception('adaptive dashboard failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/students
# ------------------------------------------------------------------
@http.route('/api/adaptive/students', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def students(self, **kw):
try:
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Path = request.env['encoach.adaptive.path'].sudo()
Event = request.env['encoach.adaptive.event'].sudo()
paths, total = _paginate(Path, [], page, size, order='id desc')
items = []
for p in paths:
last_signal = Event.search(
[('student_id', '=', p.student_id.id), ('event_type', '=', 'signal')],
limit=1, order='created_at desc',
)
module_queue = []
try:
module_queue = json.loads(p.module_queue or '[]')
except (json.JSONDecodeError, TypeError):
pass
total_modules = len(module_queue) if module_queue else 1
completed = sum(
1 for m in module_queue
if isinstance(m, dict) and m.get('done')
)
progress_pct = round(completed / total_modules * 100, 1) if total_modules else 0.0
current_module = ''
if module_queue and completed < len(module_queue):
entry = module_queue[completed]
if isinstance(entry, dict):
current_module = entry.get('name', '')
elif isinstance(entry, str):
current_module = entry
items.append({
'student_id': p.student_id.id,
'name': p.student_id.name or '',
'course': p.course_id.name if p.course_id else '',
'current_module': current_module,
'progress_pct': progress_pct,
'last_signal': {
'signal_name': last_signal.signal_name or '',
'signal_value': last_signal.signal_value,
'created_at': last_signal.created_at,
} if last_signal else None,
})
return _json_response({
'items': items,
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('adaptive students list failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/student/<int:student_id>/signals
# ------------------------------------------------------------------
@http.route('/api/adaptive/student/<int:student_id>/signals', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def student_signals(self, student_id, **kw):
try:
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Event = request.env['encoach.adaptive.event'].sudo()
domain = [('student_id', '=', student_id)]
events, total = _paginate(Event, domain, page, size, order='created_at desc')
items = []
for ev in events:
items.append({
'id': ev.id,
'event_type': ev.event_type,
'signal_name': ev.signal_name or '',
'signal_value': ev.signal_value,
'decision': ev.decision or '',
'created_at': ev.created_at,
})
return _json_response({
'items': items,
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('student signals failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/student/<int:student_id>/recommended-resources
# ------------------------------------------------------------------
@http.route('/api/adaptive/student/<int:student_id>/recommended-resources',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def recommended_resources(self, student_id, **kw):
try:
profile = request.env['encoach.student.profile'].sudo().search(
[('user_id', '=', student_id)], limit=1)
learning_style = profile.learning_style if profile else ''
# Get resources for student's current course/module
path = request.env['encoach.adaptive.path'].sudo().search(
[('student_id', '=', student_id)], limit=1, order='id desc')
resources = request.env['encoach.resource'].sudo().search(
[('active', '=', True), ('review_status', '=', 'approved')], limit=50)
if learning_style:
from odoo.addons.encoach_adaptive.services.style_matcher import StyleMatcher
ranked = StyleMatcher.rank_resources(learning_style, resources)
else:
ranked = list(resources)
items = []
for r in ranked[:20]:
items.append({
'id': r.id,
'name': r.name,
'type': r.type or '',
'cefr_level': r.cefr_level or '',
'difficulty': r.difficulty or '',
'duration_minutes': r.duration_minutes,
'style_match': learning_style if r.type in (
STYLE_RESOURCE_MAP.get(learning_style, []) if learning_style else []
) else '',
})
return _json_response({
'student_id': student_id,
'learning_style': learning_style or 'none',
'items': items,
})
except Exception as e:
_logger.exception('recommended_resources failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/settings
# ------------------------------------------------------------------
@http.route('/api/adaptive/settings', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_settings(self, **kw):
try:
user = request.env.user.sudo()
Settings = request.env['encoach.adaptive.settings'].sudo()
settings = Settings.search([('teacher_id', '=', user.id)], limit=1)
if not settings:
return _json_response({
'step_up_threshold': 0.85,
'step_down_threshold': 0.50,
'micro_lesson_trigger': 2,
'module_skip_threshold': 0.95,
'no_progress_alert_days': 3,
'max_retries': 3,
'is_default': True,
})
return _json_response({
'id': settings.id,
'step_up_threshold': settings.step_up_threshold,
'step_down_threshold': settings.step_down_threshold,
'micro_lesson_trigger': settings.micro_lesson_trigger,
'module_skip_threshold': settings.module_skip_threshold,
'no_progress_alert_days': settings.no_progress_alert_days,
'max_retries': settings.max_retries,
'is_default': False,
})
except Exception as e:
_logger.exception('get adaptive settings failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/adaptive/settings
# ------------------------------------------------------------------
@http.route('/api/adaptive/settings', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update_settings(self, **kw):
try:
body = _get_json_body()
user = request.env.user.sudo()
Settings = request.env['encoach.adaptive.settings'].sudo()
settings = Settings.search([('teacher_id', '=', user.id)], limit=1)
allowed_fields = [
'step_up_threshold', 'step_down_threshold', 'micro_lesson_trigger',
'module_skip_threshold', 'no_progress_alert_days', 'max_retries',
]
vals = {k: body[k] for k in allowed_fields if k in body}
if not settings:
vals['teacher_id'] = user.id
entity = user.entity_ids[:1]
if entity:
vals['entity_id'] = entity.id
settings = Settings.create(vals)
else:
settings.write(vals)
return _json_response({
'id': settings.id,
'step_up_threshold': settings.step_up_threshold,
'step_down_threshold': settings.step_down_threshold,
'micro_lesson_trigger': settings.micro_lesson_trigger,
'module_skip_threshold': settings.module_skip_threshold,
'no_progress_alert_days': settings.no_progress_alert_days,
'max_retries': settings.max_retries,
})
except Exception as e:
_logger.exception('update adaptive settings failed')
return _error_response(str(e), 500)

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
from odoo import models, fields
class EncoachAdaptiveEvent(models.Model):
_name = 'encoach.adaptive.event'
_description = 'Adaptive Learning Event'
_order = 'created_at desc'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
course_id = fields.Many2one('op.course', ondelete='set null')
event_type = fields.Selection([
('signal', 'Signal'),
('decision', 'Decision'),
], required=True)
signal_name = fields.Char(size=100)
signal_value = fields.Float()
decision = fields.Char(size=200)
context = fields.Text()
created_at = fields.Datetime(default=fields.Datetime.now)

View File

@@ -0,0 +1,16 @@
from odoo import models, fields
class EncoachAdaptivePath(models.Model):
_name = 'encoach.adaptive.path'
_description = 'Adaptive Learning Path'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
course_id = fields.Many2one('op.course', ondelete='set null')
module_queue = fields.Text()
source = fields.Selection([
('placement', 'Placement'),
('exam', 'Exam'),
('ai_generated', 'AI Generated'),
])
next_generation_brief = fields.Text()

View File

@@ -0,0 +1,20 @@
from odoo import api, models, fields
class EncoachAdaptiveSettings(models.Model):
_name = 'encoach.adaptive.settings'
_description = 'Adaptive Engine Settings'
teacher_id = fields.Many2one('res.users', ondelete='cascade')
entity_id = fields.Many2one('encoach.entity', ondelete='cascade')
step_up_threshold = fields.Float(default=0.85)
step_down_threshold = fields.Float(default=0.50)
micro_lesson_trigger = fields.Integer(default=2)
module_skip_threshold = fields.Float(default=0.95)
no_progress_alert_days = fields.Integer(default=3)
max_retries = fields.Integer(default=3)
@api.model
def _cron_check_no_progress(self):
from odoo.addons.encoach_adaptive.services.alert_service import AdaptiveAlertService
AdaptiveAlertService.check_no_progress(self.env)

View File

@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_adaptive_event_user,encoach.adaptive.event.user,model_encoach_adaptive_event,base.group_user,1,1,1,1
access_adaptive_path_user,encoach.adaptive.path.user,model_encoach_adaptive_path,base.group_user,1,1,1,1
access_adaptive_settings_user,encoach.adaptive.settings.user,model_encoach_adaptive_settings,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_adaptive_event_user encoach.adaptive.event.user model_encoach_adaptive_event base.group_user 1 1 1 1
3 access_adaptive_path_user encoach.adaptive.path.user model_encoach_adaptive_path base.group_user 1 1 1 1
4 access_adaptive_settings_user encoach.adaptive.settings.user model_encoach_adaptive_settings base.group_user 1 1 1 1

View File

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

View File

@@ -0,0 +1,149 @@
import logging
import json
_logger = logging.getLogger(__name__)
class AdaptiveEngine:
"""4-phase adaptive learning engine.
Phase 1: Module-level up/down stepping
Phase 2: Micro-lesson injection
Phase 3: Module skipping
Phase 4: No-progress alerts
"""
DEFAULT_SETTINGS = {
'step_up_threshold': 0.85,
'step_down_threshold': 0.50,
'micro_lesson_trigger': 2,
'module_skip_threshold': 0.95,
'no_progress_alert_days': 3,
'max_retries': 3,
}
@staticmethod
def get_settings(env, teacher_id=None, entity_id=None):
"""Get adaptive settings for teacher/entity or defaults."""
Settings = env['encoach.adaptive.settings'].sudo()
settings = None
if teacher_id:
settings = Settings.search([('teacher_id', '=', teacher_id)], limit=1)
if not settings and entity_id:
settings = Settings.search([('entity_id', '=', entity_id), ('teacher_id', '=', False)], limit=1)
if settings:
return {
'step_up_threshold': settings.step_up_threshold,
'step_down_threshold': settings.step_down_threshold,
'micro_lesson_trigger': settings.micro_lesson_trigger,
'module_skip_threshold': settings.module_skip_threshold,
'no_progress_alert_days': settings.no_progress_alert_days,
'max_retries': settings.max_retries,
}
return dict(AdaptiveEngine.DEFAULT_SETTINGS)
@staticmethod
def process_checkpoint(env, student_id, course_id, module_id, score, settings=None):
"""Process a module checkpoint and make adaptive decisions."""
if not settings:
settings = AdaptiveEngine.DEFAULT_SETTINGS
Event = env['encoach.adaptive.event'].sudo()
Module = env['encoach.course.module'].sudo()
module = Module.browse(module_id)
decision = None
signals = []
signals.append({
'signal_name': 'checkpoint_score',
'signal_value': score,
})
if score >= settings['step_up_threshold']:
decision = 'step_up'
module.write({'status': 'completed'})
next_module = Module.search([
('course_id', '=', course_id),
('sequence', '>', module.sequence),
('status', '=', 'locked'),
], limit=1, order='sequence')
if next_module:
next_module.write({'status': 'available'})
elif score < settings['step_down_threshold']:
decision = 'step_down'
else:
decision = 'continue'
module.write({'status': 'completed'})
next_module = Module.search([
('course_id', '=', course_id),
('sequence', '>', module.sequence),
('status', '=', 'locked'),
], limit=1, order='sequence')
if next_module:
next_module.write({'status': 'available'})
if score >= settings['module_skip_threshold']:
skip_modules = Module.search([
('course_id', '=', course_id),
('sequence', '>', module.sequence),
('status', '=', 'locked'),
], limit=2, order='sequence')
for sm in skip_modules:
sm.write({'status': 'skipped'})
if skip_modules:
decision = 'skip_ahead'
signals.append({'signal_name': 'module_skip', 'signal_value': len(skip_modules)})
for sig in signals:
Event.create({
'student_id': student_id,
'course_id': course_id,
'event_type': 'signal',
'signal_name': sig['signal_name'],
'signal_value': sig['signal_value'],
})
Event.create({
'student_id': student_id,
'course_id': course_id,
'event_type': 'decision',
'decision': decision,
'context': json.dumps({'module_id': module_id, 'score': score}),
})
return {
'decision': decision,
'score': score,
'signals': signals,
}
@staticmethod
def check_no_progress(env, student_id, course_id, settings=None):
"""Phase 4: Check if student has stalled."""
if not settings:
settings = AdaptiveEngine.DEFAULT_SETTINGS
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(days=settings['no_progress_alert_days'])
Event = env['encoach.adaptive.event'].sudo()
recent_events = Event.search_count([
('student_id', '=', student_id),
('course_id', '=', course_id),
('created_at', '>=', cutoff.strftime('%Y-%m-%d %H:%M:%S')),
])
if recent_events == 0:
Event.create({
'student_id': student_id,
'course_id': course_id,
'event_type': 'signal',
'signal_name': 'no_progress_alert',
'signal_value': settings['no_progress_alert_days'],
})
return True
return False

View File

@@ -0,0 +1,67 @@
import logging
from datetime import timedelta
from odoo import fields
_logger = logging.getLogger(__name__)
class AdaptiveAlertService:
"""Checks for students with no learning progress and creates teacher alerts."""
@classmethod
def check_no_progress(cls, env):
"""Find students with no adaptive events within threshold days and alert teachers.
Called by scheduled action (ir.cron).
"""
Settings = env['encoach.adaptive.settings'].sudo()
Event = env['encoach.adaptive.event'].sudo()
Path = env['encoach.adaptive.path'].sudo()
Activity = env['mail.activity'].sudo()
all_settings = Settings.search([])
if not all_settings:
all_settings = Settings.new({'no_progress_alert_days': 3})
for setting in all_settings:
days = setting.no_progress_alert_days or 3
cutoff = fields.Datetime.now() - timedelta(days=days)
teacher = setting.teacher_id
if not teacher:
continue
# Find students with active paths but no recent events
paths = Path.search([])
for path in paths:
student_id = path.student_id.id
recent_events = Event.search_count([
('student_id', '=', student_id),
('created_at', '>=', cutoff),
])
if recent_events == 0:
# Check if alert already exists for this student
existing = Activity.search([
('res_model', '=', 'res.users'),
('res_id', '=', student_id),
('user_id', '=', teacher.id),
('summary', 'ilike', 'No learning progress'),
('date_deadline', '>=', fields.Date.today()),
], limit=1)
if not existing:
activity_type = env.ref('mail.mail_activity_data_todo', raise_if_not_found=False)
Activity.create({
'res_model_id': env['ir.model']._get_id('res.users'),
'res_id': student_id,
'user_id': teacher.id,
'activity_type_id': activity_type.id if activity_type else False,
'summary': f'No learning progress for {days}+ days',
'note': f'Student {path.student_id.name} has not shown any '
f'learning activity in the last {days} days.',
'date_deadline': fields.Date.today(),
})
_logger.info('Created no-progress alert for student %s → teacher %s',
path.student_id.name, teacher.name)

View File

@@ -0,0 +1,35 @@
import logging
_logger = logging.getLogger(__name__)
STYLE_RESOURCE_MAP = {
'visual': ['video', 'pdf', 'interactive'],
'auditory': ['video', 'interactive'],
'reading': ['pdf', 'document'],
'kinesthetic': ['interactive'],
}
class StyleMatcher:
"""Ranks learning resources based on student's learning style preference."""
@classmethod
def rank_resources(cls, learning_style, resources):
"""Sort resources so that types matching the student's style come first.
Args:
learning_style: str, one of visual/auditory/reading/kinesthetic
resources: recordset of encoach.resource
Returns:
sorted list of resource records
"""
preferred_types = STYLE_RESOURCE_MAP.get(learning_style, [])
return sorted(resources, key=lambda r: (r.type not in preferred_types, r.id))
@classmethod
def filter_by_style(cls, learning_style, resources):
"""Return only resources matching the learning style (with fallback to all)."""
preferred_types = STYLE_RESOURCE_MAP.get(learning_style, [])
matched = [r for r in resources if r.type in preferred_types]
return matched if matched else list(resources)

View File

@@ -0,0 +1,54 @@
.o_signal_timeline .st-timeline {
position: relative;
padding-left: 48px;
}
.o_signal_timeline .st-timeline::before {
content: "";
position: absolute;
left: 19px;
top: 0;
bottom: 0;
width: 2px;
background: #dee2e6;
}
.o_signal_timeline .st-timeline-item {
position: relative;
margin-bottom: 1rem;
}
.o_signal_timeline .st-timeline-marker {
position: absolute;
left: -48px;
top: 8px;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 0.85rem;
z-index: 1;
border: 3px solid #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.o_signal_timeline .st-timeline-content {
padding-left: 4px;
}
.o_signal_timeline .st-timeline-content .card {
border-radius: 0.5rem;
transition: box-shadow 0.15s;
}
.o_signal_timeline .st-timeline-content .card:hover {
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.08) !important;
}
.o_signal_timeline .st-student-card {
transition: transform 0.15s, box-shadow 0.15s;
cursor: pointer;
}
.o_signal_timeline .st-student-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
}
.o_signal_timeline .cursor-pointer {
cursor: pointer;
}

View File

@@ -0,0 +1,152 @@
/** @odoo-module */
import { Component, onWillStart, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
class SignalTimeline extends Component {
static template = "encoach_adaptive.SignalTimeline";
static components = { Layout };
static props = ["*"];
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.state = useState({
loading: true,
studentId: null,
student: null,
events: [],
filteredEvents: [],
filters: { eventType: "", dateFrom: "", dateTo: "" },
totalCount: 0,
});
onWillStart(async () => {
const ctx = this.props.action?.context || {};
this.state.studentId = ctx.student_id || ctx.active_id || null;
if (this.state.studentId) {
await this.loadTimeline();
} else {
await this.loadStudentList();
}
});
}
async loadStudentList() {
try {
this.state.studentList = await this.orm.searchRead(
"res.users",
[["account_source", "!=", false]],
["name", "email"],
{ limit: 50, order: "name" },
);
} catch (e) {
console.error("Failed to load students:", e);
} finally {
this.state.loading = false;
}
}
async selectStudent(studentId) {
this.state.studentId = studentId;
this.state.loading = true;
await this.loadTimeline();
}
async loadTimeline() {
this.state.loading = true;
try {
const domain = [["student_id", "=", this.state.studentId]];
if (this.state.filters.eventType) {
domain.push(["event_type", "=", this.state.filters.eventType]);
}
if (this.state.filters.dateFrom) {
domain.push(["created_at", ">=", this.state.filters.dateFrom]);
}
if (this.state.filters.dateTo) {
domain.push(["created_at", "<=", this.state.filters.dateTo + " 23:59:59"]);
}
const [events, students] = await Promise.all([
this.orm.searchRead(
"encoach.adaptive.event",
domain,
["student_id", "course_id", "event_type", "signal_name", "signal_value", "decision", "context", "created_at"],
{ limit: 100, order: "created_at desc" },
),
this.orm.searchRead(
"res.users",
[["id", "=", this.state.studentId]],
["name", "email"],
),
]);
this.state.student = students.length ? students[0] : null;
this.state.events = events;
this.state.filteredEvents = events;
this.state.totalCount = events.length;
} catch (e) {
console.error("Timeline load error:", e);
} finally {
this.state.loading = false;
}
}
onFilterChange(field, ev) {
this.state.filters[field] = ev.target.value;
if (this.state.studentId) {
this.loadTimeline();
}
}
clearFilters() {
this.state.filters = { eventType: "", dateFrom: "", dateTo: "" };
if (this.state.studentId) {
this.loadTimeline();
}
}
eventIcon(eventType) {
return eventType === "signal" ? "fa-bolt" : "fa-gavel";
}
eventColor(eventType) {
return eventType === "signal" ? "primary" : "success";
}
formatDate(dt) {
if (!dt) return "";
return dt.replace("T", " ").substring(0, 19);
}
parseContext(ctx) {
if (!ctx) return null;
try {
return typeof ctx === "string" ? JSON.parse(ctx) : ctx;
} catch {
return null;
}
}
get signalCount() {
return this.state.filteredEvents.filter(e => e.event_type === "signal").length;
}
get decisionCount() {
return this.state.filteredEvents.filter(e => e.event_type === "decision").length;
}
goBack() {
this.state.studentId = null;
this.state.student = null;
this.state.events = [];
this.state.filteredEvents = [];
this.state.filters = { eventType: "", dateFrom: "", dateTo: "" };
this.state.loading = true;
this.loadStudentList();
}
}
registry.category("actions").add("encoach_signal_timeline", SignalTimeline);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
{
'name': 'EnCoach AI Course',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'AI content generation pipelines for General English and IELTS courses',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen'],
'data': [
'security/ir.model.access.csv',
'views/ai_generation_log_views.xml',
'views/ai_ielts_log_views.xml',
'views/ai_course_menus.xml',
'views/ai_review_views.xml',
'views/ielts_review_views.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}

View File

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

View File

@@ -0,0 +1,396 @@
import json
import logging
from odoo import http, fields
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
_logger = logging.getLogger(__name__)
class EncoachAiCourseController(http.Controller):
# ------------------------------------------------------------------
# POST /api/ai-course/english/create
# ------------------------------------------------------------------
@http.route('/api/ai-course/english/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_english(self, **kw):
try:
body = _get_json_body()
cefr_level = body.get('cefr_level')
if not cefr_level:
return _error_response('cefr_level is required', 400)
user = request.env.user.sudo()
gap_profile_id = body.get('gap_profile_id')
GapProfile = request.env['encoach.gap.profile'].sudo()
if gap_profile_id:
gap_profile = GapProfile.browse(int(gap_profile_id))
if not gap_profile.exists():
return _error_response('Gap profile not found', 404)
else:
gap_profile = GapProfile.search(
[('student_id', '=', user.id)],
order='created_at desc', limit=1,
)
if not gap_profile:
gap_profile = GapProfile.create({
'student_id': user.id,
'source_type': 'exam',
'skill_gaps': json.dumps([]),
'topic_weaknesses': json.dumps([]),
})
brief = {
'skill_gaps': json.loads(gap_profile.skill_gaps or '[]'),
'topic_weaknesses': json.loads(gap_profile.topic_weaknesses or '[]'),
'cefr_level': cefr_level,
}
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.create({
'student_id': user.id,
'course_type': 'general_english',
'status': 'pending_review',
'brief': json.dumps(brief),
'attempts': 1,
})
return _json_response({
'log_id': log.id,
'status': log.status,
'brief': brief,
}, 201)
except Exception as e:
_logger.exception('create_english failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/ielts/create
# ------------------------------------------------------------------
@http.route('/api/ai-course/ielts/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_ielts(self, **kw):
try:
body = _get_json_body()
skill = body.get('skill')
target_band = body.get('target_band')
if not skill:
return _error_response('skill is required', 400)
if not target_band:
return _error_response('target_band is required', 400)
valid_skills = ('listening', 'reading', 'writing', 'speaking')
if skill not in valid_skills:
return _error_response(
f'skill must be one of: {", ".join(valid_skills)}', 400,
)
brief = {
'skill': skill,
'target_band': float(target_band),
'custom_instructions': body.get('brief', ''),
}
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
log = Log.create({
'skill': skill,
'status': 'format_check',
'brief': json.dumps(brief),
'attempts': 1,
})
return _json_response({
'log_id': log.id,
'status': log.status,
'skill': log.skill,
}, 201)
except Exception as e:
_logger.exception('create_ielts failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/<int:course_id>/quality
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/quality', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def quality(self, course_id, **kw):
try:
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
return _json_response({
'status': log.status,
'readability_score': 0.0,
'cefr_alignment': log.status in ('quality_check', 'approved'),
'grammar_issues': [],
'attempts': log.attempts,
})
except Exception as e:
_logger.exception('quality check failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/<int:course_id>/approve
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/approve', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def approve(self, course_id, **kw):
try:
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
if log.status == 'approved':
return _error_response('Already approved', 400)
log.write({
'status': 'approved',
'approved_by': request.env.user.id,
})
return _json_response({'approved': True})
except Exception as e:
_logger.exception('approve failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/<int:course_id>/reject
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/reject', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def reject(self, course_id, **kw):
try:
body = _get_json_body()
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
reason = body.get('reason', '')
can_retry = log.attempts < 3
if can_retry:
log.write({
'status': 'generating',
'attempts': log.attempts + 1,
'error_log': reason or log.error_log,
})
else:
log.write({
'status': 'rejected',
'error_log': reason or log.error_log,
})
return _json_response({
'rejected': True,
'can_retry': can_retry,
})
except Exception as e:
_logger.exception('reject failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/<int:course_id>/validation
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/validation', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def validation(self, course_id, **kw):
try:
IeltsLog = request.env['encoach.ai.ielts.generation.log'].sudo()
ielts_log = IeltsLog.browse(course_id)
if ielts_log.exists():
fmt_result = {}
band_result = {}
try:
fmt_result = json.loads(ielts_log.format_check_result or '{}')
except (json.JSONDecodeError, TypeError):
pass
try:
band_result = json.loads(ielts_log.band_check_result or '{}')
except (json.JSONDecodeError, TypeError):
pass
overall_passed = bool(
ielts_log.format_check_result and ielts_log.band_check_result
)
return _json_response({
'type': 'ielts',
'validation_results': {
'format_check_result': fmt_result,
'band_check_result': band_result,
},
'overall_passed': overall_passed,
})
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
validation_results = {
'status': log.status,
'quality_passed': log.status in ('approved', 'pending_review'),
'attempts': log.attempts,
}
return _json_response({
'type': 'english',
'validation_results': validation_results,
'overall_passed': log.status == 'approved',
})
except Exception as e:
_logger.exception('validation check failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/review-queue
# ------------------------------------------------------------------
@http.route('/api/ai-course/review-queue', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def review_queue(self, **kw):
try:
body = _get_json_body() if request.httprequest.content_length else {}
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Resource = request.env['encoach.resource'].sudo()
domain = [('ai_generated', '=', True), ('review_status', '=', 'pending')]
total = Resource.search_count(domain)
resources = Resource.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc')
return _json_response({
'total': total,
'page': page,
'size': size,
'items': [r.to_api_dict() for r in resources],
})
except Exception as e:
_logger.exception('review_queue failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/review/<int:resource_id>
# ------------------------------------------------------------------
@http.route('/api/ai-course/review/<int:resource_id>', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def review_resource(self, resource_id, **kw):
try:
body = _get_json_body()
action = body.get('action')
notes = body.get('notes', '')
if action not in ('approve', 'reject'):
return _error_response('action must be approve or reject', 400)
Resource = request.env['encoach.resource'].sudo()
resource = Resource.browse(resource_id)
if not resource.exists():
return _error_response('Resource not found', 404)
vals = {
'review_status': 'approved' if action == 'approve' else 'rejected',
'approved': action == 'approve',
}
resource.write(vals)
return _json_response({'status': vals['review_status'], 'resource_id': resource_id})
except Exception as e:
_logger.exception('review_resource failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/ielts-review-queue
# ------------------------------------------------------------------
@http.route('/api/ai-course/ielts-review-queue', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def ielts_review_queue(self, **kw):
try:
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
domain = [('review_status', '=', 'pending_review')]
total = Log.search_count(domain)
logs = Log.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc')
items = []
for log in logs:
items.append({
'id': log.id,
'skill': log.skill or '',
'band_target': log.band_target if hasattr(log, 'band_target') else 0,
'review_status': log.review_status,
'created_at': log.create_date.isoformat() if log.create_date else '',
})
return _json_response({
'total': total,
'page': page,
'size': size,
'items': items,
})
except Exception as e:
_logger.exception('ielts_review_queue failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/ielts-review/<int:log_id>
# ------------------------------------------------------------------
@http.route('/api/ai-course/ielts-review/<int:log_id>', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def ielts_review(self, log_id, **kw):
try:
body = _get_json_body()
action = body.get('action')
examiner_notes = body.get('examiner_notes', '')
if action not in ('approve', 'reject', 'revise'):
return _error_response('action must be approve, reject, or revise', 400)
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
log = Log.browse(log_id)
if not log.exists():
return _error_response('Log not found', 404)
status_map = {
'approve': 'approved',
'reject': 'rejected',
'revise': 'revision_needed',
}
log.write({
'review_status': status_map[action],
'examiner_id': request.env.user.id,
'examiner_notes': examiner_notes,
'reviewed_at': fields.Datetime.now(),
})
return _json_response({'status': status_map[action], 'log_id': log_id})
except Exception as e:
_logger.exception('ielts_review failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,2 @@
from . import ai_generation_log
from . import ai_ielts_generation_log

View File

@@ -0,0 +1,25 @@
from odoo import models, fields
class EncoachAiGenerationLog(models.Model):
_name = 'encoach.ai.generation.log'
_description = 'AI Generation Log'
student_id = fields.Many2one('res.users', ondelete='set null')
course_type = fields.Selection([
('general_english', 'General English'),
('ielts', 'IELTS'),
], required=True)
brief = fields.Text()
attempts = fields.Integer(default=0)
final_resource_id = fields.Integer(string='Final Resource ID')
approved_by = fields.Many2one('res.users', ondelete='set null')
status = fields.Selection([
('generating', 'Generating'),
('quality_check', 'Quality Check'),
('pending_review', 'Pending Review'),
('approved', 'Approved'),
('rejected', 'Rejected'),
], default='generating', required=True)
created_at = fields.Datetime(default=fields.Datetime.now)
error_log = fields.Text()

View File

@@ -0,0 +1,35 @@
from odoo import models, fields
class EncoachAiIeltsGenerationLog(models.Model):
_name = 'encoach.ai.ielts.generation.log'
_description = 'AI IELTS Generation Log'
skill = fields.Selection([
('listening', 'Listening'),
('reading', 'Reading'),
('writing', 'Writing'),
('speaking', 'Speaking'),
], required=True)
brief = fields.Text()
format_check_result = fields.Text()
band_check_result = fields.Text()
examiner_id = fields.Many2one('res.users', ondelete='set null')
status = fields.Selection([
('generating', 'Generating'),
('format_check', 'Format Check'),
('examiner_review', 'Examiner Review'),
('approved', 'Approved'),
('rejected', 'Rejected'),
], default='generating', required=True)
attempts = fields.Integer(default=0)
created_at = fields.Datetime(default=fields.Datetime.now)
review_status = fields.Selection([
('draft', 'Draft'),
('pending_review', 'Pending Review'),
('approved', 'Approved'),
('rejected', 'Rejected'),
('revision_needed', 'Revision Needed'),
], default='draft')
examiner_notes = fields.Text()
reviewed_at = fields.Datetime()

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_ai_generation_log_user,encoach.ai.generation.log.user,model_encoach_ai_generation_log,base.group_user,1,1,1,1
access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user,model_encoach_ai_ielts_generation_log,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_ai_generation_log_user encoach.ai.generation.log.user model_encoach_ai_generation_log base.group_user 1 1 1 1
3 access_encoach_ai_ielts_generation_log_user encoach.ai.ielts.generation.log.user model_encoach_ai_ielts_generation_log base.group_user 1 1 1 1

View File

@@ -0,0 +1,2 @@
from .english_pipeline import EnglishPipeline
from .ielts_pipeline import IeltsPipeline

View File

@@ -0,0 +1,124 @@
import json
import logging
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
except ImportError:
OpenAIService = None
try:
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
except ImportError:
ContentSourceGate = None
_logger = logging.getLogger(__name__)
class EnglishPipeline:
"""AI content generation pipeline for General English courses."""
def __init__(self, env):
self.env = env
self.ai = OpenAIService(env)
def generate_content(self, env, gap_profile, cefr_level):
"""Generate General English content based on gap analysis.
Args:
env: Odoo environment.
gap_profile: dict with 'skill_gaps' list describing weak areas.
cefr_level: target CEFR level string (e.g. 'B1').
Returns:
dict with generated content.
"""
skill_gaps = gap_profile.get('skill_gaps', [])
gaps_description = ', '.join(skill_gaps) if skill_gaps else 'general skills'
messages = [
{'role': 'system', 'content': (
'You are an expert English language curriculum designer. '
'Return JSON with keys: "title", "objectives", "units" '
'(each unit has "topic", "grammar_focus", "vocabulary", '
'"reading_text", "exercises").'
)},
{'role': 'user', 'content': (
f'Generate a General English course at CEFR {cefr_level} level. '
f'Focus on these gap areas: {gaps_description}. '
f'Include practical, real-world content appropriate for the level.'
)},
]
try:
content = self.ai.chat_json(messages, temperature=0.8)
except Exception as e:
_logger.error("English content generation failed: %s", e)
env['encoach.ai.generation.log'].create({
'course_type': 'general_english',
'brief': json.dumps(gap_profile),
'status': 'rejected',
'error_log': str(e),
})
return {'error': str(e)}
resource = env['encoach.ai.generation.log'].create({
'course_type': 'general_english',
'brief': json.dumps(gap_profile),
'status': 'quality_check',
'attempts': 1,
})
# Apply content source gate logic
if ContentSourceGate is not None:
try:
ContentSourceGate.apply_gate(resource)
except Exception as e:
_logger.warning("ContentSourceGate.apply_gate failed: %s", e)
return content
def quality_check(self, env, content, cefr_level):
"""Run quality gate checks on generated content.
Args:
env: Odoo environment.
content: dict of generated content to validate.
cefr_level: target CEFR level for alignment check.
Returns:
dict with 'passed' bool and 'issues' list.
"""
issues = []
if not content.get('units'):
issues.append('No units generated')
else:
for i, unit in enumerate(content['units'], 1):
if not unit.get('exercises'):
issues.append(f'Unit {i} has no exercises')
if not unit.get('reading_text'):
issues.append(f'Unit {i} has no reading text')
if not content.get('objectives'):
issues.append('No learning objectives defined')
messages = [
{'role': 'system', 'content': (
'You are a CEFR alignment expert. Return JSON with keys: '
'"aligned" (bool) and "issues" (list of strings).'
)},
{'role': 'user', 'content': (
f'Check if this content is aligned with CEFR {cefr_level}: '
f'{json.dumps(content)}'
)},
]
try:
alignment = self.ai.chat_json(messages, temperature=0.3)
if not alignment.get('aligned'):
issues.extend(alignment.get('issues', ['CEFR misalignment detected']))
except Exception as e:
_logger.warning("CEFR alignment check failed: %s", e)
issues.append(f'CEFR alignment check error: {e}')
return {'passed': len(issues) == 0, 'issues': issues}

View File

@@ -0,0 +1,170 @@
import json
import logging
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
except ImportError:
OpenAIService = None
try:
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
except ImportError:
ContentSourceGate = None
_logger = logging.getLogger(__name__)
SKILL_PROMPTS = {
'listening': (
'Generate an IELTS Listening section. Return JSON with keys: '
'"script", "speakers", "duration_estimate", "questions".'
),
'reading': (
'Generate an IELTS Reading passage with questions. Return JSON with keys: '
'"passage", "word_count", "questions", "question_types".'
),
'writing': (
'Generate an IELTS Writing task. Return JSON with keys: '
'"task_type", "prompt", "requirements", "sample_band_descriptors".'
),
'speaking': (
'Generate an IELTS Speaking part. Return JSON with keys: '
'"part", "questions", "cue_card" (if part 2), "follow_up_questions".'
),
}
IELTS_FORMAT_RULES = {
'listening': {'required_keys': ['script', 'questions'], 'min_questions': 10},
'reading': {'required_keys': ['passage', 'questions'], 'min_word_count': 500},
'writing': {'required_keys': ['prompt', 'requirements']},
'speaking': {'required_keys': ['questions']},
}
class IeltsPipeline:
"""AI content generation pipeline for IELTS skill-specific content."""
def __init__(self, env):
self.env = env
self.ai = OpenAIService(env)
def generate_content(self, env, skill, brief):
"""Generate IELTS skill-specific content.
Args:
env: Odoo environment.
skill: one of 'listening', 'reading', 'writing', 'speaking'.
brief: dict with generation parameters.
Returns:
dict with generated content.
"""
system_prompt = SKILL_PROMPTS.get(skill, SKILL_PROMPTS['reading'])
brief_text = json.dumps(brief) if isinstance(brief, dict) else str(brief)
messages = [
{'role': 'system', 'content': (
f'You are an expert IELTS content creator. {system_prompt}'
)},
{'role': 'user', 'content': (
f'Generate IELTS {skill} content based on this brief: {brief_text}'
)},
]
try:
content = self.ai.chat_json(messages, temperature=0.8)
except Exception as e:
_logger.error("IELTS %s content generation failed: %s", skill, e)
return {'error': str(e)}
resource = env['encoach.ai.ielts.generation.log'].create({
'skill': skill,
'brief': brief_text,
'status': 'format_check',
'attempts': 1,
})
# Apply content source gate logic
if ContentSourceGate is not None:
try:
ContentSourceGate.apply_gate(resource)
except Exception as e:
_logger.warning("ContentSourceGate.apply_gate failed: %s", e)
return content
def format_check(self, env, content, skill):
"""Validate IELTS format compliance for a given skill.
Args:
env: Odoo environment.
content: dict of generated content.
skill: IELTS skill type.
Returns:
dict with 'passed' bool and 'errors' list.
"""
errors = []
rules = IELTS_FORMAT_RULES.get(skill, {})
for key in rules.get('required_keys', []):
if key not in content or not content[key]:
errors.append(f'Missing required key: {key}')
if skill == 'listening':
questions = content.get('questions', [])
min_q = rules.get('min_questions', 10)
if len(questions) < min_q:
errors.append(
f'Listening requires at least {min_q} questions, '
f'got {len(questions)}'
)
if skill == 'reading':
passage = content.get('passage', '')
min_wc = rules.get('min_word_count', 500)
word_count = len(passage.split()) if passage else 0
if word_count < min_wc:
errors.append(
f'Reading passage must be at least {min_wc} words, '
f'got {word_count}'
)
if skill == 'speaking' and not content.get('questions'):
errors.append('Speaking section requires at least one question')
return {'passed': len(errors) == 0, 'errors': errors}
def band_calibration(self, env, content, target_band):
"""Check content aligns with target IELTS band level.
Args:
env: Odoo environment.
content: dict of generated content.
target_band: float target band score (e.g. 6.5).
Returns:
dict with 'passed' bool and 'issues' list.
"""
messages = [
{'role': 'system', 'content': (
'You are an IELTS band calibration expert. Evaluate whether '
'the provided content matches the target band level. '
'Return JSON with keys: "calibrated" (bool), "estimated_band" (float), '
'"issues" (list of strings).'
)},
{'role': 'user', 'content': (
f'Target band: {target_band}. '
f'Content: {json.dumps(content)}'
)},
]
try:
result = self.ai.chat_json(messages, temperature=0.3)
except Exception as e:
_logger.error("Band calibration failed: %s", e)
return {'passed': False, 'issues': [f'Band calibration error: {e}']}
return {
'passed': result.get('calibrated', False),
'issues': result.get('issues', []),
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_ai_generation_logs"
name="AI Generation Logs"
parent="encoach_core.menu_encoach_courses"
action="encoach_ai_course.action_ai_generation_log"
sequence="30"/>
<menuitem id="menu_ai_ielts_logs"
name="IELTS Generation Logs"
parent="encoach_core.menu_encoach_courses"
action="encoach_ai_course.action_ai_ielts_log"
sequence="40"/>
</odoo>

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_ai_generation_log_form" model="ir.ui.view">
<field name="name">encoach.ai.generation.log.form</field>
<field name="model">encoach.ai.generation.log</field>
<field name="arch" type="xml">
<form string="AI Generation Log">
<header>
<field name="status" widget="statusbar" statusbar_visible="generating,quality_check,pending_review,approved"/>
</header>
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="course_type"/>
<field name="attempts"/>
</group>
<group>
<field name="final_resource_id"/>
<field name="approved_by"/>
<field name="created_at"/>
</group>
</group>
<notebook>
<page string="Brief" name="brief">
<field name="brief"/>
</page>
<page string="Error Log" name="error_log">
<field name="error_log"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_ai_generation_log_list" model="ir.ui.view">
<field name="name">encoach.ai.generation.log.list</field>
<field name="model">encoach.ai.generation.log</field>
<field name="arch" type="xml">
<list string="AI Generation Logs">
<field name="course_type"/>
<field name="status" widget="badge" decoration-info="status == 'generating'" decoration-warning="status == 'pending_review'" decoration-success="status == 'approved'" decoration-danger="status == 'rejected'"/>
<field name="attempts"/>
<field name="student_id"/>
<field name="created_at"/>
</list>
</field>
</record>
<record id="view_ai_generation_log_search" model="ir.ui.view">
<field name="name">encoach.ai.generation.log.search</field>
<field name="model">encoach.ai.generation.log</field>
<field name="arch" type="xml">
<search string="Search AI Generation Logs">
<field name="student_id"/>
<separator/>
<filter string="Generating" name="generating" domain="[('status', '=', 'generating')]"/>
<filter string="Quality Check" name="quality_check" domain="[('status', '=', 'quality_check')]"/>
<filter string="Pending Review" name="pending_review" domain="[('status', '=', 'pending_review')]"/>
<filter string="Approved" name="approved" domain="[('status', '=', 'approved')]"/>
<filter string="Rejected" name="rejected" domain="[('status', '=', 'rejected')]"/>
<separator/>
<filter string="General English" name="general_english" domain="[('course_type', '=', 'general_english')]"/>
<filter string="IELTS" name="ielts" domain="[('course_type', '=', 'ielts')]"/>
</search>
</field>
</record>
<record id="action_ai_generation_log" model="ir.actions.act_window">
<field name="name">AI Generation Logs</field>
<field name="res_model">encoach.ai.generation.log</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

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