Compare commits
81 Commits
feature/te
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7139cbc8 | ||
|
|
565ddd5ff7 | ||
|
|
fc384efe85 | ||
| a6fa9d78d8 | |||
| f37c8fc7f7 | |||
| de89518280 | |||
| fbf6150cca | |||
| a4ec9a5fb2 | |||
| 4e4202742f | |||
| 499f58f617 | |||
| 65d5eb2480 | |||
|
|
5ec6ae0ae1 | ||
|
|
cd47d01f53 | ||
|
|
096b042daf | ||
|
|
afd1662a60 | ||
|
|
0ed7f88cab | ||
|
|
cfdf2be527 | ||
|
|
ed8e75d88c | ||
|
|
1dd1168fee | ||
|
|
971e9860c8 | ||
|
|
8d173b93cb | ||
| a5a3a2dc62 | |||
|
|
fa6f4976c3 | ||
|
|
882179870c | ||
|
|
e2aa8031ff | ||
|
|
1223074bde | ||
|
|
75ee0f1fe0 | ||
|
|
170d7c8d2e | ||
|
|
d34180e107 | ||
|
|
eef3edf7e8 | ||
|
|
d35ccc255f | ||
|
|
a554ef5d42 | ||
| b1b3d20eb4 | |||
|
|
4253f0174a | ||
|
|
bab588b9da | ||
| e33a9a61bb | |||
|
|
93def02e94 | ||
|
|
e1f059069f | ||
|
|
6712d1d551 | ||
| 7024197c7b | |||
|
|
93c530eef2 | ||
|
|
e70a2854f4 | ||
|
|
3972023a30 | ||
|
|
dcf5ea6941 | ||
|
|
47d09a3ce5 | ||
|
|
1a0349c381 | ||
|
|
c016a52200 | ||
|
|
96f419d653 | ||
|
|
d940db075e | ||
|
|
7f23127e44 | ||
|
|
7737f6def5 | ||
|
|
7f1f058e8f | ||
|
|
6ec68160c8 | ||
|
|
98b9837a54 | ||
|
|
74d83af57f | ||
|
|
50f58dc995 | ||
|
|
b9df9b5299 | ||
|
|
372b835e84 | ||
|
|
01cce7662d | ||
|
|
2b2e81514b | ||
|
|
ca91544acd | ||
|
|
82ec3debcc | ||
|
|
571a08d0f7 | ||
|
|
6a62a43d61 | ||
|
|
b02ee8b6b7 | ||
|
|
0c8443256d | ||
|
|
140ca7408d | ||
|
|
907a5c0e92 | ||
|
|
f1c4953a63 | ||
|
|
a3e12f62fa | ||
|
|
5ff9f12de7 | ||
|
|
e30e52e21e | ||
|
|
d98cd55b99 | ||
|
|
6c93c5d600 | ||
| bdc6598734 | |||
| d9f8a62886 | |||
| 66ce923907 | |||
| c1b23c8a5c | |||
| 9b6a2b7c22 | |||
|
|
3e83d8d7d5 | ||
| a4b9ab62cf |
67
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: Deploy Backend to Staging
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy backend to staging
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Pull latest code
|
||||||
|
run: |
|
||||||
|
cd /opt/encoach/encoach_backend_new_v2
|
||||||
|
git fetch origin
|
||||||
|
git reset --hard origin/main
|
||||||
|
echo "Deployed: $(git log -1 --oneline)"
|
||||||
|
|
||||||
|
- name: Build Odoo image
|
||||||
|
run: |
|
||||||
|
cd /opt/encoach/encoach_backend_new_v2
|
||||||
|
# odoo.conf is not in the repo; copy from the persistent override for the build context
|
||||||
|
cp /opt/encoach/overrides/odoo.conf ./odoo.conf
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.yml \
|
||||||
|
-f /opt/encoach/overrides/encoach.override.yml \
|
||||||
|
build odoo
|
||||||
|
rm -f ./odoo.conf
|
||||||
|
|
||||||
|
- name: Run DB migrations
|
||||||
|
run: |
|
||||||
|
# Dynamically fetch the list of installed encoach_* modules from the DB
|
||||||
|
MODULES=$(docker exec encoach-v4-db psql -U odoo -d encoach_v2 -tAc \
|
||||||
|
"SELECT string_agg(name, ,) FROM ir_module_module WHERE state=installed AND name LIKE encoach%;")
|
||||||
|
echo "Upgrading modules: $MODULES"
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--network encoach_backend_new_v2_default \
|
||||||
|
-v /opt/encoach/overrides/odoo.conf:/etc/odoo/odoo.conf:ro \
|
||||||
|
-v /opt/encoach/encoach_backend_new_v2:/mnt/extra-addons:ro \
|
||||||
|
-v /opt/encoach/encoach_backend_new_v2/openeducat_erp-19.0/openeducat_erp-19.0:/mnt/extra-addons/openeducat_erp-19.0:ro \
|
||||||
|
-v encoach_backend_new_v2_odoo-web-data:/var/lib/odoo \
|
||||||
|
encoach-backend:latest \
|
||||||
|
odoo -u "$MODULES" --stop-after-init 2>&1 | tail -20
|
||||||
|
|
||||||
|
- name: Restart Odoo
|
||||||
|
run: |
|
||||||
|
cd /opt/encoach/encoach_backend_new_v2
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.yml \
|
||||||
|
-f /opt/encoach/overrides/encoach.override.yml \
|
||||||
|
up -d --no-deps odoo
|
||||||
|
|
||||||
|
- name: Smoke test
|
||||||
|
run: |
|
||||||
|
echo "Polling Odoo /api/health (max 180s)..."
|
||||||
|
STATUS="000"
|
||||||
|
for i in $(seq 1 18); do
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://localhost:8069/api/health 2>/dev/null || echo "000")
|
||||||
|
[ "$STATUS" = "200" ] && echo "Odoo healthy after $((i*10))s" && break
|
||||||
|
echo " attempt $i: $STATUS — waiting 10s..."
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
[ "$STATUS" != "200" ] && echo "ERROR: Odoo still $STATUS after 180s" && exit 1
|
||||||
|
FE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://localhost:3000/ 2>/dev/null || echo "000")
|
||||||
|
[ "$FE" != "200" ] && echo "ERROR: Frontend $FE" && exit 1
|
||||||
|
echo "All OK — Odoo=200 Frontend=$FE"
|
||||||
28
.gitignore
vendored
@@ -1,28 +0,0 @@
|
|||||||
# Environment files — never commit secrets
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*.pyo
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
env/
|
|
||||||
*.egg-info/
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Poetry
|
|
||||||
poetry.lock
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Docker
|
|
||||||
*.tar
|
|
||||||
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM odoo:19.0
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
COPY requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip3 install --break-system-packages --no-cache-dir --ignore-installed typing_extensions -r /tmp/requirements.txt
|
||||||
|
|
||||||
|
COPY custom_addons /opt/odoo/custom_addons
|
||||||
|
COPY odoo.conf /etc/odoo/odoo.conf
|
||||||
|
|
||||||
|
USER odoo
|
||||||
|
|
||||||
|
EXPOSE 8069 8072
|
||||||
BIN
GE1 Course Outline_ Fall AY25-26.pdf
Normal file
32
README.md
@@ -1,32 +0,0 @@
|
|||||||
# EnCoach Backend — v2
|
|
||||||
|
|
||||||
## Branching Workflow
|
|
||||||
|
|
||||||
This repo is connected to the staging server via a Git post-receive hook.
|
|
||||||
**All deployment is automatic — but only after code review approval.**
|
|
||||||
|
|
||||||
### How to contribute
|
|
||||||
|
|
||||||
1. Never push directly to `main` — branch protection will block it.
|
|
||||||
2. Create a feature or fix branch:
|
|
||||||
```bash
|
|
||||||
git checkout -b feature/your-feature-name
|
|
||||||
```
|
|
||||||
3. Develop, commit, and push your branch:
|
|
||||||
```bash
|
|
||||||
git push origin feature/your-feature-name
|
|
||||||
```
|
|
||||||
4. Open a **Pull Request** on Gitea targeting `main`.
|
|
||||||
5. Request review from **devops (Talal)**.
|
|
||||||
6. Once approved and merged, the staging server rebuilds and redeploys automatically.
|
|
||||||
|
|
||||||
### Environment
|
|
||||||
|
|
||||||
The `.env` file is **not committed**. It lives only on the staging server at `/opt/encoach/backend-v2/.env`.
|
|
||||||
Contact the team lead if you need a local copy of the environment variables.
|
|
||||||
|
|
||||||
### Required files (push with your code)
|
|
||||||
|
|
||||||
- `Dockerfile` — used by the staging server to build the container image
|
|
||||||
- `docker-compose.yml` — defines the backend service (port mapping, env vars, etc.)
|
|
||||||
# Pipeline test Mon Mar 30 19:42:50 +04 2026
|
|
||||||
1
custom_addons/clarity_backend_theme_bits/__init__.py
Executable file
@@ -0,0 +1 @@
|
|||||||
|
from . import controller
|
||||||
76
custom_addons/clarity_backend_theme_bits/__manifest__.py
Executable 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',
|
||||||
|
],
|
||||||
|
}
|
||||||
1
custom_addons/clarity_backend_theme_bits/controller/__init__.py
Executable file
@@ -0,0 +1 @@
|
|||||||
|
from . import main
|
||||||
17
custom_addons/clarity_backend_theme_bits/controller/main.py
Executable 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
|
||||||
BIN
custom_addons/clarity_backend_theme_bits/static/description/icon.png
Executable file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
custom_addons/clarity_backend_theme_bits/static/description/images/1.png
Executable file
|
After Width: | Height: | Size: 903 KiB |
BIN
custom_addons/clarity_backend_theme_bits/static/description/images/2.png
Executable file
|
After Width: | Height: | Size: 45 KiB |
BIN
custom_addons/clarity_backend_theme_bits/static/description/images/3.png
Executable file
|
After Width: | Height: | Size: 147 KiB |
BIN
custom_addons/clarity_backend_theme_bits/static/description/images/4.png
Executable file
|
After Width: | Height: | Size: 76 KiB |
BIN
custom_addons/clarity_backend_theme_bits/static/description/images/5.png
Executable file
|
After Width: | Height: | Size: 109 KiB |
BIN
custom_addons/clarity_backend_theme_bits/static/description/images/6.png
Executable file
|
After Width: | Height: | Size: 298 KiB |
BIN
custom_addons/clarity_backend_theme_bits/static/description/images/7.png
Executable file
|
After Width: | Height: | Size: 312 KiB |
BIN
custom_addons/clarity_backend_theme_bits/static/description/images/8.png
Executable file
|
After Width: | Height: | Size: 251 KiB |
BIN
custom_addons/clarity_backend_theme_bits/static/description/images/9.png
Executable file
|
After Width: | Height: | Size: 680 KiB |
|
After Width: | Height: | Size: 207 KiB |
BIN
custom_addons/clarity_backend_theme_bits/static/description/images/banner.png
Executable file
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 726 KiB |
|
After Width: | Height: | Size: 746 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 489 KiB |
|
After Width: | Height: | Size: 525 B |
BIN
custom_addons/clarity_backend_theme_bits/static/description/images/icon_img.png
Executable file
|
After Width: | Height: | Size: 392 B |
BIN
custom_addons/clarity_backend_theme_bits/static/description/images/main-b.png
Executable file
|
After Width: | Height: | Size: 780 KiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 860 KiB |
|
After Width: | Height: | Size: 217 KiB |
757
custom_addons/clarity_backend_theme_bits/static/description/index.html
Executable 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>
|
||||||
BIN
custom_addons/clarity_backend_theme_bits/static/description/logo.gif
Executable file
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 5.3 MiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 65 KiB |
BIN
custom_addons/clarity_backend_theme_bits/static/description/new-sam-banner.gif
Executable file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
custom_addons/clarity_backend_theme_bits/static/description/theme_screenshot.gif
Executable file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
custom_addons/clarity_backend_theme_bits/static/img/logo.png
Executable file
|
After Width: | Height: | Size: 14 KiB |
193
custom_addons/clarity_backend_theme_bits/static/src/js/Headerbar.js
Executable 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 = {};
|
||||||
46
custom_addons/clarity_backend_theme_bits/static/src/js/SidebarBottom.js
Executable 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
|
||||||
|
});
|
||||||
110
custom_addons/clarity_backend_theme_bits/static/src/js/WebClient.js
Executable 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 },
|
||||||
|
});
|
||||||
67
custom_addons/clarity_backend_theme_bits/static/src/js/navbar.js
Executable 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
680
custom_addons/clarity_backend_theme_bits/static/src/scss/layout.scss
Executable 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;
|
||||||
|
}
|
||||||
70
custom_addons/clarity_backend_theme_bits/static/src/scss/login.scss
Executable 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
301
custom_addons/clarity_backend_theme_bits/static/src/scss/navbar.scss
Executable 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;
|
||||||
|
}
|
||||||
33
custom_addons/clarity_backend_theme_bits/static/src/xml/WebClient.xml
Executable 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&field=logo&id={{currentCompany.id}}"/>
|
||||||
|
<img class="img img-fluid company_logo_icon d-none" t-attf-src="/web/image?model=res.company&field=logo_icon&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>
|
||||||
141
custom_addons/clarity_backend_theme_bits/static/src/xml/navbar/sidebar.xml
Executable 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 && 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>
|
||||||
@@ -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>
|
||||||
29
custom_addons/clarity_backend_theme_bits/views/res_config_setting.xml
Executable 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>
|
||||||
23
custom_addons/clarity_backend_theme_bits/views/res_users.xml
Executable 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&version=18&app=simplify_access_management" title="terabits">Access Here.</a>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
32
custom_addons/clarity_backend_theme_bits/views/webclient_templates.xml
Executable 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&utm_medium=auth" target="_blank">Powered by <span>Odoo</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
3
custom_addons/encoach_adaptive/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
from . import services
|
||||||
26
custom_addons/encoach_adaptive/__manifest__.py
Normal 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',
|
||||||
|
}
|
||||||
1
custom_addons/encoach_adaptive/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import adaptive
|
||||||
351
custom_addons/encoach_adaptive/controllers/adaptive.py
Normal 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)
|
||||||
14
custom_addons/encoach_adaptive/data/ir_cron.xml
Normal 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>
|
||||||
3
custom_addons/encoach_adaptive/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from . import adaptive_event
|
||||||
|
from . import adaptive_path
|
||||||
|
from . import adaptive_settings
|
||||||
19
custom_addons/encoach_adaptive/models/adaptive_event.py
Normal 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)
|
||||||
16
custom_addons/encoach_adaptive/models/adaptive_path.py
Normal 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()
|
||||||
20
custom_addons/encoach_adaptive/models/adaptive_settings.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
3
custom_addons/encoach_adaptive/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .adaptive_engine import AdaptiveEngine
|
||||||
|
from . import style_matcher
|
||||||
|
from . import alert_service
|
||||||
149
custom_addons/encoach_adaptive/services/adaptive_engine.py
Normal 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
|
||||||
67
custom_addons/encoach_adaptive/services/alert_service.py
Normal 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)
|
||||||
35
custom_addons/encoach_adaptive/services/style_matcher.py
Normal 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)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
152
custom_addons/encoach_adaptive/static/src/js/signal_timeline.js
Normal 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);
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
22
custom_addons/encoach_adaptive/views/adaptive_menus.xml
Normal 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>
|
||||||
46
custom_addons/encoach_adaptive/views/adaptive_path_views.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
3
custom_addons/encoach_ai/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
from . import services
|
||||||
35
custom_addons/encoach_ai/__manifest__.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"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", "encoach_api"],
|
||||||
|
"external_dependencies": {
|
||||||
|
"python": ["openai", "boto3", "langgraph", "langchain_core"],
|
||||||
|
# Soft deps used only by free media fallbacks; the platform still
|
||||||
|
# boots and works fine without them — see services/free_image.py
|
||||||
|
# and services/free_tts.py for graceful import-failure handling.
|
||||||
|
# Add to a real requirements file: ``pip install Pillow gTTS``.
|
||||||
|
},
|
||||||
|
"data": [
|
||||||
|
"security/ir.model.access.csv",
|
||||||
|
"views/ai_settings_views.xml",
|
||||||
|
"data/ai_defaults.xml",
|
||||||
|
"data/agents_defaults.xml",
|
||||||
|
],
|
||||||
|
"installable": True,
|
||||||
|
"application": True,
|
||||||
|
"license": "LGPL-3",
|
||||||
|
}
|
||||||
7
custom_addons/encoach_ai/controllers/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from . import ai_controller
|
||||||
|
from . import coach_controller
|
||||||
|
from . import media_controller
|
||||||
|
from . import prompt_controller
|
||||||
|
from . import feedback_controller
|
||||||
|
from . import agents_controller
|
||||||
|
from . import ai_settings_controller
|
||||||
244
custom_addons/encoach_ai/controllers/agents_controller.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"""Admin endpoints for configuring and exercising AI agents.
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
|
||||||
|
* Read endpoints require a valid JWT but not admin. The "AI Agents"
|
||||||
|
tab needs to be reachable by anyone who can see ``/admin/ai/prompts``
|
||||||
|
today (analysts, teachers auditing prompt changes, etc.).
|
||||||
|
* Write endpoints — ``PATCH /api/ai/agents/<id>`` and
|
||||||
|
``POST /api/ai/agents/<id>/test`` — additionally require admin
|
||||||
|
privileges (``base.group_system``), matching the existing prompt
|
||||||
|
controller's policy.
|
||||||
|
* ``/test`` is deliberately synchronous and uncached: admins use it to
|
||||||
|
quickly verify a config change produces sane output. It caps the
|
||||||
|
LLM at 500 tokens to keep iteration cheap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
|
_error_response,
|
||||||
|
_get_json_body,
|
||||||
|
_json_response,
|
||||||
|
jwt_required,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin():
|
||||||
|
if not request.env.user.has_group("base.group_system"):
|
||||||
|
return _error_response("Admin privileges required", 403)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachAIAgentsController(http.Controller):
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/ai/agents
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route("/api/ai/agents", type="http", auth="none", methods=["GET"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def list_agents(self, **kw):
|
||||||
|
try:
|
||||||
|
search = (kw.get("search") or "").strip()
|
||||||
|
domain = []
|
||||||
|
if search:
|
||||||
|
domain = [
|
||||||
|
"|", "|",
|
||||||
|
("key", "ilike", search),
|
||||||
|
("name", "ilike", search),
|
||||||
|
("description", "ilike", search),
|
||||||
|
]
|
||||||
|
Agent = request.env["encoach.ai.agent"].sudo()
|
||||||
|
records = Agent.search(domain, order="sequence, name")
|
||||||
|
items = [r.to_api_dict(include_prompt=False) for r in records]
|
||||||
|
return _json_response({
|
||||||
|
"items": items,
|
||||||
|
"data": items,
|
||||||
|
"total": len(items),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception("list agents failed")
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/ai/agents/<id>
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
"/api/ai/agents/<int:agent_id>",
|
||||||
|
type="http", auth="none", methods=["GET"], csrf=False,
|
||||||
|
)
|
||||||
|
@jwt_required
|
||||||
|
def get_agent(self, agent_id, **kw):
|
||||||
|
try:
|
||||||
|
agent = request.env["encoach.ai.agent"].sudo().browse(int(agent_id))
|
||||||
|
if not agent.exists():
|
||||||
|
return _error_response("Agent not found", 404)
|
||||||
|
data = agent.to_api_dict(include_prompt=True)
|
||||||
|
data["tools"] = [t.to_api_dict() for t in agent.tool_ids]
|
||||||
|
return _json_response(data)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception("get agent failed")
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PATCH /api/ai/agents/<id> (admin-only)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
"/api/ai/agents/<int:agent_id>",
|
||||||
|
type="http", auth="none", methods=["PATCH", "PUT"], csrf=False,
|
||||||
|
)
|
||||||
|
@jwt_required
|
||||||
|
def update_agent(self, agent_id, **kw):
|
||||||
|
err = _require_admin()
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
try:
|
||||||
|
agent = request.env["encoach.ai.agent"].sudo().browse(int(agent_id))
|
||||||
|
if not agent.exists():
|
||||||
|
return _error_response("Agent not found", 404)
|
||||||
|
body = _get_json_body() or {}
|
||||||
|
vals: dict = {}
|
||||||
|
# Whitelist every settable field so callers can't flip `active` or
|
||||||
|
# rewrite `key` without knowing they're allowed to.
|
||||||
|
for f in (
|
||||||
|
"name", "description", "system_prompt", "prompt_key",
|
||||||
|
"model", "fallback_model", "response_format",
|
||||||
|
"graph_type", "quality_checks",
|
||||||
|
):
|
||||||
|
if f in body:
|
||||||
|
vals[f] = body[f] or ""
|
||||||
|
for f in ("temperature",):
|
||||||
|
if f in body:
|
||||||
|
try:
|
||||||
|
vals[f] = float(body[f])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
for f in ("max_tokens", "max_revisions", "sequence"):
|
||||||
|
if f in body:
|
||||||
|
try:
|
||||||
|
vals[f] = int(body[f])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if "active" in body:
|
||||||
|
vals["active"] = bool(body["active"])
|
||||||
|
if "tool_keys" in body and isinstance(body["tool_keys"], list):
|
||||||
|
tool_ids = request.env["encoach.ai.tool"].sudo().search(
|
||||||
|
[("key", "in", [str(k) for k in body["tool_keys"]])]
|
||||||
|
).ids
|
||||||
|
vals["tool_ids"] = [(6, 0, tool_ids)]
|
||||||
|
with request.env.cr.savepoint():
|
||||||
|
agent.write(vals)
|
||||||
|
return _json_response(agent.to_api_dict(include_prompt=True))
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception("update agent failed")
|
||||||
|
return _error_response(str(exc), 400)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/ai/agents/<id>/test (admin-only)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
"/api/ai/agents/<int:agent_id>/test",
|
||||||
|
type="http", auth="none", methods=["POST"], csrf=False,
|
||||||
|
)
|
||||||
|
@jwt_required
|
||||||
|
def test_agent(self, agent_id, **kw):
|
||||||
|
err = _require_admin()
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
try:
|
||||||
|
agent = request.env["encoach.ai.agent"].sudo().browse(int(agent_id))
|
||||||
|
if not agent.exists():
|
||||||
|
return _error_response("Agent not found", 404)
|
||||||
|
body = _get_json_body() or {}
|
||||||
|
variables = body.get("variables") or {}
|
||||||
|
payload = body.get("payload")
|
||||||
|
language = body.get("language") or request.env.user.lang or "en"
|
||||||
|
|
||||||
|
from odoo.addons.encoach_ai.services.agent_runtime import AgentRuntime
|
||||||
|
runtime = AgentRuntime(request.env, agent, language=language)
|
||||||
|
# Small-budget test: cap max_tokens so iteration stays cheap.
|
||||||
|
original_max = agent.max_tokens
|
||||||
|
if original_max > 800:
|
||||||
|
agent.sudo().write({"max_tokens": 800})
|
||||||
|
try:
|
||||||
|
final = runtime.invoke(variables=variables, payload=payload)
|
||||||
|
finally:
|
||||||
|
if agent.max_tokens != original_max:
|
||||||
|
agent.sudo().write({"max_tokens": original_max})
|
||||||
|
|
||||||
|
output = final.get("output")
|
||||||
|
return _json_response({
|
||||||
|
"error": final.get("error") or "",
|
||||||
|
"output": output,
|
||||||
|
"output_raw": (final.get("output_raw") or "")[:6000],
|
||||||
|
"tool_results": (final.get("tool_results") or [])[:20],
|
||||||
|
"retrieval_hits": len(final.get("retrieval") or []),
|
||||||
|
"revisions_used": final.get("revisions_used") or 0,
|
||||||
|
"quality_issues": final.get("quality_issues") or [],
|
||||||
|
"iterations": final.get("iterations") or 0,
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception("test agent failed")
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/ai/agents/tools
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
"/api/ai/agents/tools",
|
||||||
|
type="http", auth="none", methods=["GET"], csrf=False,
|
||||||
|
)
|
||||||
|
@jwt_required
|
||||||
|
def list_tools(self, **kw):
|
||||||
|
try:
|
||||||
|
tools = request.env["encoach.ai.tool"].sudo().search([], order="category, sequence, name")
|
||||||
|
items = [t.to_api_dict() for t in tools]
|
||||||
|
return _json_response({"items": items, "data": items, "total": len(items)})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception("list tools failed")
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PATCH /api/ai/agents/tools/<id> (admin-only; currently toggle active)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
"/api/ai/agents/tools/<int:tool_id>",
|
||||||
|
type="http", auth="none", methods=["PATCH", "PUT"], csrf=False,
|
||||||
|
)
|
||||||
|
@jwt_required
|
||||||
|
def update_tool(self, tool_id, **kw):
|
||||||
|
err = _require_admin()
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
try:
|
||||||
|
tool = request.env["encoach.ai.tool"].sudo().browse(int(tool_id))
|
||||||
|
if not tool.exists():
|
||||||
|
return _error_response("Tool not found", 404)
|
||||||
|
body = _get_json_body() or {}
|
||||||
|
vals: dict = {}
|
||||||
|
if "active" in body:
|
||||||
|
vals["active"] = bool(body["active"])
|
||||||
|
for f in ("name", "description", "category"):
|
||||||
|
if f in body:
|
||||||
|
vals[f] = body[f] or ""
|
||||||
|
if "schema" in body:
|
||||||
|
# Accept a parsed dict OR raw JSON string.
|
||||||
|
raw = body["schema"]
|
||||||
|
if isinstance(raw, (dict, list)):
|
||||||
|
vals["schema_json"] = json.dumps(raw)
|
||||||
|
else:
|
||||||
|
vals["schema_json"] = str(raw)
|
||||||
|
with request.env.cr.savepoint():
|
||||||
|
tool.write(vals)
|
||||||
|
return _json_response(tool.to_api_dict())
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception("update tool failed")
|
||||||
|
return _error_response(str(exc), 400)
|
||||||
1704
custom_addons/encoach_ai/controllers/ai_controller.py
Normal file
292
custom_addons/encoach_ai/controllers/ai_settings_controller.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""Admin endpoints for AI provider selection and API-key management.
|
||||||
|
|
||||||
|
* ``GET /api/ai/settings/providers`` — current provider per capability,
|
||||||
|
redacted view of which API keys are present (booleans only — keys are
|
||||||
|
*never* echoed back), and the list of allowed providers per capability.
|
||||||
|
|
||||||
|
* ``PATCH /api/ai/settings/providers`` — write provider choices and/or
|
||||||
|
API keys to ``ir.config_parameter``. Settings take effect on the very
|
||||||
|
next request (no caching), so admins can flip providers without an
|
||||||
|
Odoo restart.
|
||||||
|
|
||||||
|
The controller is admin-gated:
|
||||||
|
|
||||||
|
* The caller must be authenticated (``@jwt_required``).
|
||||||
|
* The caller must have ``user_type == 'admin'`` *or* be in the
|
||||||
|
``base.group_system`` group. Anything else returns 403.
|
||||||
|
|
||||||
|
API-key fields are write-only over the wire. Sending an empty string
|
||||||
|
clears the key; omitting the field leaves it unchanged. The GET response
|
||||||
|
returns ``{"openai_key_set": true | false, ...}`` markers so the UI can
|
||||||
|
render a "saved · click to replace" state without ever leaking the
|
||||||
|
secret value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request, Response
|
||||||
|
|
||||||
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
|
jwt_required, _json_response, _error_response, _get_json_body,
|
||||||
|
)
|
||||||
|
from odoo.addons.encoach_ai.services import provider_router
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration tables
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Provider keys per capability — mirrors provider_router.CAPABILITIES but
|
||||||
|
# adds UI labels and the "kind" so the frontend can render appropriate
|
||||||
|
# icons / disclaimers.
|
||||||
|
_PROVIDER_OPTIONS = {
|
||||||
|
'text': [
|
||||||
|
{'value': 'openai', 'label': 'OpenAI (GPT-4o)', 'kind': 'paid'},
|
||||||
|
{'value': 'mock', 'label': 'Mock (deterministic stub)', 'kind': 'free'},
|
||||||
|
],
|
||||||
|
'image': [
|
||||||
|
{'value': 'auto', 'label': 'Auto (paid → free fallback)', 'kind': 'auto'},
|
||||||
|
{'value': 'openai', 'label': 'OpenAI (DALL-E 3)', 'kind': 'paid'},
|
||||||
|
{'value': 'pillow', 'label': 'Pillow placeholder (offline)', 'kind': 'free'},
|
||||||
|
{'value': 'unsplash', 'label': 'Unsplash Source (free, network)', 'kind': 'free'},
|
||||||
|
{'value': 'mock', 'label': 'Mock card', 'kind': 'free'},
|
||||||
|
],
|
||||||
|
'audio': [
|
||||||
|
{'value': 'auto', 'label': 'Auto (paid → free fallback)', 'kind': 'auto'},
|
||||||
|
{'value': 'polly', 'label': 'AWS Polly (neural)', 'kind': 'paid'},
|
||||||
|
{'value': 'elevenlabs', 'label': 'ElevenLabs (multilingual)', 'kind': 'paid'},
|
||||||
|
{'value': 'gtts', 'label': 'gTTS (free, network)', 'kind': 'free'},
|
||||||
|
{'value': 'silent', 'label': 'Silent stub (offline)', 'kind': 'free'},
|
||||||
|
],
|
||||||
|
'video': [
|
||||||
|
{'value': 'auto', 'label': 'Auto', 'kind': 'auto'},
|
||||||
|
{'value': 'ffmpeg', 'label': 'ffmpeg slideshow (image+audio)', 'kind': 'free'},
|
||||||
|
{'value': 'static', 'label': 'Static placeholder image', 'kind': 'free'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# API-key params managed by this endpoint. Keys are write-only — the
|
||||||
|
# response only ever returns ``<name>_set: bool``.
|
||||||
|
_KEY_PARAMS = {
|
||||||
|
'openai_api_key': 'encoach_ai.openai_api_key',
|
||||||
|
'aws_access_key': 'encoach_ai.aws_access_key',
|
||||||
|
'aws_secret_key': 'encoach_ai.aws_secret_key',
|
||||||
|
'aws_region': 'encoach_ai.aws_region',
|
||||||
|
'elevenlabs_api_key': 'encoach_ai.elevenlabs_api_key',
|
||||||
|
'gptzero_api_key': 'encoach_ai.gptzero_api_key',
|
||||||
|
# Paymob (payments) — included so all platform secrets live in one UI
|
||||||
|
'paymob_api_key': 'encoach.paymob.api_key',
|
||||||
|
'paymob_integration_id': 'encoach.paymob.integration_id',
|
||||||
|
'paymob_iframe_id': 'encoach.paymob.iframe_id',
|
||||||
|
'paymob_hmac_secret': 'encoach.paymob.hmac_secret',
|
||||||
|
}
|
||||||
|
|
||||||
|
# These params don't carry secrets so we expose their plaintext values.
|
||||||
|
_PLAIN_PARAMS = {
|
||||||
|
'aws_region': 'encoach_ai.aws_region',
|
||||||
|
'openai_model': 'encoach_ai.openai_model',
|
||||||
|
'openai_fast_model': 'encoach_ai.openai_fast_model',
|
||||||
|
'elevenlabs_model': 'encoach_ai.elevenlabs_model',
|
||||||
|
'request_timeout': 'encoach_ai.request_timeout',
|
||||||
|
'max_retries': 'encoach_ai.max_retries',
|
||||||
|
'enabled': 'encoach_ai.enabled',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Authorization
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin(env):
|
||||||
|
"""Return True if the calling user is an EnCoach admin or system admin."""
|
||||||
|
user = env.user
|
||||||
|
if not user or not user.id:
|
||||||
|
return False
|
||||||
|
if user.has_group('base.group_system'):
|
||||||
|
return True
|
||||||
|
user_type = getattr(user, 'user_type', None)
|
||||||
|
return user_type == 'admin'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Serialization helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _read_state(env):
|
||||||
|
"""Build the full settings payload (no secrets in the output)."""
|
||||||
|
Param = env['ir.config_parameter'].sudo()
|
||||||
|
providers = {}
|
||||||
|
for cap, options in _PROVIDER_OPTIONS.items():
|
||||||
|
providers[cap] = {
|
||||||
|
'active': provider_router.get_active_provider(env, cap),
|
||||||
|
'options': options,
|
||||||
|
'paid_with_credentials': provider_router.get_paid_provider_keys(
|
||||||
|
env, cap,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
keys_set = {}
|
||||||
|
for short, full_param in _KEY_PARAMS.items():
|
||||||
|
# ``aws_region`` happens to live in both maps — it's not a secret,
|
||||||
|
# so we surface its value in ``plain`` and *also* mark it as set so
|
||||||
|
# the UI can show the field consistently.
|
||||||
|
val = Param.get_param(full_param)
|
||||||
|
keys_set[short] = bool(val)
|
||||||
|
plain = {short: Param.get_param(p, '')
|
||||||
|
for short, p in _PLAIN_PARAMS.items()}
|
||||||
|
return {
|
||||||
|
'providers': providers,
|
||||||
|
'keys_set': keys_set,
|
||||||
|
'plain': plain,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_clear_marker(value):
|
||||||
|
"""A trimmed, empty string explicitly clears the param."""
|
||||||
|
return isinstance(value, str) and value.strip() == ''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Controller
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AISettingsController(http.Controller):
|
||||||
|
"""REST surface for the AI Provider Settings admin page."""
|
||||||
|
|
||||||
|
@http.route('/api/ai/settings/providers',
|
||||||
|
type='http', auth='none', methods=['GET', 'OPTIONS'],
|
||||||
|
csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def get_providers(self, **kw):
|
||||||
|
if not _is_admin(request.env):
|
||||||
|
return _error_response('Admin access required', 403)
|
||||||
|
try:
|
||||||
|
return _json_response({'data': _read_state(request.env)})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('ai_settings.get failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
@http.route('/api/ai/settings/providers',
|
||||||
|
type='http', auth='none', methods=['PATCH', 'POST', 'PUT'],
|
||||||
|
csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def patch_providers(self, **kw):
|
||||||
|
if not _is_admin(request.env):
|
||||||
|
return _error_response('Admin access required', 403)
|
||||||
|
try:
|
||||||
|
body = _get_json_body() or {}
|
||||||
|
Param = request.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
|
# 1. Update active provider per capability — validate that the
|
||||||
|
# chosen value is one of the offered options to keep junk
|
||||||
|
# out of ir.config_parameter.
|
||||||
|
provider_updates = body.get('providers') or {}
|
||||||
|
invalid = []
|
||||||
|
for cap, value in provider_updates.items():
|
||||||
|
if cap not in _PROVIDER_OPTIONS:
|
||||||
|
invalid.append(f'unknown capability: {cap}')
|
||||||
|
continue
|
||||||
|
allowed = {o['value'] for o in _PROVIDER_OPTIONS[cap]}
|
||||||
|
if value not in allowed:
|
||||||
|
invalid.append(f'{cap}: {value!r} not in {sorted(allowed)}')
|
||||||
|
continue
|
||||||
|
Param.set_param(provider_router.CAPABILITIES[cap]['param'], value)
|
||||||
|
if invalid:
|
||||||
|
return _error_response(
|
||||||
|
'Invalid provider selections: ' + '; '.join(invalid), 400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Update API keys — write-only. An empty string clears the
|
||||||
|
# param; omitting the field leaves it untouched.
|
||||||
|
key_updates = body.get('keys') or {}
|
||||||
|
for short, value in key_updates.items():
|
||||||
|
if short not in _KEY_PARAMS:
|
||||||
|
continue
|
||||||
|
full_param = _KEY_PARAMS[short]
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if _is_clear_marker(value):
|
||||||
|
Param.set_param(full_param, '')
|
||||||
|
else:
|
||||||
|
# Trim whitespace to defend against a copy-paste with
|
||||||
|
# a trailing newline that would silently break SDK auth.
|
||||||
|
Param.set_param(full_param, str(value).strip())
|
||||||
|
|
||||||
|
# 3. Plain-value updates (model names, region, timeout, ...).
|
||||||
|
plain_updates = body.get('plain') or {}
|
||||||
|
for short, value in plain_updates.items():
|
||||||
|
if short not in _PLAIN_PARAMS:
|
||||||
|
continue
|
||||||
|
Param.set_param(_PLAIN_PARAMS[short], '' if value is None
|
||||||
|
else str(value))
|
||||||
|
|
||||||
|
# Audit log so admins can see who flipped providers.
|
||||||
|
try:
|
||||||
|
_logger.info(
|
||||||
|
'ai_settings.update by user_id=%s providers=%s '
|
||||||
|
'keys_changed=%s plain_changed=%s',
|
||||||
|
request.env.user.id,
|
||||||
|
list(provider_updates.keys()),
|
||||||
|
[k for k in key_updates.keys() if k in _KEY_PARAMS],
|
||||||
|
list(plain_updates.keys()),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return _json_response({'data': _read_state(request.env)})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('ai_settings.patch failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
|
|
||||||
|
@http.route('/api/ai/settings/providers/test',
|
||||||
|
type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def test_provider(self, **kw):
|
||||||
|
"""Quick "is this configured?" probe for the UI's Test button.
|
||||||
|
|
||||||
|
Body: ``{"capability": "image" | "audio" | "text"}``.
|
||||||
|
|
||||||
|
Returns the resolved provider chain plus a flag for each entry
|
||||||
|
indicating whether credentials are present. We deliberately do
|
||||||
|
NOT make a real network call — we just resolve the chain and
|
||||||
|
check for credentials so the test is instant and free.
|
||||||
|
"""
|
||||||
|
if not _is_admin(request.env):
|
||||||
|
return _error_response('Admin access required', 403)
|
||||||
|
try:
|
||||||
|
body = _get_json_body() or {}
|
||||||
|
capability = body.get('capability') or 'image'
|
||||||
|
if capability not in provider_router.CAPABILITIES:
|
||||||
|
return _error_response(
|
||||||
|
f'Unknown capability: {capability}', 400,
|
||||||
|
)
|
||||||
|
chain = provider_router.resolve_chain(request.env, capability)
|
||||||
|
paid_with_creds = set(
|
||||||
|
provider_router.get_paid_provider_keys(request.env, capability),
|
||||||
|
)
|
||||||
|
entries = []
|
||||||
|
for prov in chain:
|
||||||
|
if prov in provider_router.CAPABILITIES[capability]['paid']:
|
||||||
|
ok = prov in paid_with_creds
|
||||||
|
note = 'credentials configured' if ok else 'no API key'
|
||||||
|
else:
|
||||||
|
ok = True # free providers are always available
|
||||||
|
note = 'free fallback'
|
||||||
|
entries.append({'provider': prov, 'ok': ok, 'note': note})
|
||||||
|
return _json_response({
|
||||||
|
'capability': capability,
|
||||||
|
'active': provider_router.get_active_provider(
|
||||||
|
request.env, capability),
|
||||||
|
'chain': entries,
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('ai_settings.test failed')
|
||||||
|
return _error_response(str(exc), 500)
|
||||||
130
custom_addons/encoach_ai/controllers/coach_controller.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""REST endpoints for AI coaching — matches frontend coaching.service.ts."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request, Response
|
||||||
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
|
jwt_required, _json_response as _base_json_response, _get_json_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_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 {}
|
||||||
|
|
||||||
|
|
||||||
|
def _request_language():
|
||||||
|
"""Read the caller's UI language from the ``Accept-Language`` header.
|
||||||
|
|
||||||
|
The frontend ``api-client`` automatically attaches this header from the
|
||||||
|
active i18n language so AI-generated text can be localized. Falls back
|
||||||
|
to English if the header is missing or malformed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return request.httprequest.headers.get("Accept-Language", "") 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, language=_request_language())
|
||||||
|
|
||||||
|
# ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
|
||||||
|
@http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
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="none", methods=["GET"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
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="none", methods=["POST"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
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="none", methods=["POST"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
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="none", methods=["POST"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
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="none", methods=["POST"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
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"})
|
||||||
217
custom_addons/encoach_ai/controllers/feedback_controller.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"""Endpoints for submitting & aggregating AI feedback."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import fields, http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
|
_error_response,
|
||||||
|
_get_json_body,
|
||||||
|
_json_response,
|
||||||
|
_paginate,
|
||||||
|
jwt_required,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _feedback_to_dict(row):
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"subject_type": row.subject_type,
|
||||||
|
"subject_id": row.subject_id,
|
||||||
|
"subject_key": row.subject_key,
|
||||||
|
"rating": row.rating,
|
||||||
|
"comment": row.comment or "",
|
||||||
|
"tags": (row.tags or "").split(",") if row.tags else [],
|
||||||
|
"prompt_key": row.prompt_key or None,
|
||||||
|
"prompt_version": row.prompt_version or None,
|
||||||
|
"ai_log_id": row.ai_log_id.id if row.ai_log_id else None,
|
||||||
|
"user_id": row.user_id.id,
|
||||||
|
"user_name": row.user_id.name,
|
||||||
|
"entity_id": row.entity_id.id if row.entity_id else None,
|
||||||
|
"course_id": row.course_id.id if row.course_id else None,
|
||||||
|
"status": row.status,
|
||||||
|
"create_date": (
|
||||||
|
fields.Datetime.to_string(row.create_date) if row.create_date else None
|
||||||
|
),
|
||||||
|
"resolved_at": (
|
||||||
|
fields.Datetime.to_string(row.resolved_at) if row.resolved_at else None
|
||||||
|
),
|
||||||
|
"resolution_notes": row.resolution_notes or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachAIFeedbackController(http.Controller):
|
||||||
|
"""Feedback write + admin triage endpoints."""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/ai/feedback — student submits/updates feedback
|
||||||
|
#
|
||||||
|
# Upsert semantics: we enforce uniqueness per (user, subject_type,
|
||||||
|
# subject_id) at the DB layer, so the client doesn't need to know whether
|
||||||
|
# the row already exists.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route("/api/ai/feedback", type="http", auth="none", methods=["POST"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def submit(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body() or {}
|
||||||
|
subject_type = body.get("subject_type")
|
||||||
|
subject_id = body.get("subject_id")
|
||||||
|
rating = body.get("rating")
|
||||||
|
if subject_type not in {
|
||||||
|
"question", "coach", "explanation", "translation", "narrative", "other",
|
||||||
|
}:
|
||||||
|
return _error_response("Invalid subject_type", 400)
|
||||||
|
if not isinstance(subject_id, int) or subject_id <= 0:
|
||||||
|
return _error_response("subject_id must be a positive integer", 400)
|
||||||
|
if rating not in {"up", "down"}:
|
||||||
|
return _error_response("rating must be 'up' or 'down'", 400)
|
||||||
|
comment = body.get("comment") or ""
|
||||||
|
if rating == "down" and not (comment or "").strip():
|
||||||
|
# We keep this a soft constraint so the UI can decide how
|
||||||
|
# strict to be; returning an error here gives clients a clear
|
||||||
|
# path to prompt the user when needed.
|
||||||
|
return _error_response(
|
||||||
|
"A short comment is required when rating is 'down'.", 400,
|
||||||
|
)
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
"subject_type": subject_type,
|
||||||
|
"subject_id": subject_id,
|
||||||
|
"rating": rating,
|
||||||
|
"comment": comment,
|
||||||
|
"tags": (
|
||||||
|
",".join(body.get("tags") or [])
|
||||||
|
if isinstance(body.get("tags"), list)
|
||||||
|
else (body.get("tags") or "")
|
||||||
|
),
|
||||||
|
"prompt_key": body.get("prompt_key") or False,
|
||||||
|
"prompt_version": body.get("prompt_version") or 0,
|
||||||
|
"ai_log_id": body.get("ai_log_id") or False,
|
||||||
|
"entity_id": body.get("entity_id") or False,
|
||||||
|
"course_id": body.get("course_id") or False,
|
||||||
|
"user_id": request.env.user.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
Feedback = request.env["encoach.ai.feedback"].sudo()
|
||||||
|
existing = Feedback.search([
|
||||||
|
("user_id", "=", request.env.user.id),
|
||||||
|
("subject_type", "=", subject_type),
|
||||||
|
("subject_id", "=", subject_id),
|
||||||
|
], limit=1)
|
||||||
|
with request.env.cr.savepoint():
|
||||||
|
if existing:
|
||||||
|
existing.write(vals)
|
||||||
|
row = existing
|
||||||
|
else:
|
||||||
|
row = Feedback.create(vals)
|
||||||
|
return _json_response(_feedback_to_dict(row), 200 if existing else 201)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("ai feedback submit failed")
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/ai/feedback/summary?subject_type=question&subject_id=42
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
"/api/ai/feedback/summary", type="http", auth="none", methods=["GET"], csrf=False,
|
||||||
|
)
|
||||||
|
@jwt_required
|
||||||
|
def summary(self, subject_type=None, subject_id=None, **kw):
|
||||||
|
try:
|
||||||
|
if not subject_type or not subject_id:
|
||||||
|
return _error_response("subject_type and subject_id are required", 400)
|
||||||
|
Feedback = request.env["encoach.ai.feedback"].sudo()
|
||||||
|
domain = [
|
||||||
|
("subject_type", "=", subject_type),
|
||||||
|
("subject_id", "=", int(subject_id)),
|
||||||
|
]
|
||||||
|
rows = Feedback.search(domain)
|
||||||
|
up = sum(1 for r in rows if r.rating == "up")
|
||||||
|
down = sum(1 for r in rows if r.rating == "down")
|
||||||
|
my_row = Feedback.search([
|
||||||
|
*domain,
|
||||||
|
("user_id", "=", request.env.user.id),
|
||||||
|
], limit=1)
|
||||||
|
return _json_response({
|
||||||
|
"subject_type": subject_type,
|
||||||
|
"subject_id": int(subject_id),
|
||||||
|
"up": up,
|
||||||
|
"down": down,
|
||||||
|
"total": up + down,
|
||||||
|
"my_rating": my_row.rating if my_row else None,
|
||||||
|
"my_comment": my_row.comment or "" if my_row else "",
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("ai feedback summary failed")
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/ai/feedback — admin triage list
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route("/api/ai/feedback", type="http", auth="none", methods=["GET"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def list_feedback(self, **kw):
|
||||||
|
try:
|
||||||
|
if not request.env.user.has_group("base.group_system"):
|
||||||
|
return _error_response("Admin privileges required", 403)
|
||||||
|
Feedback = request.env["encoach.ai.feedback"].sudo()
|
||||||
|
domain = []
|
||||||
|
if kw.get("status"):
|
||||||
|
domain.append(("status", "=", kw["status"]))
|
||||||
|
if kw.get("rating"):
|
||||||
|
domain.append(("rating", "=", kw["rating"]))
|
||||||
|
if kw.get("subject_type"):
|
||||||
|
domain.append(("subject_type", "=", kw["subject_type"]))
|
||||||
|
if kw.get("prompt_key"):
|
||||||
|
domain.append(("prompt_key", "=", kw["prompt_key"]))
|
||||||
|
offset, limit, page = _paginate(kw)
|
||||||
|
total = Feedback.search_count(domain)
|
||||||
|
rows = Feedback.search(domain, offset=offset, limit=limit, order="create_date desc")
|
||||||
|
items = [_feedback_to_dict(r) for r in rows]
|
||||||
|
return _json_response({
|
||||||
|
"items": items,
|
||||||
|
"data": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": limit,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("ai feedback list failed")
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/ai/feedback/<id>/resolve — admin triage
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(
|
||||||
|
'/api/ai/feedback/<int:feedback_id>/resolve',
|
||||||
|
type="http", auth="none", methods=["POST"], csrf=False,
|
||||||
|
)
|
||||||
|
@jwt_required
|
||||||
|
def resolve(self, feedback_id, **kw):
|
||||||
|
try:
|
||||||
|
if not request.env.user.has_group("base.group_system"):
|
||||||
|
return _error_response("Admin privileges required", 403)
|
||||||
|
body = _get_json_body() or {}
|
||||||
|
status = body.get("status")
|
||||||
|
if status not in {"acknowledged", "fixed", "dismissed"}:
|
||||||
|
return _error_response(
|
||||||
|
"status must be one of acknowledged|fixed|dismissed", 400,
|
||||||
|
)
|
||||||
|
row = request.env["encoach.ai.feedback"].sudo().browse(feedback_id)
|
||||||
|
if not row.exists():
|
||||||
|
return _error_response("Feedback not found", 404)
|
||||||
|
with request.env.cr.savepoint():
|
||||||
|
row.write({
|
||||||
|
"status": status,
|
||||||
|
"resolved_by_id": request.env.user.id,
|
||||||
|
"resolved_at": fields.Datetime.now(),
|
||||||
|
"resolution_notes": body.get("notes") or "",
|
||||||
|
})
|
||||||
|
return _json_response(_feedback_to_dict(row))
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception("ai feedback resolve failed")
|
||||||
|
return _error_response(str(e), 500)
|
||||||
158
custom_addons/encoach_ai/controllers/media_controller.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""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
|
||||||
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
|
jwt_required, _json_response as _base_json_response, _get_json_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_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="none", methods=["POST"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
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="none", methods=["POST"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
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="none", methods=["GET"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
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="none", methods=["POST"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
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="none", methods=["GET"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
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="none", methods=["POST"], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
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)
|
||||||