13 Commits

Author SHA1 Message Date
Yamen Ahmad
b9df9b5299 Merge remote-tracking branch 'origin/main' into v4
Made-with: Cursor

# Conflicts:
#	.gitignore
2026-04-13 12:46:00 +04:00
Yamen Ahmad
372b835e84 feat(generation): add Listening section selector, exercise wizard, and edit/preview support
- Replace section list with checkbox-based section type selector (Social Conversation, Social Monologue, Academic Discussion, Academic Monologue) matching old platform
- Add Set Up Exercises wizard for Listening sections with listening-specific exercise types
- Add exercise display with edit/delete/clear-all for each listening section
- Add Save/Discard/Edit buttons and read-only mode for listening context textarea
- Generalize exercise wizard and edit dialog to work with both Reading and Listening modules

Made-with: Cursor
2026-04-13 12:27:18 +04:00
Yamen Ahmad
2b2e81514b feat(generation): add exercise instructions, grouped display, and wizard improvements
- Add per-section instructions field to exercise wizard with sensible defaults
- Group exercises by type with section headers showing instructions
- Pass type_instructions to backend AI prompt for context-aware generation
- Add instructions editing in exercise edit dialog
- Update Exercise interface to include instructions field

Made-with: Cursor
2026-04-13 00:54:19 +04:00
Yamen Ahmad
82ec3debcc feat: QA fixes, new APIs (assignments, rubrics, custom exams), Generation page enhancements
- Fix ELAI video generation (correct render endpoint, script splitting for 60s limit)
- Fix speaking script generation error handling and empty response display
- Add custom exam list API (GET /api/exam/custom/list)
- Add assignments REST API (list, create, get)
- Add rubrics REST API (list, create)
- Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player
- Improve submit feedback with exam ID and status in toast notifications
- Fix ExamsListPage to show both custom exams and exam sessions
- Connect RubricsPage to backend API with fallback data
- Add Dockerfile, docker-compose.yml, requirements.txt for deployment
- Fix placement, grading, scoring, and auth controllers
- Add ErrorBoundary component for frontend resilience
- Add QA report and credentials documentation

Made-with: Cursor
2026-04-12 14:26:39 +04:00
Yamen Ahmad
571a08d0f7 docs: add comprehensive platform user guide for all user types
Covers Student, Teacher, Admin roles with every page/feature documented:
- 30+ student features (courses, exams, adaptive learning, AI courses)
- 17+ teacher features (course management, grading, workbench)
- 60+ admin features (generation, exam management, LMS, analytics)
- AI features guide (7 generation types, grading, coaching)
- End-to-end exam workflow (create → assign → take → grade → release)
- Troubleshooting & FAQ section

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-04-10 17:26:42 +04:00
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
771 changed files with 102094 additions and 3 deletions

33
.gitignore vendored
View File

@@ -51,19 +51,46 @@ new_project/encoach_frontend_new_v1/
*.log
odoo.log
# Docker
*.tar
# 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/
# Session files
sessions/
# Filestore
filestore/
# Extra / unused addon dirs
# Enterprise / extra addons (not part of this repo)
addons_enterprise/
addons_extra/
new_project/enterprise-17/
# Node modules
node_modules/
# 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/
# Coverage / test output
.coverage

13
backend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM odoo:19.0
USER root
COPY requirements.txt /tmp/requirements.txt
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
COPY custom_addons /opt/odoo/custom_addons
COPY odoo.conf /etc/odoo/odoo.conf
USER odoo
EXPOSE 8069 8072

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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