[I18N] other: automatic update

Auto-generated commit based on local changes.
This commit is contained in:
maltayyar2 2025-12-27 00:41:36 +03:00
parent f586a60704
commit 8c54caad84
91 changed files with 22582 additions and 1582 deletions

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#from . import controllers
from . import models
from . import models

View File

@ -1,34 +1,77 @@
# -*- coding: utf-8 -*-
{
'name': "System Board",
'summary': """
This module allows you to configure dashboard for different states or stages for specific model.""",
'name': "System Dashboard",
'summary': "Configurable dashboard for employee self-service and manager approvals",
'description': """
This module allows you to configure dashboard for different users
add state and its corresponding group which it will be visible ,
depends on logged in user they can see records if it is on one state that they has access on
there also a self service screen which employees can see their request in any model.""",
System Dashboard Classic
========================
A comprehensive dashboard module that provides:
* **Self-Service Portal**: Employees can view and manage their own requests
(leaves, expenses, timesheets, etc.)
* **Manager Dashboard**: Managers can see pending approvals with state/stage
filtering based on user groups
* **Configurable Services**: Add any Odoo model as a dashboard service card
with custom actions and views
* **Attendance Integration**: Built-in check-in/check-out functionality
(requires 'attendances' module)
* **Theme Customization**: Configurable colors and visibility settings
Key Features:
-------------
- Dynamic state/stage loading from any model
- Group-based visibility for approval cards
- Self-service mode for employee-facing services
- Real-time attendance with confetti celebration
- Responsive design with RTL support
""",
'author': "Expert Co. Ltd., Sudan Team",
# 'website': "http://www.yourcompany.com",
'category': 'Uncategorized',
'version': '0.1',
'application':True,
# any module necessary for this one to work correctly
'depends': ['base'],
# always loaded
'category': 'Human Resources/Dashboard',
'version': '14.0.1.0.0',
'license': 'LGPL-3',
'application': True,
# Required Dependencies
'depends': [
#'hr_base', MUST BE FIRST! exp_hr_payroll uses its groups but doesn't depend on it
'base', # Core Odoo
'resource', # Work calendar for attendance hours
'web', # Dashboard assets & QWeb client actions
'employee_requests',
'hr_holidays_public',
'hr_timesheet',
'exp_official_mission',
'odex_mobile',
],
# Optional Dependencies (soft - checked at runtime):
# - attendances: For check-in/check-out functionality (attendance.attendance model)
# - odoo_dynamic_workflow: For dynamic workflow states integration
# - hr_holidays: For leave management integration
# - hr_payroll: For payslip integration
# - hr_timesheet: For timesheet integration (account.analytic.line)
# Data files
'data': [
'security/ir.model.access.csv',
'security/security.xml',
'views/system_dashboard.xml',
'views/config.xml',
'views/dashboard_settings.xml',
],
# QWeb templates
'qweb': [
"static/src/xml/*.xml",
'static/src/xml/*.xml',
],
# Assets are loaded via views/system_dashboard.xml
'installable': True,
'auto_install': False,
}

View File

@ -1,3 +0,0 @@
# -*- coding: utf-8 -*-
#from . import controllers

View File

@ -1,56 +0,0 @@
# # -*- coding: utf-8 -*-
# # Part of Odoo. See LICENSE file for full copyright and licensing details.
# from odoo import http, _
# from odoo.http import request
# from odoo.tools.safe_eval import safe_eval
# class BaseDashboard(http.Controller):
# @http.route(['/system/dashboard'], type='http', auth="user", website=True)
# def system_dashboard(self, **kw):
# values = []
# base = request.env['base.dashbord'].sudo().search([])
# for models in base:
# for model in models:
# for line in model.board_ids:
# if request.uid in line.group_id.users.ids:
# state_click = safe_eval(line.state)
# state_follow = safe_eval(line.state)
# #get only state from list of list ex:[['state', '=', 'confrim']] return draft,so it become record to confrim
# state = state_click[-1][-1]
# #get state to follow ,which is not in curent state so replace = with !=
# state_follow [-1][-2] = '!='
# state_to_click = request.env[line.model_name].sudo().search_count(state_click)
# state_to_follow = request.env[line.model_name].sudo().search_count(state_follow)
# values.append({
# 'type':'user', # to differentiate between user has record to click on or employee has requests to see
# 'name':line.model_id.name, # name in card ex: Leave
# 'user':request.env.user, # user data on user card,use user.name to get his name
# 'model': line.model_name,# to be passed to js as field res_model
# 'count_state_click':state_to_click, #count of state to click on card
# 'count_state_follow':state_to_follow,#count of state to follow on card
# 'state': 'records to ' + '' + state, # title of card ex:records to confirm
# 'domain_to_click': state_click, # to be passed to js for his records on field called domain on action window
# 'domain_to_follow':state_follow , # to be passed to js for records to follow up on field called domain on action window
# })
# #if user has no record to click but needs to create new records or see his requests
# else:
# values.append({
# 'type':'service',
# 'name':line.model_id.name,
# 'user':request.env.user,
# 'model': line.model_name,
# })
# print("values========================",values)
# return request.render("system_dashboard.portal_template", {'values':values})

View File

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- <data noupdate="1">
<record id="sale_draft" model="base.dashbord">
<field name="model_id" ref="sale.model_sale_order"/>
<field name="model_name">sale.order</field>
</record>
<record id="sale_draft_line" model="base.dashbord.line">
<field name="board_id" ref="system_dashboard.sale_draft"/>
<field name="state">draft</field>
<field name="group_id" ref="base.group_user"/>
<field name="model_name">sale.order</field>
</record>
<record id="hr_holidays_confirm" model="base.dashbord">
<field name="model_id" ref="hr_holidays.model_hr_holidays"/>
<field name="model_name">hr.holidays</field>
</record>
<record id="hr_holidays_confirm_line" model="base.dashbord.line">
<field name="board_id" ref="system_dashboard.hr_holidays_confirm"/>
<field name="state">confirm</field>
<field name="group_id" ref="hr_holidays.group_hr_holidays_manager"/>
<field name="model_name">hr.holidays</field>
</record>
<record id="hr_holidays_validate" model="base.dashbord">
<field name="model_id" ref="hr_holidays.model_hr_holidays"/>
<field name="model_name">hr.holidays</field>
</record>
<record id="hr_holidays_validate_line" model="base.dashbord.line">
<field name="board_id" ref="system_dashboard.hr_holidays_validate"/>
<field name="state">validate</field>
<field name="group_id" ref="hr_holidays.group_hr_holidays_manager"/>
<field name="model_name">hr.holidays</field>
</record>
<record id="hr_holidays_draft_line" model="base.dashbord.line">
<field name="board_id" ref="system_dashboard.hr_holidays_validate"/>
<field name="state">draft</field>
<field name="group_id" ref="hr_holidays.group_hr_holidays_manager"/>
<field name="model_name">hr.holidays</field>
</record> -->
</data>
</odoo>

View File

@ -1,30 +1,5 @@
<odoo>
<data>
<!-- -->
<!-- <record id="object0" model="cash_flow.cash_flow"> -->
<!-- <field name="name">Object 0</field> -->
<!-- <field name="value">0</field> -->
<!-- </record> -->
<!-- -->
<!-- <record id="object1" model="cash_flow.cash_flow"> -->
<!-- <field name="name">Object 1</field> -->
<!-- <field name="value">10</field> -->
<!-- </record> -->
<!-- -->
<!-- <record id="object2" model="cash_flow.cash_flow"> -->
<!-- <field name="name">Object 2</field> -->
<!-- <field name="value">20</field> -->
<!-- </record> -->
<!-- -->
<!-- <record id="object3" model="cash_flow.cash_flow"> -->
<!-- <field name="name">Object 3</field> -->
<!-- <field name="value">30</field> -->
<!-- </record> -->
<!-- -->
<!-- <record id="object4" model="cash_flow.cash_flow"> -->
<!-- <field name="name">Object 4</field> -->
<!-- <field name="value">40</field> -->
<!-- </record> -->
<!-- -->
<!-- Demo data placeholder - no demo records currently defined -->
</data>
</odoo>

View File

@ -14,7 +14,8 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
"X-Generator: Poedit 3.3.2\n"
#. module: system_dashboard_classic
@ -61,8 +62,8 @@ msgstr "الحضور والانصراف"
#: model:ir.actions.act_window,name:system_dashboard_classic.base_dashboard_action
#: model:ir.model,name:system_dashboard_classic.model_base_dashbord
#: model:ir.ui.menu,name:system_dashboard_classic.base_dashboard
msgid "Base Dashboard"
msgstr "لوحة المعلومات الاساسية"
msgid "Dashboard Builder"
msgstr "تصميم الداشبورد"
#. module: system_dashboard_classic
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord_line__board_id
@ -81,8 +82,8 @@ msgstr "اسم النموذج"
#. module: system_dashboard_classic
#: model:ir.ui.menu,name:system_dashboard_classic.base_dashboard_root
msgid "Configrutions"
msgstr "الاعدادات"
msgid "Configuration"
msgstr "الإعدادات"
#. module: system_dashboard_classic
#: model_terms:ir.actions.act_window,help:system_dashboard_classic.approval_screen
@ -227,11 +228,11 @@ msgstr "قسيمة راتب"
#: code:addons/system_dashboard_classic/static/src/xml/system_dashboard.xml:0
#, python-format
msgid "Salary Slips"
msgstr "قسيمة الراتب"
msgstr "قسائم الراتب"
#. module: system_dashboard_classic
#: model:ir.ui.menu,name:system_dashboard_classic.menu_self_service_service
msgid "Self Service Screen"
msgid "Self Service"
msgstr "الخدمة الذاتية"
#. module: system_dashboard_classic
@ -246,6 +247,7 @@ msgstr "بدون تأثير مالي؟"
#. module: system_dashboard_classic
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord__sequence
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord_line__sequence
msgid "sequence"
msgstr "التسلسل"
@ -277,13 +279,9 @@ msgstr "الحالة"
#: model:ir.actions.act_window,name:system_dashboard_classic.approval_screen
#: model:ir.actions.act_window,name:system_dashboard_classic.self_service_dashboard
#: model:ir.model,name:system_dashboard_classic.model_system_dashboard_classic_dashboard
msgid "System Dashboard"
msgstr "لوحة المعلومات"
#. module: system_dashboard_classic
#: model:ir.ui.menu,name:system_dashboard_classic.system_dashboard_classic_menu
msgid "System Dashboard"
msgstr "لوحة الخدمة الذاتية"
msgid "Dashboard"
msgstr "لوحة المعلومات"
#. module: system_dashboard_classic
#: code:addons/system_dashboard_classic/models/config.py:0
@ -403,11 +401,6 @@ msgstr ""
msgid "records to "
msgstr ""
#. module: system_dashboard_classic
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord_line__sequence
msgid "sequence"
msgstr "التسلسل"
#. module: system_dashboard_classic
#: model:ir.model,name:system_dashboard_classic.model_stage_stage
msgid "stage.stage"
@ -437,4 +430,147 @@ msgstr "حذف المراحل"
#. module: system_dashboard_classic
#: model:ir.model.fields,field_description:system_dashboard_classic.field_base_dashbord__search_field
msgid "Search field"
msgstr "حقل البحث"
msgstr "حقل البحث"
#. module: system_dashboard_classic
#. openerp-web
#: code:addons/system_dashboard_classic/static/src/xml/self_service_dashboard.xml:0
#: code:addons/system_dashboard_classic/static/src/xml/system_dashboard.xml:0
#, python-format
msgid "Monthly Attendance"
msgstr "الحضور الشهري"
#. module: system_dashboard_classic
#. openerp-web
#: code:addons/system_dashboard_classic/static/src/xml/self_service_dashboard.xml:0
#: code:addons/system_dashboard_classic/static/src/xml/system_dashboard.xml:0
#, python-format
msgid "Timesheet"
msgstr "الجداول الزمنية"
#. module: system_dashboard_classic
#: model:ir.model.fields,field_description:system_dashboard_classic.field_res_config_settings__dashboard_primary_color
msgid "Primary Color"
msgstr "اللون الأساسي"
#. module: system_dashboard_classic
#: model:ir.model.fields,field_description:system_dashboard_classic.field_res_config_settings__dashboard_secondary_color
msgid "Secondary Color"
msgstr "اللون الثانوي"
#. module: system_dashboard_classic
#: model:ir.model.fields,field_description:system_dashboard_classic.field_res_config_settings__dashboard_success_color
msgid "Success Color"
msgstr "لون النجاح/الاتصال"
#. module: system_dashboard_classic
#: model:ir.model.fields,field_description:system_dashboard_classic.field_res_config_settings__dashboard_warning_color
msgid "Warning Color"
msgstr "لون التنبيه/المتبقي"
#. module: system_dashboard_classic
#. openerp-web
#: code:addons/system_dashboard_classic/static/src/js/system_dashboard.js:0
#, python-format
msgid "Remaining"
msgstr "المتبقي"
#. module: system_dashboard_classic
#. openerp-web
#: code:addons/system_dashboard_classic/static/src/js/system_dashboard.js:0
#, python-format
msgid "Total"
msgstr "الإجمالي"
#. module: system_dashboard_classic
#: model:ir.model.fields,field_description:system_dashboard_classic.field_res_config_settings__dashboard_show_attendance_section
msgid "Show Attendance Section"
msgstr "إظهار قسم الحضور والانصراف"
#. module: system_dashboard_classic
#: model:ir.actions.act_window,name:system_dashboard_classic.action_dashboard_configuration
#: model:ir.ui.menu,name:system_dashboard_classic.menu_dashboard_configuration
msgid "General Settings"
msgstr "الإعدادات العامة"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "Model Configuration"
msgstr "إعدادات النموذج"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "Display Options"
msgstr "خيارات العرض"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "Employee Filter Configuration"
msgstr "إعدادات فلترة الموظف"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "Advanced View Settings"
msgstr "إعدادات العرض المتقدمة"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "State/Stage Configuration"
msgstr "إعدادات الحالات/المراحل"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "Load Model States"
msgstr "تحميل حالات النموذج"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "Refresh States"
msgstr "تحديث الحالات"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "Remove All States"
msgstr "حذف جميع الحالات"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "Service Name"
msgstr "اسم الخدمة"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "Lower numbers appear first"
msgstr "الأرقام الأصغر تظهر أولاً"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "Enable for employee self-service cards"
msgstr "تفعيل لبطاقات الخدمة الذاتية للموظف"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "Mark if this service has no financial impact"
msgstr "حدد إذا كانت هذه الخدمة بدون تأثير مالي"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "The action to open when clicking this card"
msgstr "الإجراء الذي يفتح عند النقر على هذه البطاقة"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid "Create your first dashboard service"
msgstr "أنشئ أول خدمة في الداشبورد"
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_form
msgid ""
"Configure which Odoo models appear as service cards on the employee "
"dashboard."
msgstr "حدد أي نماذج Odoo تظهر كبطاقات خدمات في لوحة تحكم الموظف."
#. module: system_dashboard_classic
#: model_terms:ir.ui.view,arch_db:system_dashboard_classic.view_base_dashboard_tree
msgid "Dashboard Services"
msgstr "خدمات الداشبورد"

View File

@ -1,2 +1,3 @@
from . import config
from . import models
from . import models
from . import res_users

View File

@ -3,7 +3,7 @@ from odoo.exceptions import ValidationError
class BaseDashboard(models.Model):
_name = 'base.dashbord'
_description = 'Base Dashboard'
_description = 'Dashboard Builder'
_order = 'sequence'
sequence = fields.Integer()
@ -29,6 +29,39 @@ class BaseDashboard(models.Model):
)
icon_type = fields.Selection(
[('image', 'Image'), ('icon', 'Icon Library')],
string='Icon Type',
default='image',
required=True
)
icon_name = fields.Char(
string='Icon Class',
help="FontAwesome class (e.g., 'fa-plane', 'fa-users'). See https://fontawesome.com/v4/icons/"
)
icon_preview_html = fields.Html(compute='_compute_icon_preview', string="Icon/Image Preview")
@api.depends('icon_type', 'icon_name', 'card_image')
def _compute_icon_preview(self):
for record in self:
if record.icon_type == 'icon' and record.icon_name:
# Dynamic Icon Preview - Class based sizing
record.icon_preview_html = f'<div class="dashboard-icon-preview-box icon-mode"><i class="fa {record.icon_name}"></i></div>'
elif record.icon_type == 'image' and record.card_image:
# Actual Image Preview
# CRITICAL: Use bin_size=False to ensure we get DATA not SIZE TEXT (e.g. "25 KB")
try:
img_rec = record.with_context(bin_size=False)
image_data = img_rec.card_image.decode('utf-8') if isinstance(img_rec.card_image, bytes) else img_rec.card_image
record.icon_preview_html = f'<div class="dashboard-icon-preview-box image-mode"><img src="data:image/png;base64,{image_data}"/></div>'
except Exception:
record.icon_preview_html = '<div class="dashboard-icon-preview-box error-mode"><i class="fa fa-exclamation-triangle"></i></div>'
else:
# Default Placeholder
record.icon_preview_html = '<div class="dashboard-icon-preview-box default-mode"><i class="fa fa-th-large"></i></div>'
card_image = fields.Binary(
string='Card Image',
)
@ -431,3 +464,208 @@ class StageStage(models.Model):
form_view_id = fields.Many2one('ir.ui.view', string='Form View',readonly=True)
list_view_id = fields.Many2one('ir.ui.view', string='List View',readonly=True)
action_id = fields.Many2one('ir.actions.act_window', string='Action',readonly=True)
class DashboardConfigSettings(models.TransientModel):
"""Dashboard Theme Settings - Configurable colors from UI"""
_inherit = 'res.config.settings'
_description = 'Dashboard Configuration Settings'
# Dashboard Theme Colors
dashboard_primary_color = fields.Char(
string='Primary Color',
config_parameter='system_dashboard_classic.primary_color',
default='#0891b2',
help='Main accent color for the dashboard (e.g., #0891b2)'
)
dashboard_secondary_color = fields.Char(
string='Secondary Color',
config_parameter='system_dashboard_classic.secondary_color',
default='#1e293b',
help='Secondary color for headers and dark elements (e.g., #1e293b)'
)
dashboard_success_color = fields.Char(
string='Success Color',
config_parameter='system_dashboard_classic.success_color',
default='#10b981',
help='Success/Online status color (e.g., #10b981)'
)
dashboard_warning_color = fields.Char(
string='Warning Color',
config_parameter='system_dashboard_classic.warning_color',
default='#f59e0b',
help='Warning/Remaining balance color (e.g., #f59e0b)'
)
# Dashboard Statistics Visibility Settings
# NOTE: We DON'T use config_parameter here because Odoo's default behavior
# converts any non-empty string to True via bool(). Instead, we manually
# handle storage in get_values/set_values.
dashboard_show_annual_leave = fields.Boolean(
string='Show Annual Leave',
default=True,
help='Show/Hide Annual Leave statistics card in dashboard header'
)
dashboard_show_salary_slips = fields.Boolean(
string='Show Salary Slips',
default=True,
help='Show/Hide Salary Slips statistics card in dashboard header'
)
dashboard_show_timesheet = fields.Boolean(
string='Show Weekly Timesheet',
default=True,
help='Show/Hide Weekly Timesheet statistics card in dashboard header'
)
dashboard_show_attendance_hours = fields.Boolean(
string='Show Attendance Hours',
default=True,
help='Show/Hide Attendance Hours statistics card in dashboard header'
)
# Attendance Check-in/out Section
dashboard_show_attendance_section = fields.Boolean(
string='Show Attendance Section',
default=True,
help='Show/Hide the Attendance Check-in/out section in dashboard header. When hidden, statistics cards will expand to fill the space.'
)
# Enable/Disable Check-in/out Button
dashboard_enable_attendance_button = fields.Boolean(
string='Enable Check-in/out Button',
default=False,
help='Enable/Disable the Check-in/Check-out button functionality. When disabled, the button will appear but will not function.'
)
# Chart Type Selection Fields
dashboard_annual_leave_chart_type = fields.Selection([
('donut', 'Donut'),
('pie', 'Pie'),
], default='donut', string='Annual Leave Chart',
config_parameter='system_dashboard_classic.annual_leave_chart_type')
dashboard_salary_slips_chart_type = fields.Selection([
('donut', 'Donut'),
('pie', 'Pie'),
], default='donut', string='Salary Slips Chart',
config_parameter='system_dashboard_classic.salary_slips_chart_type')
dashboard_timesheet_chart_type = fields.Selection([
('donut', 'Donut'),
('pie', 'Pie'),
], default='donut', string='Weekly Timesheet Chart',
config_parameter='system_dashboard_classic.timesheet_chart_type')
dashboard_attendance_hours_chart_type = fields.Selection([
('donut', 'Donut'),
('pie', 'Pie'),
], default='donut', string='Attendance Hours Chart',
config_parameter='system_dashboard_classic.attendance_hours_chart_type')
@api.model
def get_dashboard_colors(self):
"""API method to get dashboard colors for JavaScript"""
ICPSudo = self.env['ir.config_parameter'].sudo()
return {
'primary': ICPSudo.get_param('system_dashboard_classic.primary_color', '#0891b2'),
'secondary': ICPSudo.get_param('system_dashboard_classic.secondary_color', '#1e293b'),
'success': ICPSudo.get_param('system_dashboard_classic.success_color', '#10b981'),
'warning': ICPSudo.get_param('system_dashboard_classic.warning_color', '#f59e0b'),
}
@api.model
def get_values(self):
"""Override to properly load Boolean visibility settings from ir.config_parameter
We store these as explicit strings ('True'/'False') and convert them back to
Python booleans here.
"""
res = super(DashboardConfigSettings, self).get_values()
ICPSudo = self.env['ir.config_parameter'].sudo()
# Helper to safely get boolean value from string storage
def get_bool_param(key, default='True'):
value = ICPSudo.get_param(key, default)
return value == 'True'
res.update(
dashboard_show_annual_leave=get_bool_param('system_dashboard_classic.show_annual_leave'),
dashboard_show_salary_slips=get_bool_param('system_dashboard_classic.show_salary_slips'),
dashboard_show_timesheet=get_bool_param('system_dashboard_classic.show_timesheet'),
dashboard_show_attendance_hours=get_bool_param('system_dashboard_classic.show_attendance_hours'),
dashboard_show_attendance_section=get_bool_param('system_dashboard_classic.show_attendance_section'),
dashboard_enable_attendance_button=get_bool_param('system_dashboard_classic.enable_attendance_button', 'False'),
)
return res
def set_values(self):
"""Override to explicitly store Boolean visibility settings as strings
We store as 'True' or 'False' strings to prevent Odoo from deleting
the parameter when value is False.
"""
res = super(DashboardConfigSettings, self).set_values()
# Explicitly store visibility settings as string values
ICPSudo = self.env['ir.config_parameter'].sudo()
ICPSudo.set_param('system_dashboard_classic.show_annual_leave',
'True' if self.dashboard_show_annual_leave else 'False')
ICPSudo.set_param('system_dashboard_classic.show_salary_slips',
'True' if self.dashboard_show_salary_slips else 'False')
ICPSudo.set_param('system_dashboard_classic.show_timesheet',
'True' if self.dashboard_show_timesheet else 'False')
ICPSudo.set_param('system_dashboard_classic.show_attendance_hours',
'True' if self.dashboard_show_attendance_hours else 'False')
ICPSudo.set_param('system_dashboard_classic.show_attendance_section',
'True' if self.dashboard_show_attendance_section else 'False')
ICPSudo.set_param('system_dashboard_classic.enable_attendance_button',
'True' if self.dashboard_enable_attendance_button else 'False')
return res
@api.model
def get_stats_visibility(self):
"""API method to get statistics visibility settings for JavaScript
Returns dict with boolean values for each visibility setting.
Default is True (show) if parameter doesn't exist.
"""
ICPSudo = self.env['ir.config_parameter'].sudo()
# Helper to safely get boolean value
def get_bool_param(key, default='True'):
value = ICPSudo.get_param(key, default)
# Handle various possible values
if value in ('True', 'true', True, '1', 1):
return True
return False
return {
'show_annual_leave': get_bool_param('system_dashboard_classic.show_annual_leave'),
'show_salary_slips': get_bool_param('system_dashboard_classic.show_salary_slips'),
'show_timesheet': get_bool_param('system_dashboard_classic.show_timesheet'),
'show_attendance_hours': get_bool_param('system_dashboard_classic.show_attendance_hours'),
'show_attendance_section': get_bool_param('system_dashboard_classic.show_attendance_section'),
}
@api.model
def get_chart_types(self):
"""API method to get chart type settings for JavaScript
Returns dict with chart type for each card.
Default is 'donut' if parameter doesn't exist.
"""
ICPSudo = self.env['ir.config_parameter'].sudo()
return {
'annual_leave_chart': ICPSudo.get_param('system_dashboard_classic.annual_leave_chart_type', 'donut'),
'salary_slips_chart': ICPSudo.get_param('system_dashboard_classic.salary_slips_chart_type', 'donut'),
'timesheet_chart': ICPSudo.get_param('system_dashboard_classic.timesheet_chart_type', 'donut'),
'attendance_hours_chart': ICPSudo.get_param('system_dashboard_classic.attendance_hours_chart_type', 'donut'),
}

View File

@ -1,11 +1,10 @@
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError , AccessError
from odoo.tools.safe_eval import safe_eval
from odoo.exceptions import AccessError, UserError
import ast
from datetime import datetime, date
from dateutil.relativedelta import relativedelta, SA, SU, MO
import calendar
import pytz
from math import radians, sin, cos, sqrt, asin
@ -31,8 +30,56 @@ class SystemDashboard(models.Model):
# employee and user date and vars defined to be used inside this method
values = {'user': [], 'timesheet': [], 'leaves': [], 'payroll': [], 'attendance': [], 'employee': [],
'cards': []}
'cards': [], 'attendance_hours': [], 'chart_types': {}, 'card_orders': {}}
base = self.env['base.dashbord'].search([])
# Load chart type settings
ICPSudo = self.env['ir.config_parameter'].sudo()
values['chart_types'] = {
'annual_leave': ICPSudo.get_param('system_dashboard_classic.annual_leave_chart_type', 'donut'),
'salary_slips': ICPSudo.get_param('system_dashboard_classic.salary_slips_chart_type', 'donut'),
'timesheet': ICPSudo.get_param('system_dashboard_classic.timesheet_chart_type', 'donut'),
'attendance_hours': ICPSudo.get_param('system_dashboard_classic.attendance_hours_chart_type', 'donut'),
}
# Load chart colors from settings (match the totals styling)
# primary = Total/Remaining color (teal), warning = Left amount color (amber)
values['chart_colors'] = {
'primary': ICPSudo.get_param('system_dashboard_classic.primary_color', '#0891b2'),
'warning': ICPSudo.get_param('system_dashboard_classic.warning_color', '#f59e0b'),
'secondary': ICPSudo.get_param('system_dashboard_classic.secondary_color', '#1e293b'),
}
# Load attendance button setting (disabled by default)
values['enable_attendance_button'] = ICPSudo.get_param('system_dashboard_classic.enable_attendance_button', 'False') == 'True'
# Load stats visibility settings (to prevent fadeIn from overriding hidden state)
def get_bool_param(key, default='True'):
value = ICPSudo.get_param(key, default)
return value in ('True', 'true', True, '1', 1)
values['stats_visibility'] = {
'show_annual_leave': get_bool_param('system_dashboard_classic.show_annual_leave'),
'show_salary_slips': get_bool_param('system_dashboard_classic.show_salary_slips'),
'show_timesheet': get_bool_param('system_dashboard_classic.show_timesheet'),
'show_attendance_hours': get_bool_param('system_dashboard_classic.show_attendance_hours'),
'show_attendance_section': get_bool_param('system_dashboard_classic.show_attendance_section'),
}
# Initialize celebration data (birthday and work anniversary)
values['celebration'] = {
'is_birthday': False,
'is_anniversary': False,
'anniversary_years': 0,
}
# Initialize gender-aware data for personalized experience
values['gender_info'] = {
'gender': 'male', # default
'honorific': 'أستاذ', # default male
'pronoun_you': 'ك', # default male (لديك)
'verb_suffix': 'تَ', # default male (قمتَ)
}
user = self.env.user
user_id = self.env['res.users'].sudo().search_read(
@ -43,46 +90,116 @@ class SystemDashboard(models.Model):
employee_object = self.env['hr.employee'].sudo().search(
[('user_id', '=', user.id)], limit=1)
job_english = employee_object.job_id.english_name
if hasattr(employee_object.job_id, 'english_name') and employee_object.job_id.english_name:
job_english = employee_object.job_id.english_name
else:
job_english = employee_object.job_id.name or ''
t_date = date.today()
# ==========================================
# CELEBRATION DETECTION: Birthday & Anniversary
# ==========================================
today = date.today()
if employee_object:
# ==========================================
# GENDER-AWARE PERSONALIZATION
# ==========================================
# hr.employee has 'gender' field with values: 'male', 'female', 'other'
employee_gender = getattr(employee_object, 'gender', 'male') or 'male'
if employee_gender == 'female':
values['gender_info'] = {
'gender': 'female',
'honorific': 'أستاذة', # Ms./Mrs.
'pronoun_you': 'كِ', # لديكِ (feminine you)
'verb_suffix': 'تِ', # قمتِ (feminine past tense)
}
else:
values['gender_info'] = {
'gender': 'male',
'honorific': 'أستاذ', # Mr.
'pronoun_you': 'ك', # لديك (masculine you)
'verb_suffix': 'تَ', # قمتَ (masculine past tense)
}
# Check for BIRTHDAY (compare month and day only)
birthday = getattr(employee_object, 'birthday', None)
if birthday:
if birthday.month == today.month and birthday.day == today.day:
values['celebration']['is_birthday'] = True
# Check for WORK ANNIVERSARY (first contract start date)
# Using contract_id.date_start from hr.contract
joining_date = None
if employee_object.contract_id and employee_object.contract_id.date_start:
joining_date = employee_object.contract_id.date_start
if joining_date:
# Compare month and day
if joining_date.month == today.month and joining_date.day == today.day:
# Calculate years of service
years = today.year - joining_date.year
if years > 0: # Only celebrate if at least 1 year
values['celebration']['is_anniversary'] = True
values['celebration']['anniversary_years'] = years
t_date = today # Use same date object
attendance_date = {}
leaves_data = {}
payroll_data = {}
timesheet_data = {}
attendance_hours_data = {}
###################################################
# check whether last action sign in or out and its date
is_hr_attendance_module = self.env['ir.module.module'].sudo().search([('name', '=', 'hr_attendance')])
if is_hr_attendance_module and is_hr_attendance_module.state == 'installed':
attendance = self.env['attendance.attendance'].sudo().search(
[('employee_id', '=', employee_object.id), ('action_date', '=', t_date)])
is_attendance = True
if not attendance:
is_attendance = False
if attendance and attendance[-1].action == 'sign_out':
is_attendance = False
# ATTENDANCE LOGIC IMPROVEMENT:
# Fetch the absolute latest record regardless of date (e.g. yesterday, mobile app entry)
# This ensures we show the REAL status.
last_attendance = self.env['attendance.attendance'].sudo().search(
[('employee_id', '=', employee_object.id)], limit=1, order="name desc")
is_attendance = False
if last_attendance and last_attendance.action == 'sign_in':
is_attendance = True
user_tz = pytz.timezone(self.env.context.get('tz', 'Asia/Riyadh') or self.env.user.tz)
if attendance:
time_object = fields.Datetime.from_string(attendance[-1].name)
time_in_timezone = False
if last_attendance:
# Use 'name' (datetime) for the time display
time_object = fields.Datetime.from_string(last_attendance.name)
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
attendance_date.update({'is_attendance': is_attendance,
'time': time_in_timezone if attendance else False
'time': time_in_timezone
})
# if noc is found case shoud be handeld
###############################################
# compute leaves taken and remaing leaves
is_leave_module = self.env['ir.module.module'].sudo().search([('name', '=', 'hr_holidays_community')])
if is_leave_module and is_leave_module.state == 'installed':
is_leave_installed = bool(is_leave_module and is_leave_module.state == 'installed')
# Always set default values and module status
leaves_data.update({
'taken': 0,
'remaining_leaves': 0,
'is_module_installed': is_leave_installed
})
if is_leave_installed:
# FIX: Removed limit=1 to consider ALL valid allocations (e.g. current year + carried over)
leaves = self.env['hr.holidays'].sudo().search(
[('employee_id', '=', employee_object.id), ('holiday_status_id.leave_type', '=', 'annual'),
('type', '=', 'add'), ('check_allocation_view', '=', 'balance')], limit=1)
taken = leaves.leaves_taken
remaining_leaves = leaves.remaining_leaves
('type', '=', 'add'), ('check_allocation_view', '=', 'balance')])
# Sum up valid allocations
taken = sum(l.leaves_taken for l in leaves)
remaining_leaves = sum(l.remaining_leaves for l in leaves)
leaves_data.update({'taken': taken,
'remaining_leaves': remaining_leaves
})
###################################################
@ -91,17 +208,47 @@ class SystemDashboard(models.Model):
last_day = date(date.today().year, 12, 31)
is_payslip_module = self.env['ir.module.module'].sudo().search([('name', '=', 'exp_hr_payroll')])
if is_payslip_module and is_payslip_module.state == 'installed':
payslip = self.env['hr.payslip'].sudo().search_count(
is_payslip_installed = bool(is_payslip_module and is_payslip_module.state == 'installed')
# Always set default values and module status
payroll_data.update({
'taken': 0,
'payslip_remaining': 0,
'is_module_installed': is_payslip_installed
})
if is_payslip_installed:
payslip_count = self.env['hr.payslip'].sudo().search_count(
[('employee_id', '=', employee_object.id), ('date_from', '>=', first_day), ('date_to', '<=', last_day)])
payroll_data.update({'taken': payslip,
'payslip_remaining': 12 - payslip
# FIX: Calculate expected slips based on contract start date
# If joined mid-year, they shouldn't expect 12 slips
contract = self.env['hr.contract'].sudo().search([('employee_id', '=', employee_object.id), ('state', '=', 'open')], limit=1)
expected_slips = 12
if contract and contract.date_start and contract.date_start.year == date.today().year:
# If joined this year, expected slips = months from start date to Dec
expected_slips = 12 - contract.date_start.month + 1
# Ensure we don't show negative remaining
remaining_slips = max(0, expected_slips - payslip_count)
payroll_data.update({'taken': payslip_count,
'payslip_remaining': remaining_slips
})
##############################################
# compute timesheet taken and remaing timesheet
is_analytic_module = self.env['ir.module.module'].sudo().search([('name', '=', 'analytic')])
if is_analytic_module and is_analytic_module.state == 'installed':
is_timesheet_installed = bool(is_analytic_module and is_analytic_module.state == 'installed')
# Always set default values and module status
timesheet_data.update({
'taken': 0,
'timesheet_remaining': 0,
'is_module_installed': is_timesheet_installed
})
if is_timesheet_installed:
calender = employee_object.resource_calendar_id
days_off_name = []
days_special_name = []
@ -161,25 +308,47 @@ class SystemDashboard(models.Model):
star_of_week = SA
# calcultion of all working hours and return done working hours and remaining
lenght_days_off = len(days_off_name)
lenght_special_days_off = len(days_special_name)
lenght_work_days = (days_of_week - lenght_days_off) - lenght_special_days_off
total_wroking_hours = (working_hours * lenght_work_days) + sepcial_working_hours
domain = [('employee_id', '=', employee_object.id), '&', (
'date', '>=', (date.today() + relativedelta(weeks=-1, days=1, weekday=star_of_week)).strftime('%Y-%m-%d')),
('date', '<=', (date.today() + relativedelta(weekday=6)).strftime('%Y-%m-%d')), ]
timesheet = self.env['account.analytic.line'].sudo().search(domain)
day_name_list = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', ]
done_hours = 0
for sheet in timesheet:
day = datetime.strptime(str(sheet.date), '%Y-%m-%d').weekday()
day_name = day_name_list[day]
if day_name not in days_off_name:
done_hours = done_hours + sheet.unit_amount
# ATTENDANCE/TIMESHEET LOGIC IMPROVEMENT:
# Replaced manual calculation with standard Odoo calendar method
# This ensures Public Holidays (Global Leaves) are correctly handled
# Determine start/end of week logic (kept from original code to maintain week start preference)
# star_of_week is determined above (SU, SA, MO)
start_date = date.today() + relativedelta(weeks=-1, days=1, weekday=star_of_week)
end_date = start_date + relativedelta(days=6) # Ensure strictly 7 days week range
# Note: start_date is calculated based on star_of_week logic (e.g. last Saturday)
total_wroking_hours = 0
if employee_object.resource_calendar_id:
# Use standard Odoo method
total_wroking_hours = employee_object.resource_calendar_id.get_work_hours_count(
datetime.combine(start_date, datetime.min.time()),
datetime.combine(end_date, datetime.max.time()),
compute_leaves=True
)
else:
# Fallback manual calculation (if calendar missing)
# Restore variables needed for calculation
lenght_days_off = len(days_off_name)
lenght_special_days_off = len(days_special_name)
lenght_work_days = (days_of_week - lenght_days_off) - lenght_special_days_off
total_wroking_hours = (working_hours * lenght_work_days) + sepcial_working_hours
don_hours = sum(item.unit_amount for item in timesheet)
timesheet_data.update({'taken': don_hours,
'timesheet_remaining': total_wroking_hours - don_hours
domain = [('employee_id', '=', employee_object.id),
('date', '>=', start_date),
('date', '<=', end_date)]
timesheet = self.env['account.analytic.line'].sudo().search(domain)
# Filter days off manually? Odoo search already filters by date.
# Original code filtered 'day_name not in days_off_name'.
# If standard timesheet is used, entries on off-days are usually overtime or valid.
# We will sum ALL valid timesheet entries in the period.
done_hours = sum(sheet.unit_amount for sheet in timesheet)
timesheet_data.update({'taken': done_hours,
'timesheet_remaining': max(0, total_wroking_hours - done_hours)
})
##############################################
@ -198,23 +367,29 @@ class SystemDashboard(models.Model):
if self.is_user(line.group_ids,
user): # call method to return if user is in one of the groups in current line
# static vars for the card
# TODO: find a way to fix translation,
# the filed is name is translateable,
# but its not loaded when change lang,
# so we handel it by searching on translation object.
model_name_to_serach = model.name if model.name else model.model_id.name
#value = self.env['ir.translation'].sudo().search([('source', '=', model_name_to_serach)], limit=1).value
value = "text"
card_name = model.name if model.name else model.model_id.name
# if self.env.user.lang == 'en_US':
# card_name = model.name if model.name else model.model_id.name
# else:
# card_name = value
mod['name'] = card_name
mod['name_arabic'] = card_name
mod['name_english'] = card_name
# FIX: Fetch explicit translations for bilingual support
# model.name returns current user lang, so we force context
card_name_ar = model.with_context(lang='ar_001').name or model.with_context(lang='ar_SY').name or model.name
card_name_en = model.with_context(lang='en_US').name or model.name
# Fallback if model name is empty (use model description)
if not card_name_ar:
card_name_ar = model.model_id.with_context(lang='ar_001').name or model.model_id.name
if not card_name_en:
card_name_en = model.model_id.with_context(lang='en_US').name or model.model_id.name
mod['name'] = card_name_ar if self.env.user.lang in ['ar_001', 'ar_SY'] else card_name_en
mod['name_arabic'] = card_name_ar
mod['name_english'] = card_name_en
mod['model'] = model.model_name
mod['image'] = model.card_image
mod['icon_type'] = model.icon_type
mod['icon_name'] = model.icon_name
# Default Icon Logic
if not mod['image'] and not mod['icon_name']:
mod['icon_type'] = 'icon'
mod['icon_name'] = 'fa-th-large'
# var used in domain to serach with either state or state
state_or_stage = 'state' if line.state_id else 'stage_id'
@ -374,7 +549,14 @@ class SystemDashboard(models.Model):
#if 'user_id' in mod._fields:
# service_action_domain.append(('user_id', '=', user.id))
# service_action_domain.append(('employee_id.user_id','=',user.id))
# value = self.env['ir.translation'].sudo().search([('source', '=', model.name)], limit=1).value
# Default Icon Logic
if not model.card_image and not model.icon_name:
model_icon_type = 'icon'
model_icon_name = 'fa-th-large'
else:
model_icon_type = model.icon_type
model_icon_name = model.icon_name
values['cards'].append({
'type': 'selfs',
'name': card_name,
@ -383,6 +565,8 @@ class SystemDashboard(models.Model):
'model': model.model_name,
'state_count': self.env[model.model_name].search_count(service_action_domain),
'image': model.card_image,
'icon_type': model_icon_type,
'icon_name': model_icon_name,
'form_view': model.form_view_id.id,
'list_view': model.list_view_id.id,
'js_domain': service_action_domain,
@ -399,74 +583,291 @@ class SystemDashboard(models.Model):
values['payroll'].append(payroll_data)
values['attendance'].append(attendance_date)
values['timesheet'].append(timesheet_data)
# Compute attendance hours from hr.attendance.transaction
try:
AttendanceTransaction = self.env['hr.attendance.transaction'].sudo()
# Get current month's attendance transactions for the employee
first_day_of_month = date(t_date.year, t_date.month, 1)
attendance_txns = AttendanceTransaction.search([
('employee_id', '=', employee_object.id),
('date', '>=', first_day_of_month),
('date', '<=', t_date)
])
# ATTENDANCE LOGIC IMPROVEMENT:
# Calculate planned hours from the Resource Calendar (Work Schedule)
# This ensures we count days where the employee was ABSENT (no transaction)
# as missed hours, providing a true compliance percentage.
if employee_object.resource_calendar_id:
# Use standard Odoo method to get expected working hours for the period
# Use standard Odoo method to get expected working hours for the period
# from start of month to today (Month-to-Date)
# compute_related ensures we consider resource specificities if needed
# Important: Convert dates to datetime as required by get_work_hours_count
plan_hours_total = employee_object.resource_calendar_id.get_work_hours_count(
datetime.combine(first_day_of_month, datetime.min.time()),
datetime.combine(t_date, datetime.max.time()),
compute_leaves=True
)
else:
# Fallback to sum of transactions if no calendar (though rare for active employees)
plan_hours_total = sum(txn.plan_hours for txn in attendance_txns)
official_hours_total = sum(txn.official_hours for txn in attendance_txns)
attendance_hours_data.update({
'plan_hours': round(plan_hours_total, 2),
'official_hours': round(official_hours_total, 2),
'is_module_installed': True # Module is installed if we got here
})
except Exception:
attendance_hours_data.update({
'plan_hours': 0,
'official_hours': 0,
'is_module_installed': False # Module not installed or error
})
values['attendance_hours'].append(attendance_hours_data)
values['job_english'] = job_english
# Load user's saved card order preferences
import json
try:
card_orders_json = self.env.user.dashboard_card_orders or '{}'
values['card_orders'] = json.loads(card_orders_json)
except (json.JSONDecodeError, TypeError, AttributeError):
values['card_orders'] = {}
return values
def _haversine_distance(self, lat1, lon1, lat2, lon2):
"""
Calculate the great-circle distance between two GPS points using Haversine formula.
@param lat1, lon1: First point coordinates in degrees
@param lat2, lon2: Second point coordinates in degrees
@return: Distance in meters
"""
R = 6371000 # Earth's radius in meters
# Convert to radians
lat1_rad, lon1_rad = radians(lat1), radians(lon1)
lat2_rad, lon2_rad = radians(lat2), radians(lon2)
# Differences
dlat = lat2_rad - lat1_rad
dlon = lon2_rad - lon1_rad
# Haversine formula
a = sin(dlat / 2) ** 2 + cos(lat1_rad) * cos(lat2_rad) * sin(dlon / 2) ** 2
c = 2 * asin(sqrt(a))
return R * c
@api.model
def checkin_checkout(self):
def checkin_checkout(self, latitude=None, longitude=None):
"""
Check-in or Check-out with zone-based geolocation validation.
@param latitude: User's current latitude from browser geolocation
@param longitude: User's current longitude from browser geolocation
@return: Dict with is_attendance, time, and any error info
"""
ctx = self._context
attendance_status = ctx.get('check', False)
t_date = date.today()
user = self.env.user
employee_object = self.env['hr.employee'].sudo().search([('user_id', '=', user.id)], limit=1)
vals_in = {'employee_id': employee_object.id,'action': 'sign_in','action_type': 'manual'}
vals_out = {'employee_id': employee_object.id,'action': 'sign_out','action_type': 'manual'}
employee_object = self.env['hr.employee'].sudo().search([('user_id', '=', user.id)], limit=1)
if not employee_object:
is_arabic = 'ar' in self._context.get('lang', 'en_US')
msg = "عذراً، لم يتم العثور على ملف وظيفي مرتبط بحسابك.\nيرجى مراجعة إدارة الموارد البشرية." if is_arabic else "Sorry, no employee profile found linked to your account.\nPlease contact HR department."
return {'error': True, 'message': msg}
# ============================================================
# ZONE VALIDATION - Check if employee is within allowed zone
# ============================================================
AttendanceZone = self.env['attendance.zone'].sudo()
# Find employee's assigned zones
employee_zones = AttendanceZone.search([('employee_ids', 'in', employee_object.id)])
# Check for general zone (applies to all employees)
general_zone = employee_zones.filtered(lambda z: z.general)
# If employee has a general zone, allow from anywhere (no location check needed)
zone_validation_required = not bool(general_zone)
if zone_validation_required:
is_arabic = 'ar' in self._context.get('lang', 'en_US')
# Location is required for non-general zones
if not latitude or not longitude:
msg = "يتطلب النظام الوصول إلى موقعك الجغرافي لتسجيل الحضور.\nيرجى تفعيل صلاحية الموقع في المتصفح والمحاولة مرة أخرى." if is_arabic else "System requires access to your location for attendance.\nPlease enable location permission in your browser and try again."
return {'error': True, 'message': msg}
# If no zones assigned at all
if not employee_zones:
msg = "لم يتم تعيين منطقة حضور خاصة بك.\nيرجى التواصل مع إدارة النظام." if is_arabic else "No specific attendance zone assigned to you.\nPlease contact system administration."
return {'error': True, 'message': msg}
# Check if user is within any of their assigned zones
is_in_zone = False
closest_zone_distance = float('inf')
allowed_range = 0
valid_zones_count = 0 # Track zones with valid coordinates
for zone in employee_zones:
if not zone.latitude or not zone.longitude or not zone.allowed_range:
continue
try:
zone_lat = float(zone.latitude)
zone_lon = float(zone.longitude)
zone_range = float(zone.allowed_range)
except (ValueError, TypeError):
continue
valid_zones_count += 1
# Calculate distance from user to zone center
distance = self._haversine_distance(latitude, longitude, zone_lat, zone_lon)
if distance <= zone_range:
is_in_zone = True
break
# Track closest zone for error message
if distance < closest_zone_distance:
closest_zone_distance = distance
allowed_range = zone_range
if not is_in_zone:
# Edge case: all zones have invalid coordinates
if valid_zones_count == 0:
msg = "بيانات منطقة الحضور غير مكتملة.\nيرجى التواصل مع إدارة النظام لتحديث الإحداثيات." if is_arabic else "Attendance zone data is incomplete.\nPlease contact system administration to update coordinates."
return {'error': True, 'message': msg}
# Calculate how far outside the zone user is
distance_outside = int(closest_zone_distance - allowed_range)
# Gender-aware Arabic message
# تتواجد (male) / تتواجدين (female)
# لتتمكن (male) / لتتمكني (female)
employee_gender = getattr(employee_object, 'gender', 'male') or 'male'
if employee_gender == 'female':
ar_msg = "عذراً، أنتِ تتواجدين خارج نطاق الحضور المسموح.\nيرجى الاقتراب مسافة %s متر تقريباً أو أكثر لتتمكني من التسجيل."
else:
ar_msg = "عذراً، أنت تتواجد خارج نطاق الحضور المسموح.\nيرجى الاقتراب مسافة %s متر تقريباً أو أكثر لتتمكن من التسجيل."
msg_formats = {
'ar': ar_msg,
'en': "Sorry, you are outside the allowed attendance zone.\nPlease move approximately %s meters or more closer to be able to check in."
}
msg = (msg_formats['ar'] if is_arabic else msg_formats['en']) % distance_outside
return {
'error': True,
'message': msg
}
# ============================================================
# ATTENDANCE CREATION (Zone validation passed)
# ============================================================
vals_in = {
'employee_id': employee_object.id,
'action': 'sign_in',
'action_type': 'manual',
'latitude': str(latitude) if latitude else False,
'longitude': str(longitude) if longitude else False,
}
vals_out = {
'employee_id': employee_object.id,
'action': 'sign_out',
'action_type': 'manual',
'latitude': str(latitude) if latitude else False,
'longitude': str(longitude) if longitude else False,
}
# check last attendance record before do any action
Attendance = self.env['attendance.attendance']
is_attendance = False
# get last attendance record
# Robust Logic: Always fetch the ABSOLUTE last record regardless of date
last_attendance = self.env['attendance.attendance'].sudo().search(
[('employee_id', '=', employee_object.id), ('action_date', '=', t_date)], limit=1, order="id desc")
[('employee_id', '=', employee_object.id)], limit=1, order="name desc")
user_tz = pytz.timezone(self.env.context.get('tz', 'Asia/Riyadh') or self.env.user.tz)
# Determine next action based on DB state
if last_attendance and last_attendance.action == 'sign_in':
# User is currently Checked In -> Action: Sign Out
result = Attendance.create(vals_out)
is_attendance = False
else:
# User is Checked Out (or no record) -> Action: Sign In
result = Attendance.create(vals_in)
is_attendance = True
# Fetch the NEW last record to return accurate time
last_attendance = self.env['attendance.attendance'].sudo().search(
[('employee_id', '=', employee_object.id)], limit=1, order="name desc")
if last_attendance:
time_object = fields.Datetime.from_string(last_attendance.name)
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
# check current attendance status
# (True == user already signed in --> create new record with signout action)
# (Fasle == user already signed out --> create new record with signin action)
if attendance_status:
is_attendance = False
# check last attendance record action
if last_attendance:
if last_attendance.action == 'sign_in':
result = Attendance.create(vals_out) # create signout record
last_attendance = self.env['attendance.attendance'].sudo().search(
[('employee_id', '=', employee_object.id), ('action_date', '=', t_date)], limit=1, order="id desc")
time_object = fields.Datetime.from_string(last_attendance.name)
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
elif last_attendance.action == 'sign_out':
is_attendance = False
else:
is_attendance = True
else:
is_attendance = True
# check last attendance record action
if last_attendance:
if last_attendance.action == 'sign_out':
result = Attendance.create(vals_in) # create signin record
last_attendance = self.env['attendance.attendance'].sudo().search(
[('employee_id', '=', employee_object.id), ('action_date', '=', t_date)], limit=1, order="id desc")
time_object = fields.Datetime.from_string(last_attendance.name)
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
elif last_attendance.action == 'sign_in':
is_attendance = True
else:
is_attendance = False
else:
result = Attendance.create(vals_in) # create signin record
last_attendance = self.env['attendance.attendance'].sudo().search(
[('employee_id', '=', employee_object.id), ('action_date', '=', t_date)], limit=1, order="id desc")
time_object = fields.Datetime.from_string(last_attendance.name)
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
time_in_timezone = False
attendance_response = {
'is_attendance': is_attendance,
'time': time_in_timezone if last_attendance else False
'time': time_in_timezone
}
return attendance_response
@api.model
def save_card_order(self, order_type, card_ids):
"""
Save card order preference for current user.
Stores in res.users.dashboard_card_orders as JSON.
@param order_type: String key identifying which cards (e.g., 'service', 'approve', 'track', 'stats')
@param card_ids: List of card identifiers in desired order
@return: Boolean success status
"""
import json
user = self.env.user
# Get current orders or initialize empty dict
try:
current_orders = json.loads(user.dashboard_card_orders or '{}')
except (json.JSONDecodeError, TypeError):
current_orders = {}
# Update the specific order type
current_orders[order_type] = card_ids
# Save back to user
user.sudo().write({
'dashboard_card_orders': json.dumps(current_orders)
})
return True
@api.model
def get_card_order(self, order_type=None):
"""
Get card order preferences for current user.
@param order_type: Optional string key for specific order type.
If None, returns all orders.
@return: List of card IDs for specific type, or dict of all orders
"""
import json
user = self.env.user
try:
all_orders = json.loads(user.dashboard_card_orders or '{}')
except (json.JSONDecodeError, TypeError):
all_orders = {}
if order_type:
return all_orders.get(order_type, [])
return all_orders

View File

@ -0,0 +1,15 @@
from odoo import models, fields
class ResUsersCardOrder(models.Model):
"""
Extend res.users to store dashboard card order preferences.
This allows card ordering to persist across devices/sessions.
"""
_inherit = 'res.users'
dashboard_card_orders = fields.Text(
string='Dashboard Card Orders',
default='{}',
help='JSON storage for dashboard card ordering preferences'
)

View File

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record model="ir.module.category" id="system_board_management">
<field name="name">System Dashboard</field>
<field name="description">User access level for System Board module</field>
<field name="sequence">20</field>
</record>
<!--
Groups
-->
<record id="system_board_group_manager" model="res.groups">
<field name="name">System Dashboard Manager</field>
<field name="category_id" ref="system_board_management"/>
</record>
<record id="system_board_group_configurations" model="res.groups">
<field name="name">System Dashboard Configurations</field>
<field name="category_id" ref="system_board_management"/>
</record>
</data>
</odoo>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Login Icon - Arrow pointing IN (opposite of logout) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 384 384" style="enable-background:new 0 0 384 384;" xml:space="preserve">
<g>
<g>
<g>
<path style="fill:#003056;" d="M341.333,0H42.667C19.093,0,0,19.093,0,42.667V128h42.667V42.667h298.667v298.667H42.667V256H0v85.333
C0,364.907,19.093,384,42.667,384h298.667C364.907,384,384,364.907,384,341.333V42.667C384,19.093,364.907,0,341.333,0z"/>
<polygon style="fill:#003056;" points="232.853,115.52 202.667,85.333 96,192 202.667,298.667 232.853,268.48 177.707,213.333 384,213.333 384,170.667
177.707,170.667 "/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 792 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,351 @@
/**
* Dashboard Genius Enhancements v3
* =================================
*/
odoo.define('system_dashboard_classic.genius_enhancements', function(require) {
"use strict";
var ajax = require('web.ajax');
$(document).ready(function() {
// Initial run after short delay
setTimeout(initGeniusEnhancements, 800);
// Run again after longer delay to catch late-loaded content
setTimeout(initGeniusEnhancements, 2000);
// Run multiple times to catch late-loaded content
var checkCount = 0;
var checkInterval = setInterval(function() {
fixEmptyEmployeeCode();
// Search bar is now handled by SystemDashboardView widget
checkCount++;
if (checkCount >= 5) {
clearInterval(checkInterval);
}
}, 1000);
});
function initGeniusEnhancements() {
fixEmptyEmployeeCode();
// Search bar is now handled by SystemDashboardView widget in self_service.js
transformSelfServicesTab();
loadCustomColors();
loadStatsVisibility();
}
/**
* Fix empty employee code display - runs multiple times
* Uses genius-hidden class instead of hide() for CSS priority
*/
function fixEmptyEmployeeCode() {
$('p.fn-id').each(function() {
var el = $(this);
var idText = el.text().trim();
// Hide if empty, just "/", "False", "false", undefined, etc.
if (!idText ||
idText === '' ||
idText === '/' ||
idText === 'undefined' ||
idText === 'null' ||
idText === 'false' ||
idText === 'False' ||
idText === 'None') {
el.addClass('genius-hidden');
} else {
el.removeClass('genius-hidden');
}
});
}
/**
* Add search bar
*/
function addSearchBar() {
var navButtons = $('.dashboard-nav-buttons');
if ($('.genius-search-container').length > 0 || navButtons.length === 0) {
return;
}
// RTL/LTR placeholder translation
var searchPlaceholder = $('body').hasClass('o_rtl')
? 'ابحث عن خدمة...'
: 'Search for a service...';
var searchHtml = '<div class="genius-search-container">' +
'<div class="genius-search-wrapper">' +
'<i class="fa fa-search genius-search-icon"></i>' +
'<input type="text" class="genius-search-input" id="geniusServiceSearch" placeholder="' + searchPlaceholder + '" autocomplete="off">' +
'<span class="genius-search-clear" id="geniusClearSearch" style="display:none;">×</span>' +
'</div></div>';
navButtons.append(searchHtml);
$('#geniusServiceSearch').on('input', function() {
var term = $(this).val().toLowerCase().trim();
$('#geniusClearSearch').toggle(term.length > 0);
filterCards(term);
});
$('#geniusClearSearch').on('click', function() {
$('#geniusServiceSearch').val('');
$(this).hide();
filterCards('');
});
}
function filterCards(term) {
var cards = $('.card3, .card2');
if (term === '') {
cards.removeClass('genius-hidden');
return;
}
cards.each(function() {
var card = $(this);
var text = card.find('h4, h3, td').text().toLowerCase();
card.toggleClass('genius-hidden', !text.includes(term));
});
}
function transformSelfServicesTab() {
var tabs = $('.dashboard-nav-buttons .nav-tabs li');
if (tabs.length === 1) {
tabs.first().addClass('genius-single-tab');
}
}
/**
* Load ALL custom colors from database and apply as CSS variables
* Generates light/dark variants automatically for complete theming
*/
function loadCustomColors() {
var ICP = 'ir.config_parameter';
var prefix = 'system_dashboard_classic.';
// Define all color parameters with their CSS variable names
var colorParams = [
{ param: 'primary_color', cssVar: '--dash-primary', defaultColor: '#0891b2' },
{ param: 'secondary_color', cssVar: '--dash-secondary', defaultColor: '#1e293b' },
{ param: 'success_color', cssVar: '--dash-success', defaultColor: '#10b981' },
{ param: 'warning_color', cssVar: '--dash-warning', defaultColor: '#f59e0b' }
];
// Load each color
colorParams.forEach(function(item) {
ajax.jsonRpc('/web/dataset/call_kw', 'call', {
model: ICP,
method: 'get_param',
args: [prefix + item.param],
kwargs: {}
}).then(function(color) {
if (color && color.match(/^#[0-9A-Fa-f]{6}$/)) {
applyColorWithVariants(item.cssVar, color);
// For primary color, also generate SVG icon filter
if (item.cssVar === '--dash-primary') {
generateIconFilter(color);
}
}
}).catch(function() {});
});
}
/**
* Generate CSS filter to colorize SVG icons to match target color
* Algorithm: First convert to black, then use filter combination
* Based on: https://stackoverflow.com/questions/42966641/
* @param {string} hexColor - The target color in hex format
*/
function generateIconFilter(hexColor) {
var rgb = hexToRgb(hexColor);
if (!rgb) return;
// Calculate HSL from RGB
var r = rgb.r / 255, g = rgb.g / 255, b = rgb.b / 255;
var max = Math.max(r, g, b), min = Math.min(r, g, b);
var h = 0, s = 0, l = (max + min) / 2;
if (max !== min) {
var d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
}
// Convert to degrees and percentages
var hue = Math.round(h * 360);
var saturation = Math.round(s * 100);
var lightness = Math.round(l * 100);
// Build filter: first make it black, then colorize
// invert(1) makes black -> white, then hue-rotate to target hue
// Or we can use sepia(1) to get yellow base, then hue-rotate
// Calculate filter values
var invert = lightness < 50 ? 0 : 1;
var sepia = 1;
var saturate = saturation / 10; // Scale to reasonable value
var hueRotate = hue;
var brightness = lightness / 50; // Normal is 1 (50%)
// Generate filter string - start with brightness(0) saturate(100%) to get black
var filterValue = 'brightness(0) saturate(100%) invert(' + (lightness > 50 ? '1' : '0.5') + ') ' +
'sepia(1) saturate(' + Math.max(2, saturate).toFixed(0) + ') ' +
'hue-rotate(' + hueRotate + 'deg) ' +
'brightness(' + brightness.toFixed(2) + ')';
// Apply as CSS variable
document.documentElement.style.setProperty('--dash-icon-filter', filterValue);
}
/**
* Apply a color and generate light/dark/RGB variants automatically
* @param {string} cssVarName - CSS variable name (e.g., '--dash-primary')
* @param {string} hexColor - Hex color value (e.g., '#0891b2')
*/
function applyColorWithVariants(cssVarName, hexColor) {
var root = document.documentElement.style;
// Apply base color
root.setProperty(cssVarName, hexColor);
// Generate RGB values for rgba() usage in CSS
var rgb = hexToRgb(hexColor);
if (rgb) {
root.setProperty(cssVarName + '-rgb', rgb.r + ', ' + rgb.g + ', ' + rgb.b);
}
// Generate and apply light variant (+25% lighter)
var lightColor = adjustColor(hexColor, 25);
root.setProperty(cssVarName + '-light', lightColor);
// Generate and apply dark variant (-20% darker)
var darkColor = adjustColor(hexColor, -20);
root.setProperty(cssVarName + '-dark', darkColor);
// For secondary color, also update header gradient
if (cssVarName === '--dash-secondary') {
root.setProperty('--dash-secondary-light', lightColor);
// Update header background directly
updateHeaderGradient(hexColor, lightColor);
}
}
/**
* Convert hex color to RGB object
* @param {string} hex - Hex color
* @returns {Object} RGB object with r, g, b properties
*/
function hexToRgb(hex) {
hex = hex.replace(/^#/, '');
if (hex.length !== 6) return null;
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16)
};
}
/**
* Adjust color brightness
* @param {string} hex - Hex color
* @param {number} percent - Percentage to adjust (-100 to +100)
* @returns {string} Adjusted hex color
*/
function adjustColor(hex, percent) {
// Remove # if present
hex = hex.replace(/^#/, '');
// Parse RGB values
var r = parseInt(hex.substring(0, 2), 16);
var g = parseInt(hex.substring(2, 4), 16);
var b = parseInt(hex.substring(4, 6), 16);
// Adjust brightness
r = Math.min(255, Math.max(0, r + Math.round(r * percent / 100)));
g = Math.min(255, Math.max(0, g + Math.round(g * percent / 100)));
b = Math.min(255, Math.max(0, b + Math.round(b * percent / 100)));
// Convert back to hex
return '#' + [r, g, b].map(function(x) {
var hex = x.toString(16);
return hex.length === 1 ? '0' + hex : hex;
}).join('');
}
/**
* Update header gradient with new colors
*/
function updateHeaderGradient(baseColor, lightColor) {
var header = document.querySelector('.dashboard-container .dashboard-header');
if (header) {
header.style.background = 'linear-gradient(135deg, ' + baseColor + ' 0%, ' + lightColor + ' 100%)';
}
}
/**
* Load statistics visibility settings from database and apply
* Hides/shows the 3 stats cards based on configuration
*/
function loadStatsVisibility() {
ajax.jsonRpc('/web/dataset/call_kw', 'call', {
model: 'res.config.settings',
method: 'get_stats_visibility',
args: [],
kwargs: {}
}).then(function(visibility) {
// Annual Leave card
if (!visibility.show_annual_leave) {
$('#leave-section').addClass('genius-hidden');
} else {
$('#leave-section').removeClass('genius-hidden');
}
// Salary Slips card
if (!visibility.show_salary_slips) {
$('#salary-section').addClass('genius-hidden');
} else {
$('#salary-section').removeClass('genius-hidden');
}
// Weekly Timesheet card
if (!visibility.show_timesheet) {
$('#timesheet-section').addClass('genius-hidden');
} else {
$('#timesheet-section').removeClass('genius-hidden');
}
// Attendance Hours card
if (!visibility.show_attendance_hours) {
$('#attendance-hours-section').addClass('genius-hidden');
} else {
$('#attendance-hours-section').removeClass('genius-hidden');
}
// Attendance Check-in/out Section (right side panel)
if (!visibility.show_attendance_section) {
// Hide the attendance section
$('.dashboard-attendance-section').addClass('genius-hidden');
// Expand the charts section to full width (from col-md-10 to col-md-12)
$('.dashboard-charts-section').removeClass('col-md-10').addClass('col-md-12');
// Add class for extra CSS control
$('.dashboard-user-statistics-section').addClass('attendance-hidden');
} else {
// Show attendance section
$('.dashboard-attendance-section').removeClass('genius-hidden');
// Restore charts section width
$('.dashboard-charts-section').removeClass('col-md-12').addClass('col-md-10');
$('.dashboard-user-statistics-section').removeClass('attendance-hidden');
}
}).catch(function() {
// Silently handle visibility settings error
});
}
return { init: initGeniusEnhancements };
});

View File

@ -1224,7 +1224,7 @@ __webpack_require__.r(__webpack_exports__);
var initTooltip = (bindedElement, data, i) => {
var div = bindedElement.append("g").attr("class", "pc-tooltip").html(function () {
var tooltipContent = "" + data[i].label + "," + data[i].value;
var tooltipContent = "" + data[i].label;
return tooltipContent;
});
positionTooltip(bindedElement);

View File

@ -1,609 +0,0 @@
odoo.define('system_dashboard_classic.dashboard', function(require) {
"use strict";
var core = require('web.core');
var session = require('web.session');
var ajax = require('web.ajax');
var ActionManager = require('web.ActionManager');
var view_registry = require('web.view_registry');
var Widget = require('web.Widget');
var AbstractAction = require('web.AbstractAction');
var QWeb = core.qweb;
var _t = core._t;
var _lt = core._lt;
var functions = [];
window.click_actions = [];
var SystemDashboardView = AbstractAction.extend({
template: 'system_dashboard_classic.dashboard',
init: function(parent, context) {
this._super(parent, context);
var data = [];
var self = this;
window.selfOb = this;
if (context.tag == 'system_dashboard_classic.dashboard') {
self._rpc({
model: 'system_dashboard_classic.dashboard',
method: 'get_data',
}, []).then(function(result) {
console.log(result)
window.resultX = result;
if (result.employee !== undefined && result.employee !== '' && result.employee[0].length !== 0) {
$('#main-cards').css('display', 'flex');
$('.fn-id,.fn-department,.fn-job').show();
// SET EMPLOYEE DATA
if (result.employee[0][0] !== undefined) {
$(".img-box").css('background-image', 'url(data:image/png;base64,' + result.employee[0][0].image_128 + ')')
$("p.fn-section").html(result.employee[0][0].name);
$("p.fn-id").html(result.employee[0][0].emp_no);
if (result.employee[0][0].job_id.length !== 0) {
$("p.fn-job").html(result.employee[0][0].job_id[1]);
}
} else {
$(".img-box").css('background-image', 'url(data:image/png;base64,' + result.user[0][0].image_128 + ')')
$("p.fn-section").html(result.user[0][0].display_name);
$("p.fn-id").html(result.user[0][0].id);
}
// SET MAIN CARD DATA (LEAVES - TIMESHEET - PAYSLIPS)
$(".leaves_count span.value").html(parseFloat(result['employee'][0][0].remaining_leaves).toFixed(2));
$(".timesheet_count span.value").html(result['employee'][0][0].timesheet_count);
$(".paylips_count span.value").html(result['employee'][0][0].payslip_count);
// LEAVES DO ACTION
$("#leaves_btn").on('click', function(event) {
event.stopPropagation();
event.preventDefault();
return self.do_action({
name: _t("Leaves"),
type: 'ir.actions.act_window',
res_model: 'hr.holidays',
src_model: 'hr.employee',
view_mode: 'tree,form',
view_type: 'form',
views: [
[false, 'list'],
[false, 'form']
],
context: {
'search_default_employee_id': [result['employee'][0][0].id],
'default_employee_id': result['employee'][0][0].id,
},
domain: [
['holiday_type', '=', 'employee'],
['holiday_status_id.limit', '=', false],
['state', '!=', 'refuse']
],
target: 'main',
flags:{
reload: true,
}
}, { on_reverse_breadcrumb: self.on_reverse_breadcrumb })
});
// TIMESHEET DO ACTION
$("#timesheet_btn").on('click', function(event) {
event.stopPropagation();
event.preventDefault();
return self.do_action({
name: _t("Timesheets"),
type: 'ir.actions.act_window',
res_model: 'account.analytic.line',
view_mode: 'tree,form',
view_type: 'form',
views: [
[false, 'list'],
[false, 'form']
],
context: {
'search_default_employee_id': [result['employee'][0][0].id],
},
domain: [
['user_id', '=', result['user'][0]]
],
target: 'main',
flags:{
reload: true,
}
}, { on_reverse_breadcrumb: self.on_reverse_breadcrumb })
});
// PAYSLIPS DO ACTION
$("#paylips_btn").on('click', function(event) {
event.stopPropagation();
event.preventDefault();
return self.do_action({
name: _t("Payslips"),
type: 'ir.actions.act_window',
res_model: 'hr.payslip',
view_mode: 'tree,form',
view_type: 'form',
views: [
[false, 'list'],
[false, 'form']
],
context: {
'search_default_employee_id': [result['employee'][0][0].id],
'default_employee_id': result['employee'][0][0].id,
},
target: 'main',
flags:{
reload: true,
}
}, { on_reverse_breadcrumb: self.on_reverse_breadcrumb })
});
} else {
if (result.user !== undefined && result.user !== '' && result.user[0].length !== 0) {
// SET EMPLOYEE DATA
$(".img-box").css('background-image', 'url(data:image/png;base64,' + result.user[0][0].image_128 + ')')
$("p.fn-section").html(result.user[0][0].name);
$("p.fn-id").html('ID: ' + result.user[0][0].id);
}
}
// Dynamic Cards (DO ACTIONS)
if (result.cards !== undefined && result.cards.length > 0) {
$.each(result.cards, function(index) {
if (result.cards[index].type == "approve") {
window.card_data = result.cards[index];
var card_title = (card_data.name_english.replace(/\s/g, '').toLocaleLowerCase());
var btn = 'click button#' + (card_title + index);
// BUILD APPROVE CARD
window.card = buildCard(result.cards[index], index, 'table1');
$(card).find('.card-header h4 span').html(_t(result.cards[index].name));
// BUILD FOLLOW CARD
window.card2 = buildCard(result.cards[index], index, 'table2');
$(card2).find('.card-header h4 span').html(_t(result.cards[index].name));
// APPENDING APPROVE CARD TO APPROVE TAB/SECTION
self.$el.find('div.card-section1').append(card);
// APPENDING FOLLOW CARD TO FOLLOW TAB/SECTION
self.$el.find('div#details .card-section').append(card2);
}
});
// TRIGGERING BUTTONS IN THE APPROVE & FOLLOW CARD TO SHOW DETAILS
self.$el.find('tr[data-target="record-button"]').on('click', function(event) {
event.stopPropagation();
event.preventDefault();
// GET DATA OF BUTTON TO HANDEL IT
var model = $(this).attr('data-model');
var name = $(this).attr('data-name');
var domain = $(this).attr('data-domain');
var form_view = parseInt($(this).attr('form-view'));
var list_view = parseInt($(this).attr('list-view'));
var type = $(this).attr('data-type');
// CHECK DOMAIN
if (domain === undefined || domain === '') {
domain = false;
} else {
domain = JSON.parse(domain);
}
// CHECK FORM VIEW VALUE
if (isNaN(form_view)) {
form_view = false;
}
// CHECK LIST VIEW VALUE
if (isNaN(list_view)) {
list_view = false;
}
//FIRING DO ACTION
return self.do_action({
name: _t(name),
type: 'ir.actions.act_window',
res_model: model,
view_mode: 'tree,form',
view_type: 'list',
views: [
[list_view, 'list'],
[form_view, 'form']
],
domain: domain,
target: 'main',
flags:{
reload: true,
}
}, { on_reverse_breadcrumb: function() { return self.reload(); } })
});
}
// Chart Settings
setTimeout(function() {
window.check = false;
//result.attendance[0].is_attendance = true;
var name = "";
if (result.employee[0][0] !== undefined) {
name = result.employee[0][0].name;
} else {
name = result.user[0][0].display_name;
}
if (result.attendance !== undefined) {
setupAttendanceArea(result.attendance[0], name);
// check button submission
$('button#check_button').on('click', function(event) {
//var check = result.attendance[0].is_attendance; //false or true
self._rpc({
model: 'system_dashboard_classic.dashboard',
method: 'checkin_checkout',
context: { 'check': check },
}, []).then(function(result) {
$('.last-checkin-section').html('');
$('.attendance-img-section').html('');
$('#check_button').html('');
setupAttendanceArea(result, resultX.employee[0][0].name);
});
});
} else {
$('.attendance-section-body').hide();
}
// Charts Labels
if ($('body').hasClass('o_rtl') === true) {
var total_leaves_title = "الرصيد الكلي ";
var remaining_leaves_title = "رصيد الأيام المتبقية ";
var taken_leaves_title = "رصيد الأيام المستنفذة ";
var total_payroll_title = "إجمالي القسائم السنوية";
var remaining_payroll_title = "قسائم الراتب المتبقية ";
var taken_payroll_title = "القسائم المستنفذة ";
var total_timesheet_title = "إجمالي ساعات الأسبوع ";
var remaining_timesheet_title = "الساعات المتبقية ";
var taken_timesheet_title = "الساعات المنجزة ";
} else {
var total_leaves_title = "Total Balance ";
var remaining_leaves_title = "Left Balance ";
var taken_leaves_title = "Total remaining days ";
var total_payroll_title = "Total annual slips";
var remaining_payroll_title = "Remaining salary slips ";
var taken_payroll_title = "Left salary slips ";
var total_timesheet_title = "Total hours of week";
var remaining_timesheet_title = "Remaining hours ";
var taken_timesheet_title = "Left hours ";
}
if (result.leaves !== undefined) {
if (result.leaves[0].taken > 0 || result.leaves[0].remaining_leaves > 0) {
var total_leaves = (result.leaves[0].taken + result.leaves[0].remaining_leaves);
var remaining_leaves_percent = ((result.leaves[0].remaining_leaves / total_leaves) * 100).toFixed(2);
var taken_leaves_percent = ((result.leaves[0].taken / total_leaves) * 100).toFixed(2);
//set data
$('.leave-total-amount').html(total_leaves_title + " " + Math.round(parseFloat(result.leaves[0].taken + result.leaves[0].remaining_leaves)));
$('.leave-left-amount').html(remaining_leaves_title + " " + Math.round(parseFloat(result.leaves[0].remaining_leaves)));
if ($('body').hasClass('o_rtl') === true) {
$('.leave-data-percent').html('%' + taken_leaves_percent);
} else {
$('.leave-data-percent').html(taken_leaves_percent + '%');
}
$('#chartContainer').html('');
pluscharts.draw({
drawOn: "#chartContainer",
type: "donut",
dataset: {
data: [{
label: remaining_leaves_title,
value: ((result.leaves[0].taken / total_leaves) * 100).toFixed(2)
},
{
label: taken_leaves_title,
value: ((result.leaves[0].remaining_leaves / total_leaves) * 100).toFixed(2)
}
],
backgroundColor: ["#003056", "#2ead97"],
borderColor: "#ffffff",
borderWidth: 0,
},
options: {
width: 15,
text: {
display: false,
color: "#f6f6f6"
},
legends: {
display: false,
width: 20,
height: 20
},
size: {
width: '140', //give 'container' if you want width and height of initiated container
height: '140'
}
}
});
// display section
$('#leave-section').fadeIn();
}
}
if (result.payroll !== undefined) {
if (result.payroll[0].taken > 0 || result.payroll[0].payslip_remaining > 0) {
// Configure Salary Slips Chart
var total_payroll = (result.payroll[0].taken + result.payroll[0].payslip_remaining);
var remaining_payroll_percent = ((result.payroll[0].payslip_remaining / total_payroll) * 100).toFixed(2);
var taken_payroll_percent = ((result.payroll[0].taken / total_payroll) * 100).toFixed(2);
$('.payroll-total-amount').html(total_payroll_title + " " + parseFloat(total_payroll));
$('.payroll-left-amount').html(remaining_payroll_title + " " + parseFloat(result.payroll[0].payslip_remaining));
if ($('body').hasClass('o_rtl') === true) {
$('.payroll-data-percent').html('%' + taken_payroll_percent);
} else {
$('.payroll-data-percent').html(taken_payroll_percent + '%');
}
$('#chartPaylips').html('');
pluscharts.draw({
drawOn: "#chartPaylips",
type: "donut",
dataset: {
data: [{
label: remaining_payroll_title,
value: ((result.payroll[0].payslip_remaining / total_payroll) * 100).toFixed(2)
},
{
label: taken_payroll_title,
value: ((result.payroll[0].taken / total_payroll) * 100).toFixed(2)
}
],
backgroundColor: ["#003056", "#2ead97"],
borderColor: "#ffffff",
borderWidth: 0,
},
options: {
width: 15,
text: {
display: false,
color: "#f6f6f6"
},
legends: {
display: false,
width: 20,
height: 20
},
size: {
width: '140', //give 'container' if you want width and height of initiated container
height: '140'
}
}
});
// display section
$('#salary-section').fadeIn();
}
}
if (result.timesheet !== undefined) {
if (result.timesheet[0].taken > 0 || result.timesheet[0].timesheet_remaining > 0) {
// Configure Weekly Timesheet Chart
var total_timesheet = (result.timesheet[0].taken + result.timesheet[0].timesheet_remaining);
var remaining_timesheet_percent = ((result.timesheet[0].timesheet_remaining / total_timesheet) * 100).toFixed(2);
var taken_timesheet_percent = ((result.timesheet[0].taken / total_timesheet) * 100).toFixed(2);
$('.timesheet-total-amount').html(total_timesheet_title + " " + parseFloat(total_timesheet));
$('.timesheet-left-amount').html(remaining_timesheet_title + " " + parseFloat(result.timesheet[0].timesheet_remaining));
$('.timesheet-data-percent').html(taken_timesheet_percent + '%');
if ($('body').hasClass('o_rtl') === true) {
$('.timesheet-data-percent').html('%' + taken_timesheet_percent);
} else {
$('.timesheet-data-percent').html(taken_timesheet_percent + '%');
}
$('#chartTimesheet').html('');
pluscharts.draw({
drawOn: "#chartTimesheet",
type: "donut",
dataset: {
data: [{
label: taken_timesheet_title,
value: ((result.timesheet[0].taken / total_timesheet) * 100).toFixed(2)
},
{
label: remaining_timesheet_title,
value: ((result.timesheet[0].timesheet_remaining / total_timesheet)).toFixed(2)
}
],
backgroundColor: ["#003056", "#2ead97"],
borderColor: "#ffffff",
borderWidth: 0,
},
options: {
width: 15,
text: {
display: false,
color: "#f6f6f6"
},
legends: {
display: false,
width: 20,
height: 20
},
size: {
width: '140', //give 'container' if you want width and height of initiated container
height: '140'
}
}
});
// display section
$('#timesheet-section').fadeIn();
}
}
// hide charts loader
$('.charts-over-layer').fadeOut();
}, 1000);
})
// .done(function() {
// self.render();
// self.href = window.location.href;
// });
}
var attrs = [{
value: 50,
label: 'Total Amount 30',
color: '#003056'
},
{
value: 50,
label: 'Left Amount',
color: '#2ead97'
},
];
function explodePie(e) {
if (typeof(e.dataSeries.dataPoints[e.dataPointIndex].exploded) === "undefined" || !e.dataSeries.dataPoints[e.dataPointIndex].exploded) {
e.dataSeries.dataPoints[e.dataPointIndex].exploded = true;
} else {
e.dataSeries.dataPoints[e.dataPointIndex].exploded = false;
}
e.chart.render();
}
},
willStart: function() {
return $.when(ajax.loadLibs(this), this._super());
},
start: function() {
var self = this;
return this._super();
},
render: function() {
var super_render = this._super;
$(".o_control_panel").addClass("o_hidden");
var self = this;
},
reload: function() {
window.location.href = this.href;
}
});
core.action_registry.add('system_dashboard_classic.dashboard', SystemDashboardView);
return SystemDashboardView
});
window.text = [{
'btn': {
'ar': 'مشاهدة التفاصيل <i class="mdi mdi-chevron-double-left"></i>',
'en': 'Show Details <i class="mdi mdi-chevron-double-right"></i>'
}
}];
/*
NAME: buildCard
DESC: Using to create (APPROVE/FOLLOW) card base on sent data and it return DOM OBJECT
RETURN: DOM OBJECT
*/
function buildCard(data, index, card_type) {
var card = document.createElement('div');
var card_container = document.createElement('div');
var card_header = document.createElement('div');
var card_body = document.createElement('div');
$(card).addClass('col-md-3 col-sm-6 col-xs-12 card2');
$(card_container).addClass('col-md-12 col-sm-12 col-xs-12 card-container');
//card header
var h4 = document.createElement('h4');
$(card_header).addClass('col-md-12 col-sm-12 col-xs-12 card-header');
$(h4).html('<img src="data:image/png;base64,' + data.image + '"/> <span>' + data.name + '</span>').attr('title', data.name);
$(card_header).append(h4);
$(card_container).append(card_header);
// card body
$(card_body).addClass('col-md-12 col-sm-12 col-xs-12 card-body');
if (card_type == "table1") {
var table = buildTableApprove(data);
} else if (card_type == "table2") {
var table = buildTableFollow(data);
}
$(card_body).append(table);
$(card_container).append(card_body);
// card
$(card).append(card_container);
return card;
}
/*
NAME: buildTableApprove
DESC: Using to create (APPROVE) rows/buttons base on sent data and it return DOM OBJECT
RETURN: DOM OBJECT
*/
function buildTableApprove(data) {
var table = document.createElement('table');
var tbody = document.createElement('tbody');
$(table).addClass('table');
if (data.lines.length !== undefined && data.lines.length > 0) {
$.each(data.lines, function(index) {
var tr = document.createElement('tr');
var td1 = document.createElement('td');
var td2 = document.createElement('td');
$(td1).html(data.lines[index].state_approval);
$(td2).html('<div>' + data.lines[index].count_state_click + '</div>');
$(tr).attr('data-target', 'record-button').attr('data-model', data.model).attr('data-name', data.name).
attr('data-domain', JSON.stringify(data.lines[index].domain_click)).attr('form-view', data.lines[index].form_view).
attr('list-view', data.lines[index].list_view).attr('data-action', JSON.stringify(data.lines[index].action_domain));
$(tr).append(td1).append(td2);
$(tbody).append(tr);
});
}
$(table).append(tbody);
return table;
}
/*
NAME: buildTableFollow
DESC: Using to create (FOLLOW) rows/buttons base on sent data and it return DOM OBJECT
RETURN: DOM OBJECT
*/
function buildTableFollow(data) {
var table = document.createElement('table');
var tbody = document.createElement('tbody');
$(table).addClass('table');
if (data.lines.length !== undefined && data.lines.length > 0) {
$.each(data.lines, function(index) {
var tr = document.createElement('tr');
var td1 = document.createElement('td');
var td2 = document.createElement('td');
$(td1).html(data.lines[index].state_folow);
$(td2).html('<div>' + data.lines[index].count_state_follow + '</div>');
$(tr).attr('data-target', 'record-button').attr('data-type', 'follow').attr('data-model', data.model).attr('data-name', data.name).
attr('data-domain', JSON.stringify(data.lines[index].domain_follow)).attr('form-view', data.lines[index].form_view).
attr('list-view', data.lines[index].list_view)
$(tr).append(td1).append(td2);
// to append
//$(tbody).append(tr);
// to override
$(tbody).html(tr)
});
}
// to append
$(table).append(tbody);
return table;
}
function setupAttendanceArea(data, name) {
window.check = data.is_attendance;
var button = '';
if ($('body').hasClass('o_rtl')) {
var checkout_title = "زمن تسجيل اخر دخول"
var checkin_title = ",مرحباً بك"
var checkin_button = "تسجيل الحضور";
var checkout_button = "تسجيل الخروج";
} else {
var checkout_title = "Last check in"
var checkin_title = "Welcome";
var checkin_button = "Check In"
var checkout_button = "Check Out";
}
if (data.is_attendance === true) {
var check = checkout_title + " " + data.time;
var image = '<img class="img-logout" src="/system_dashboard_classic/static/src/icons/logout.svg"/>';
$('#check_button').html(checkout_button).removeClass('checkin-btn').addClass('checkout-btn');
$('.last-checkin-section').html(check);
$('.attendance-img-section').html(image);
} else {
var check = checkin_title + " " + name + ",";
var image = '<img class="img-login" src="/system_dashboard_classic/static/src/icons/logout.svg"/>';
$('#check_button').html(checkin_button).removeClass('checkout-btn').addClass('checkin-btn');
$('.last-checkin-section').html(check);
$('.attendance-img-section').html(image);
}
}

View File

@ -1,151 +0,0 @@
'use strict';
(function(global) {
var Samples = global.Samples || (global.Samples = {});
var Color = global.Color;
function fallback(/* values ... */) {
var ilen = arguments.length;
var i = 0;
var v;
for (; i < ilen; ++i) {
v = arguments[i];
if (v !== undefined) {
return v;
}
}
}
Samples.COLORS = [
'#FF3784',
'#36A2EB',
'#4BC0C0',
'#F77825',
'#9966FF',
'#00A8C6',
'#379F7A',
'#CC2738',
'#8B628A',
'#8FBE00',
'#606060'
];
// Adapted from http://indiegamr.com/generate-repeatable-random-numbers-in-js/
Samples.srand = function(seed) {
this._seed = seed;
};
Samples.rand = function(min, max) {
var seed = this._seed;
min = min === undefined ? 0 : min;
max = max === undefined ? 1 : max;
this._seed = (seed * 9301 + 49297) % 233280;
return min + (this._seed / 233280) * (max - min);
};
Samples.numbers = function(config) {
var cfg = config || {};
var min = fallback(cfg.min, 0);
var max = fallback(cfg.max, 1);
var from = fallback(cfg.from, []);
var count = fallback(cfg.count, 8);
var decimals = fallback(cfg.decimals, 8);
var continuity = fallback(cfg.continuity, 1);
var dfactor = Math.pow(10, decimals) || 0;
var data = [];
var i, value;
for (i = 0; i < count; ++i) {
value = (from[i] || 0) + this.rand(min, max);
if (this.rand() <= continuity) {
data.push(Math.round(dfactor * value) / dfactor);
} else {
data.push(null);
}
}
return data;
};
Samples.color = function(offset) {
var count = Samples.COLORS.length;
var index = offset === undefined ? ~~Samples.rand(0, count) : offset;
return Samples.COLORS[index % count];
};
Samples.colors = function(config) {
var cfg = config || {};
var color = cfg.color || Samples.color(0);
var count = cfg.count !== undefined ? cfg.count : 8;
var method = cfg.mode ? Color.prototype[cfg.mode] : null;
var values = [];
var i, f, v;
for (i = 0; i < count; ++i) {
f = i / count;
if (method) {
v = method.call(Color(color), f).rgbString();
} else {
v = Samples.color(i);
}
values.push(v);
}
return values;
};
Samples.transparentize = function(color, opacity) {
var alpha = opacity === undefined ? 0.5 : 1 - opacity;
return Color(color).alpha(alpha).rgbString();
};
// INITIALIZATION
Samples.srand(Date.now());
var root = (function() {
var scripts = document.getElementsByTagName('script');
var script = scripts[scripts.length - 1];
var path = script.src;
return path.substr(0, path.lastIndexOf('/') + 1);
}());
window.addEventListener('DOMContentLoaded', function load() {
window.removeEventListener('DOMContentLoaded', load, true);
var header = document.getElementById('header');
var info = global.SAMPLE_INFO;
if (header && info) {
var group = info.group;
var name = info.name;
var desc = info.desc;
document.title = name + (group ? ' / ' + group : '') + ' / chartjs-plugin-datalabels';
header.innerHTML =
'<div class="scope">' +
'<a href="' + root + 'index.html">&laquo; chartjs-plugin-datalabels</a>' +
'</div>' +
'<div class="title">' +
'<span class="group">' + group + ' / </span>' +
'<span class="name">' + name + '</span>' +
(desc ? '<div class="desc">' + desc + '</div>' : '') +
'</div>';
}
}, true);
// Google Analytics
/* eslint-disable */
if (['localhost', '127.0.0.1', ''].indexOf(document.location.hostname) === -1) {
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-99068522-2', 'auto');
ga('send', 'pageview');
}
/* eslint-enable */
}(this));

File diff suppressed because one or more lines are too long

View File

@ -113,53 +113,129 @@
}
/*CARD (2)*/
/*CARD (2) - Approval Cards - Enhanced */
.card2 {
padding-left: 0;
margin-bottom: 20px;
.card-container {
/* border: 1px solid $bg_card_button; */
border: 1px solid #003056;
border: none;
border-radius: var(--dashboard-border-radius, 16px);
padding: 0;
overflow: hidden;
box-shadow: var(--dashboard-shadow-md, 0 4px 20px rgba(0, 0, 0, 0.08));
transition: all var(--dashboard-transition-normal, 0.3s ease);
position: relative;
background: #ffffff;
/* Top accent bar on hover */
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(
90deg,
var(--dashboard-accent, #667eea),
var(--dashboard-primary, #2ead97)
);
opacity: 0;
transition: opacity var(--dashboard-transition-normal, 0.3s ease);
z-index: 10;
}
&:hover {
transform: translateY(-6px);
box-shadow: var(--dashboard-shadow-lg, 0 12px 40px rgba(0, 0, 0, 0.12));
}
&:hover::before {
opacity: 1;
}
.card-header {
background: $bg_card_header;
height: 50px;
background: linear-gradient(
135deg,
var(--dashboard-gradient-start, #0e3e34) 0%,
var(--dashboard-gradient-end, #00887e) 100%
);
height: 56px;
vertical-align: middle;
padding-left: 10px!important;
padding-right: 10px!important;
padding: 0 16px !important;
display: flex;
align-items: center;
position: relative;
overflow: hidden;
/* Decorative glow */
&::after {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 150px;
height: 150px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(255, 255, 255, 0.12) 0%,
transparent 70%
);
pointer-events: none;
}
img {
height: 34px;
width: 34px;
margin-right: 10px;
height: 38px;
width: 38px;
margin-right: 12px;
padding: 6px;
background: rgba(255, 255, 255, 0.15);
border-radius: 10px;
transition: transform var(--dashboard-transition-normal, 0.3s ease);
}
&:hover img {
transform: scale(1.1) rotate(-5deg);
}
h4 {
line-height: 30px;
line-height: 1.3;
color: #ffffff;
font-weight: bold;
padding: 0!important;
margin-top: 8px;
margin-bottom: 8px;
font-weight: 600;
padding: 0 !important;
margin: 0;
font-size: 15px;
flex: 1;
display: flex;
align-items: center;
gap: 10px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
i {
font-size: 24px;
font-size: 22px;
opacity: 0.9;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&.red {
background: #ee0c21;
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
}
&.blue {
background: #2bb0ee;
background: linear-gradient(135deg, #2193b0 0%, #6dd5ed 100%);
}
&.green {
background: #08bf17;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
}
}
.card-body {
@ -168,22 +244,48 @@
overflow-y: auto;
background: #fff;
/* Custom scrollbar */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
}
&::-webkit-scrollbar-thumb {
background: var(--dashboard-primary, #2ead97);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--dashboard-primary-dark, #084e41);
}
table {
margin: 0;
tr {
border-top: none;
border-bottom: 1px solid #003056;
/* border-bottom: 1px solid $bg_card_button; */
border-bottom: 1px solid rgba(0, 48, 86, 0.08);
transition: all var(--dashboard-transition-fast, 0.2s ease);
cursor: pointer;
&:hover {
background: #f8f8f8;
cursor: pointer;
color: #06211a;
background: linear-gradient(
90deg,
rgba(46, 173, 150, 0.08),
rgba(102, 126, 234, 0.05)
);
}
&:nth-child(2n) {
background: #edf6fd;
background: rgba(237, 246, 253, 0.5);
}
&:nth-child(2n):hover {
background: linear-gradient(
90deg,
rgba(46, 173, 150, 0.12),
rgba(102, 126, 234, 0.08)
);
}
&:last-child {
@ -192,71 +294,146 @@
td {
border-top: none;
line-height: 26px;
font-size: 16px;
padding: 10px;
line-height: 1.6;
font-size: 14px;
padding: 12px 14px;
color: var(--dashboard-text-primary, #2d3748);
font-weight: 500;
&:last-child {
text-align: right;
div {
/* background: #003056; */
background: $bg_card_header;
height: 26px;
background: linear-gradient(
135deg,
var(--dashboard-gradient-start, #0e3e34),
var(--dashboard-gradient-end, #00887e)
);
height: 28px;
width: 28px;
text-align: center;
width: 26px;
border-radius: 50%;
line-height: 26px;
font-size: 11px;
font-weight: bold;
line-height: 28px;
font-size: 12px;
font-weight: 700;
float: right;
color: #fff;
transition: all var(--dashboard-transition-normal, 0.3s ease);
box-shadow: 0 2px 8px rgba(0, 136, 126, 0.3);
}
i{
i {
transition: color var(--dashboard-transition-fast, 0.2s ease);
&:hover {
cursor: pointer;
color: #06211a;
color: var(--dashboard-primary, #2ead97);
}
}
}
}
}
tr:hover td:last-child div {
transform: scale(1.15);
box-shadow: 0 4px 12px rgba(0, 136, 126, 0.45);
}
}
}
}
}
/*CARD (3)*/
/*CARD (3) - Self Service Cards - Enhanced */
.card3 {
padding: 0 5px;
margin-bottom: 20px;
padding: 0 8px;
margin-bottom: 24px;
.card-body {
padding: 0;
border-radius: var(--dashboard-border-radius, 16px);
overflow: hidden;
box-shadow: var(--dashboard-shadow-md, 0 4px 20px rgba(0, 0, 0, 0.08));
transition: all var(--dashboard-transition-normal, 0.3s ease);
position: relative;
/* Top accent bar on hover */
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(
90deg,
var(--dashboard-accent, #667eea),
var(--dashboard-primary, #2ead97)
);
opacity: 0;
transition: opacity var(--dashboard-transition-normal, 0.3s ease);
z-index: 10;
}
&:hover {
transform: translateY(-8px);
box-shadow: var(--dashboard-shadow-lg, 0 12px 40px rgba(0, 0, 0, 0.12));
}
&:hover::before {
opacity: 1;
}
.box-1 {
height: 250px;
background: $bg_card;
background: linear-gradient(
145deg,
#ffffff 0%,
var(--dashboard-card-bg, #f4fefe) 100%
);
text-align: center;
vertical-align: middle;
transition: all .4s;
transition: all var(--dashboard-transition-normal, 0.3s ease);
display: flex;
flex-direction: column;
cursor: pointer;
align-content: center;
align-items: center;
border: 1px solid #00562e;
justify-content: center;
border: none;
position: relative;
overflow: hidden;
/* Decorative background circle */
&::before {
content: '';
position: absolute;
top: -30%;
right: -30%;
width: 200px;
height: 200px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(46, 173, 150, 0.06) 0%,
transparent 70%
);
pointer-events: none;
transition: transform var(--dashboard-transition-slow, 0.5s ease);
}
&:hover::before {
transform: scale(1.3);
}
&.red {
background: #ee0c21;
background: linear-gradient(145deg, #fee2e2, #fecaca);
}
&.blue {
background: #2bb0ee;
background: linear-gradient(145deg, #dbeafe, #bfdbfe);
}
&.green {
background: #08bf17;
background: linear-gradient(145deg, #dcfce7, #bbf7d0);
}
i {
@ -264,52 +441,107 @@
color: #fff;
}
h4 {
margin-bottom: 0;
font-size: 20px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
color: $bg_card_button;
/* color: #003056; */
padding-top: 10px!important;
padding-bottom: 0!important;
img {
height: 70px;
width: auto;
margin-bottom: 8px;
transition: all var(--dashboard-transition-normal, 0.3s ease);
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.1));
}
&:hover img {
transform: scale(1.15) rotate(-5deg);
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.15));
}
h3 {
margin-bottom: 0;
font-size: 48px;
margin-top: 8px;
font-size: 52px;
font-weight: 800;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
color: #003056;
color: $bg_card_button;
background: linear-gradient(
135deg,
var(--dashboard-secondary, #003056),
var(--dashboard-primary, #2ead97)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
transition: transform var(--dashboard-transition-normal, 0.3s ease);
}
img {
height: 60px;
&:hover h3 {
transform: scale(1.05);
}
h4 {
margin-bottom: 0;
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
color: var(--dashboard-text-secondary, #718096);
padding-top: 10px !important;
padding-bottom: 0 !important;
max-width: 100%;
}
}
.box-2 {
background: $bg_card_button;
transition: all .4s;
background: linear-gradient(
135deg,
var(--dashboard-gradient-start, #0e3e34) 0%,
var(--dashboard-gradient-end, #00887e) 100%
);
transition: all var(--dashboard-transition-normal, 0.3s ease);
cursor: pointer;
line-height: 50px;
height: 56px;
line-height: 56px;
overflow: hidden;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
padding: 0 20px;
position: relative;
/* Subtle shine effect */
&::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1),
transparent
);
transition: left 0.5s ease;
}
&:hover::before {
left: 100%;
}
i {
font-size: 36px;
font-size: 28px;
color: #fff;
transition: all .4s;
float: right;
transition: all var(--dashboard-transition-normal, 0.3s ease);
margin-left: 10px;
}
span {
float: left;
font-size: 14px;
color: #eee;
font-size: 15px;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
h4 {
@ -322,13 +554,21 @@
}
&:hover {
background: #0c483e;
background: linear-gradient(
135deg,
var(--dashboard-primary-dark, #084e41) 0%,
var(--dashboard-gradient-start, #0e3e34) 100%
);
i {
transform: rotate(45deg);
transform: rotate(90deg) scale(1.1);
}
span {
color: #ffffff;
}
}
}
}
}

View File

@ -1,155 +0,0 @@
// CARD (1)
.card {
margin-top: 20px;
.card-body {
/*border: 1px solid #eee;*/
height: 200px;
padding: 0;
background: #ffffff;
.box-1 {
height: 100%;
background: #ee6414;
text-align: center;
vertical-align: middle;
line-height: 200px;
i {
font-size: 60px;
color: #fff;
}
&.red {
background: #ee0c21;
}
&.blue {
background: #2bb0ee;
}
&.green {
background: #08bf17;
}
&.dark-blue {
background: #3e5d7f;
}
}
.box-2 {
padding-right: 10px;
padding-left: 10px;
h4 {
margin-bottom: 0;
}
h2 {
margin-top: 5px;
margin-bottom: 0px;
font-weight: bold;
}
p {
margin-bottom: 10px;
}
a {
background: #eeeeee;
}
&.red {
background: #ee0c21;
}
&.blue {
background: #2bb0ee;
}
&.green {
background: #08bf17;
}
}
}
}
// CARD (2)
.card2 {
margin-top: 20px;
.card-container {
/*border: 1px solid #eee;*/
height: 200px;
padding: 0;
background: #ffffff;
.card-header {
height: 50px;
background: #ee6414;
vertical-align: middle;
padding-left: 10px !important;
padding-right: 10px !important;
h4 {
line-height: 30px;
color: #ffffff;
font-weight: bold;
i {
font-size: 24px;
}
}
.red {
background: #ee0c21;
}
.blue {
background: #2bb0ee;
}
.green {
background: #08bf17;
}
}
.card-body {
padding: 5px 10px;
table {
tr {
border-top: none;
border-bottom: 1px solid #eee;
&:last-child {
border-bottom: none;
}
td {
border-top: none;
/* padding-left: 0; */
padding-right: 0;
&:last-child {
text-align: right;
}
&:last-child {
i {
&:hover {
cursor: pointer;
color: #06211a;
}
}
}
}
}
}
}
}
}
// RTL
.o_rtl {
.card2 {
padding-left: inherit;
}
}

View File

@ -95,7 +95,7 @@ $border-color_1: #2eac96;
text-align: center;
padding: 0;
p {
margin-bottom: 20px;
margin-bottom: 10px;
}
h3 {
margin-top: 0;
@ -156,7 +156,6 @@ $border-color_1: #2eac96;
margin-right: 5px;
>a {
color: $color_nav;
border: 1px solid $color_5 !important;
margin: 0;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,77 +1,164 @@
$color_1: linear-gradient(270deg, rgb(14, 62, 52) 0%, rgb(0, 136, 126) 75%);
/* ============================================================
System Dashboard Classic - RTL Styles
============================================================
This file contains RTL (Right-to-Left) overrides for Arabic/Hebrew
language support. Uses .o_rtl class added by Odoo.
IMPORTANT: This file must load AFTER all LTR styles.
============================================================ */
.o_rtl {
.card {
.card-body {
.box-2 {
.btn-group {
position: absolute;
bottom: -50px;
left: 10px;
right: auto;
transition: all .4s;
/* === Profile Section RTL === */
.profile-container {
.pp-info-section {
border: none;
border-right: 1px solid #9f9f9f;
padding: 10px;
}
/* Green status dot - flip to left side */
.pp-image-section::after {
right: auto !important;
left: 8px !important;
}
}
/* === Module Box / Statistics RTL === */
.module-box {
.module-box-container {
.module-body {
padding: 10px 15px 10px 10px;
a {
background-color: transparent;
&:hover {
text-decoration: none;
}
h3 {
margin: 0;
transition: all .3s ease-in-out;
color: #8a8a8a;
font-size: 16px;
text-align: center;
padding-top: 5px;
}
}
h2 {
text-align: center;
font-size: 26px;
color: #06211a;
margin-top: 5px;
}
.module-icon {
text-align: center;
height: 80px;
width: 80px;
line-height: 80px;
border-radius: 50%;
position: absolute;
top: -40px;
right: -20px;
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, .2),
0 6px 10px 0 rgba(0, 0, 0, .14),
0 1px 18px 0 rgba(0, 0, 0, .12) !important;
i {
font-size: 45px;
color: #ffffff;
}
}
.module-icon.red { background: #ee451f; }
.module-icon.green { background: #14bf1f; }
.module-icon.yellow { background: #f9d700; }
}
/* Stat labels - reverse flex direction */
p span {
flex-direction: row-reverse !important;
}
}
}
.card3 {
padding: 0 5px;
.card-body {
.box-1 {
h4 {
margin-bottom: 0;
font-size: 16px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
color: $color_1;
padding-top: 10px !important;
padding-bottom: 0 !important;
}
/* === Card2 (Approval Cards) RTL === */
.card2 {
padding-left: inherit;
padding-right: 0;
.card-container .card-header {
flex-direction: row-reverse !important;
img {
margin-right: 0 !important;
margin-left: 12px !important;
}
.box-2 {
&:hover {
.btn-group {
bottom: 10px;
}
}
i {
h4 {
text-align: right !important;
}
}
.card-container .card-body table tr td {
text-align: right;
&:last-child {
text-align: left;
div {
float: left;
}
span {
float: right;
}
}
}
}
.card2 {
.card-container {
.card-header {
img {
margin-right: unset;
margin-left: 10px;
}
}
.card-body {
table {
tr {
td {
&:last-child {
div {
float: left;
}
}
}
}
}
}
/* === Card3 (Service Cards) RTL === */
.card3 .card-body .box-2 {
flex-direction: row-reverse !important;
i {
margin-left: 0 !important;
margin-right: 8px !important;
}
padding-left: inherit;
}
.profile-container {
padding-right: 0;
/* === Tab Navigation RTL === */
.dashboard-nav-buttons {
flex-direction: row-reverse !important;
.nav-tabs {
flex-direction: row-reverse !important;
}
}
.p0 {
padding: 0;
/* === Search Container RTL === */
.genius-search-container {
margin-left: 0 !important;
margin-right: auto !important;
}
.genius-search-icon {
left: auto !important;
right: 14px !important;
}
.genius-search-input {
padding: 10px 38px 10px 40px !important;
text-align: right !important;
}
.genius-search-clear {
right: auto !important;
left: 14px !important;
}
/* === Attendance Section RTL === */
.dashboard-attendance-section {
border-left: none !important;
border-right: 1px solid rgba(255, 255, 255, 0.1) !important;
}
}

View File

@ -1,17 +1,59 @@
$bg_user_section: linear-gradient(270deg, rgb(14, 62, 52) 0%, rgb(0, 136, 126) 75%) !default;
$bg_user_statistics: #FFFFFF !default;
$bg_checkin_btn: #2ead97 !default;
$bg_checkin_btn_hover: #084e41 !default;
$bg_checkout_btn: #2ead97 !default;
$bg_checkout_btn_hover: #084e41 !default;
$divd_border_color: #2eac96 !default;
$bg_dashboard_nav: #2ead97 !default;
$bg_dashboard_nav_hover: #084e41 !default;
$color_nav: #0B2E59 !default;
$bg_card: #f4fefe !default;
$bg_card_button: linear-gradient(270deg, #0e3e34 0%, #00887e 75%) !default;
$bg_card_header: linear-gradient(270deg, #0e3e34 0%, #00887e 75%) !default;
/* ============================================================
System Dashboard Classic - Theme Variables
============================================================
MINIMAL COLOR SCHEME:
- Primary: Teal (#0d9488)
- Secondary: Dark Blue (#1e293b)
To customize, override these CSS variables in your theme:
:root {
--theme-primary: #YOUR_COLOR;
--theme-secondary: #YOUR_COLOR;
}
============================================================ */
/* === THEME CONFIGURATION (Edit these to change the entire look) === */
:root {
/* Primary Colors - Main brand color */
--theme-primary: #0d9488;
--theme-primary-light: #14b8a6;
--theme-primary-dark: #0f766e;
/* Secondary Colors - For headers and dark elements */
--theme-secondary: #1e293b;
--theme-secondary-light: #334155;
/* Neutral Colors */
--theme-bg-light: #f8fafc;
--theme-bg-white: #ffffff;
--theme-border: #e2e8f0;
--theme-text: #475569;
--theme-text-light: #94a3b8;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
/* Transitions */
--transition: 0.25s ease;
}
/* === SCSS Variables (Backward Compatibility) === */
$bg_user_section: linear-gradient(135deg, #1e293b 0%, #334155 100%) !default;
$bg_user_statistics: #FFFFFF !default;
$bg_checkin_btn: #0d9488 !default;
$bg_checkin_btn_hover: #0f766e !default;
$bg_checkout_btn: #0d9488 !default;
$bg_checkout_btn_hover: #0f766e !default;
$divd_border_color: #0d9488 !default;
$bg_dashboard_nav: #0d9488 !default;
$bg_dashboard_nav_hover: #0f766e !default;
$color_nav: #1e293b !default;
$bg_card: #f8fafc !default;
$bg_card_button: linear-gradient(135deg, #1e293b 0%, #334155 100%) !default;
$bg_card_header: linear-gradient(135deg, #1e293b 0%, #334155 100%) !default;
@if variable-exists(sidebar_bg){
$bg_user_section: $sidebar_bg;

View File

@ -23,7 +23,7 @@
<div class="lds-roller"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>
</div>
<div class="col-md-12 col-sm-12 col-12 dashboard-module-charts" style="padding:0;">
<div class="col-md-4 col-sm-6 col-12 module-box" id="leave-section">
<div class="col-md-4 col-sm-6 col-12 module-box" id="leave-section" style="display:none;">
<div class="col-md-12 module-box-container">
<p>
<span>
@ -35,12 +35,17 @@
<i class="fa fa-circle"/> <span class="leave-left-amount"></span>
</span>
</p>
<div id="chartContainer"></div>
<h4 class="leave-data-percent"></h4>
<div class="chart-wrapper">
<div id="chartContainer"></div>
<div class="chart-center-text">
<span class="chart-center-value leave-center-value"></span>
<span class="chart-center-unit leave-center-unit"></span>
</div>
</div>
<h3>Annual Leave</h3>
</div>
</div>
<div class="col-md-4 col-sm-6 col-12 module-box" id="salary-section">
<div class="col-md-4 col-sm-6 col-12 module-box" id="salary-section" style="display:none;">
<div class="col-md-12 col-sm-12 col-12 module-box-container">
<p>
<span>
@ -52,12 +57,17 @@
<i class="fa fa-circle"/> <span class="payroll-left-amount"></span>
</span>
</p>
<div id="chartPaylips"></div>
<h4 class="payroll-data-percent"></h4>
<div class="chart-wrapper">
<div id="chartPaylips"></div>
<div class="chart-center-text">
<span class="chart-center-value payroll-center-value"></span>
<span class="chart-center-unit payroll-center-unit"></span>
</div>
</div>
<h3>Salary Slips</h3>
</div>
</div>
<div class="col-md-4 col-sm-6 col-12 module-box" id="timesheet-section">
<div class="col-md-4 col-sm-6 col-12 module-box" id="timesheet-section" style="display:none;">
<div class="col-md-12 col-sm-12 col-12 module-box-container">
<p>
<span>
@ -66,14 +76,42 @@
</p>
<p>
<span>
<i class="fa fa-circle"/> <span class="timesheet-total-amount"></span>
<i class="fa fa-circle"/> <span class="timesheet-left-amount"></span>
</span>
</p>
<div id="chartTimesheet"></div>
<h4 class="timesheet-data-percent"></h4>
<div class="chart-wrapper">
<div id="chartTimesheet"></div>
<div class="chart-center-text">
<span class="chart-center-value timesheet-center-value"></span>
<span class="chart-center-unit timesheet-center-unit"></span>
</div>
</div>
<h3>Weekly Timesheet</h3>
</div>
</div>
<!-- Attendance Hours Card -->
<div class="col-md-4 col-sm-6 col-12 module-box" id="attendance-hours-section" style="display:none;">
<div class="col-md-12 col-sm-12 col-12 module-box-container">
<p>
<span>
<i class="fa fa-circle"/> <span class="attendance-plan-hours">0</span>
</span>
</p>
<p>
<span>
<i class="fa fa-circle"/> <span class="attendance-official-hours">0</span>
</span>
</p>
<div class="chart-wrapper">
<div id="chartAttendanceHours"></div>
<div class="chart-center-text">
<span class="chart-center-value attendance-center-value"></span>
<span class="chart-center-unit attendance-center-unit"></span>
</div>
</div>
<h3>Monthly Attendance</h3>
</div>
</div>
</div>
</div>
<div class="col-md-2 col-sm-12 col-12 dashboard-attendance-section">
@ -81,12 +119,10 @@
<div class="lds-roller"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>
</div>
<div class="col-md-12 col-sm-12 col-12 attendance-section-body">
<h3>Attendance</h3>
<h3 class="attendance-title">Attendance</h3>
<p class="last-checkin-section"/>
<p class="attendance-img-section"/>
<!--p class="attendance-button-section">
<button type="button" id="check_button" class="btn btn-danger"></button>
</p-->
<p class="last-checkin-info"/>
</div>
</div>
</div>
@ -96,6 +132,9 @@
<div class="col-md-12 col-sm-12 col-12 dashboard-nav-buttons">
<ul class="nav nav-tabs">
<li role="presentation" class="nav-item"><a href="#self_services" class="nav-link active" aria-controls="self_services" role="tab" data-toggle="tab">Self Services</a></li>
<!-- Approval Tabs - hidden by default, shown via JS if user has approve cards -->
<li role="presentation" class="nav-item approval-tab-item" style="display:none;"><a href="#to_approve" class="nav-link" aria-controls="to_approve" role="tab" data-toggle="tab">To Approve <span class="pending-count-badge" style="display:none;"></span></a></li>
<li role="presentation" class="nav-item approval-tab-item" style="display:none;"><a href="#to_track" class="nav-link" aria-controls="to_track" role="tab" data-toggle="tab">To Track</a></li>
</ul>
<hr/>
</div>
@ -104,6 +143,13 @@
<div role="tabpanel" class="tab-pane fade show active" id="self_services">
<div class="col-md-12 col-12 d-flex flex-wrap card-section1" style="padding: 0 15px;"></div>
</div>
<!-- Approval Tab Panes -->
<div role="tabpanel" class="tab-pane fade" id="to_approve">
<div class="col-md-12 col-12 d-flex flex-wrap card-section-approve" style="padding: 0 15px;"></div>
</div>
<div role="tabpanel" class="tab-pane fade" id="to_track">
<div class="col-md-12 col-12 d-flex flex-wrap card-section-track" style="padding: 0 15px;"></div>
</div>
</div>
</div>
</div>

View File

@ -74,6 +74,24 @@
<h3>Weekly Timesheet</h3>
</div>
</div>
<!-- Attendance Hours Card -->
<div class="col-md-4 col-sm-6 col-12 module-box" id="attendance-hours-section">
<div class="col-md-12 col-sm-12 col-12 module-box-container">
<p>
<span>
<i class="fa fa-circle"/> <span class="attendance-plan-hours">0</span>
</span>
</p>
<p>
<span>
<i class="fa fa-circle"/> <span class="attendance-official-hours">0</span>
</span>
</p>
<div id="chartAttendanceHours"></div>
<h4 class="attendance-hours-percent"></h4>
<h3>Monthly Attendance</h3>
</div>
</div>
</div>
</div>
<div class="col-md-2 col-sm-12 col-12 dashboard-attendance-section">
@ -84,9 +102,7 @@
<h3>Attendance</h3>
<p class="last-checkin-section"/>
<p class="attendance-img-section"/>
<!--p class="attendance-button-section">
<button type="button" id="check_button" class="btn btn-danger"></button>
</p-->
<p class="last-checkin-info"/>
</div>
</div>
</div>

View File

@ -1,84 +1,198 @@
<?xml version="1.0"?>
<odoo>
<!-- Dashboard Builder - Enhanced Form View -->
<record id="view_base_dashboard_form" model="ir.ui.view">
<field name="name">Base Dashboard</field>
<field name="name">Dashboard Builder</field>
<field name="model">base.dashbord</field>
<field name="arch" type="xml">
<form>
<form string="Dashboard Service Configuration">
<sheet>
<!-- Avatar / Icon Selection -->
<!-- Avatar / Icon Selection -->
<div class="oe_title">
<label for="icon_type" class="oe_edit_only"/>
<field name="icon_type" widget="radio" class="oe_edit_only" options="{'horizontal': true}"/>
</div>
<!-- Image Widget (Visible if 'image') - NOT REQUIRED -->
<field name="card_image" widget="image" class="oe_avatar"
options="{'preview_image': 'card_image'}"
attrs="{'invisible': [('icon_type', '=', 'icon')]}"/>
<!-- Icon Preview Widget (Visible if 'icon') -->
<field name="icon_preview_html" widget="html" class="oe_avatar"
style="padding:0; border:none; background:transparent;"
attrs="{'invisible': [('icon_type', '=', 'image')]}"/>
<div class="oe_title">
<h1>
<field name="name" placeholder="Service Name" required="True"/>
</h1>
<div attrs="{'invisible': [('icon_type', '=', 'image')]}" class="o_row">
<!-- NOTE: Removed required attr to allow default fallback -->
<field name="icon_name" placeholder="fa-plane"/>
<a href="https://fontawesome.com/v4/icons/" target="_blank" class="btn btn-link" role="button">
<i class="fa fa-external-link"/> Browse Icons
</a>
</div>
</div>
<!-- Main Configuration Groups -->
<group>
<field name="model_id" options="{'no_create_edit': True}" required="1"/>
<field name="name"/>
<field name="card_image" widget="image" style="width: 100px; height: 100px;"/>
<field name="model_name" invisible="1" />
<field name="form_view_id" options="{'no_create_edit': True}" domain="[('type','=','form'),('model','=',model_name)]" />
<field name="list_view_id" options="{'no_create_edit': True}" domain="[('type','=','tree'),('model','=',model_name)]" />
<field name="action_id" options="{'no_create_edit': True}" domain="[('res_model','=',model_name)]" required="1" />
<field name="is_self_service"/>
<field name="is_financial_impact"/>
<field name="sequence"/>
<field name="search_field"/>
<field name="action_domain" invisible="1" />
<field name="action_context" invisible="1"/>
<field name="is_button" invisible="1"/>
<field name="is_stage" invisible="1"/>
<field name="is_double" invisible="1"/>
<field name="is_state" invisible="1"/>
<group string="Model Configuration">
<field name="model_id"
options="{'no_create_edit': True}"
required="1"/>
<field name="model_name" invisible="1"/>
<field name="action_id"
options="{'no_create_edit': True}"
domain="[('res_model','=',model_name)]"
required="1"
help="The action to open when clicking this card"/>
</group>
<group string="Display Options">
<field name="sequence" help="Lower numbers appear first"/>
<field name="is_self_service"
help="Enable for employee self-service cards" widget="boolean_toggle"/>
<field name="is_financial_impact"
help="Mark if this service has no financial impact" widget="boolean_toggle"/>
</group>
</group>
<group>
<group string="Employee Filter Configuration">
<field name="search_field"
placeholder="e.g., employee_id.user_id or user_id"
help="The field path used to filter records for current user.&#10;&#10;Examples:&#10;• 'employee_id.user_id' - For HR models (hr.leave, hr.expense, etc.)&#10;• 'user_id' - For models with direct user reference (purchase.order, etc.)&#10;• 'create_uid' - For records created by the user"/>
</group>
<group string="Advanced View Settings"
attrs="{'invisible': [('model_id', '=', False)]}">
<field name="form_view_id"
options="{'no_create_edit': True}"
domain="[('type','=','form'),('model','=',model_name)]"
help="Optional: Custom form view for this service"/>
<field name="list_view_id"
options="{'no_create_edit': True}"
domain="[('type','=','tree'),('model','=',model_name)]"
help="Optional: Custom list view for this service"/>
</group>
</group>
<!-- Hidden computed fields -->
<field name="action_domain" invisible="1"/>
<field name="action_context" invisible="1"/>
<field name="is_button" invisible="1"/>
<field name="is_stage" invisible="1"/>
<field name="is_double" invisible="1"/>
<field name="is_state" invisible="1"/>
<!-- State/Stage Configuration Notebook -->
<notebook>
<page name="Apply TO" string="Apply TO">
<button name="compute_selection" string="Load Model State" type="object" class="oe_highlight" attrs="{'invisible': [('is_button', '=', True)]}"/>
<div>
<button name="update_selection" string="Updtae Model State" type="object" class="oe_highlight oe_inline" attrs="{'invisible': [('is_button', '=', False)]}"/>
<span style="margin-left: 10px;"/>
<button name="unlink_nodes" string="Unlink Nodes" type="object" class="btn btn-danger oe_inline" attrs="{'invisible': [('is_button', '=', False)]}"/>
<page name="state_config" string="State/Stage Configuration">
<!-- Initial state - show info and load button -->
<div attrs="{'invisible': [('is_button', '=', True)]}">
<div class="alert alert-info" role="alert">
<i class="fa fa-info-circle"/> Click the button below to detect available states/stages for the selected model.
</div>
<button name="compute_selection"
string="Load Model States"
type="object"
class="btn-primary"
icon="fa-download"/>
</div>
<field name="line_ids" attrs="{'invisible': [('is_button', '=', False)]}" context="{'default_model_name':model_name,'default_model_id':model_id}">
<!-- After loading - show action buttons above the list -->
<div class="d-flex mb-3" attrs="{'invisible': [('is_button', '=', False)]}">
<button name="update_selection"
string="Refresh States"
type="object"
class="btn-primary"
icon="fa-refresh"/>
<button name="unlink_nodes"
string="Remove All States"
type="object"
class="btn-danger ml-2"
icon="fa-trash"
confirm="Are you sure you want to remove all loaded states?"/>
</div>
<field name="line_ids"
attrs="{'invisible': [('is_button', '=', False)]}"
context="{'default_model_name':model_name,'default_model_id':model_id}">
<tree editable="bottom">
<field name="sequence" widget="handle" />
<field name="group_ids" widget="many2many_tags" options="{'no_quick_create': True}" required ="1" />
<field name="sequence" widget="handle"/>
<field name="group_ids"
widget="many2many_tags"
options="{'no_quick_create': True, 'color_field': 'color'}"
required="1"
placeholder="Select user groups..."/>
<field name="model_name" invisible="1"/>
<field name="model_id" invisible="1"/>
<field name="state_id" attrs="{'column_invisible': [('parent.is_state', '=',False ),('parent.is_double', '=',False )]}" options="{'no_create': True, 'no_create_edit':True}" domain="[('model_id', '=', model_id)]" />
<field name="stage_id" attrs="{'column_invisible': [('parent.is_stage', '=',False)]}" options="{'no_create': True, 'no_create_edit':True}" domain="[('model_id', '=', model_id)]"/>
<field name="state_id"
attrs="{'column_invisible': [('parent.is_state', '=', False), ('parent.is_double', '=', False)]}"
options="{'no_create': True, 'no_create_edit': True}"
domain="[('model_id', '=', model_id)]"/>
<field name="stage_id"
attrs="{'column_invisible': [('parent.is_stage', '=', False)]}"
options="{'no_create': True, 'no_create_edit': True}"
domain="[('model_id', '=', model_id)]"/>
</tree>
<!-- ,('parent.model_name', '!=','hr.holidays') required = "[('parent.is_stage', '=',True )]" required ="[('parent.is_stage', '=',False )]-->
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Dashboard Builder - Enhanced Tree View -->
<record id="view_base_dashboard_tree" model="ir.ui.view">
<field name="name">Base Dashboard</field>
<field name="name">Dashboard Builder</field>
<field name="model">base.dashbord</field>
<field name="arch" type="xml">
<tree>
<tree string="Dashboard Services" default_order="sequence">
<field name="sequence" widget="handle"/>
<!-- Unified Preview Column -->
<field name="icon_preview_html" widget="html" string="Icon/Image"/>
<field name="card_image" invisible="1"/> <!-- explicit card_image kept invisible if needed by logic -->
<field name="name"/>
<field name="model_id"/>
<field name="is_self_service" widget="boolean_toggle"/>
<field name="action_id"/>
<field name="search_field"/>
</tree>
</field>
</record>
<!-- Dashboard Builder Action -->
<record id="base_dashboard_action" model="ir.actions.act_window">
<field name="name">Base Dashboard</field>
<field name="name">Dashboard Builder</field>
<field name="res_model">base.dashbord</field>
<field name="type">ir.actions.act_window</field>
<field name="context">{}</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first dashboard service
</p>
<p>
Configure which Odoo models appear as service cards on the employee dashboard.
</p>
</field>
</record>
<menuitem id="base_dashboard_root" parent="system_dashboard_classic_menu" name="Configrutions"
groups="system_dashboard_classic.system_board_group_configurations" sequence="-7"/>
<!-- Menu Items -->
<menuitem id="base_dashboard_root"
parent="system_dashboard_classic_menu"
name="Configuration"
groups="system_dashboard_classic.system_board_group_configurations"
sequence="-7"/>
<menuitem id="base_dashboard" parent="base_dashboard_root" name="Base Dashboard" action="base_dashboard_action"
groups="system_dashboard_classic.system_board_group_configurations" sequence="-7" />
<menuitem id="base_dashboard"
parent="base_dashboard_root"
name="Dashboard Builder"
action="base_dashboard_action"
groups="system_dashboard_classic.system_board_group_configurations"
sequence="-7"/>
</odoo>

View File

@ -0,0 +1,223 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Dashboard Color Settings - Extends res.config.settings form view -->
<record id="res_config_settings_view_form_dashboard" model="ir.ui.view">
<field name="name">res.config.settings.view.form.dashboard.colors</field>
<field name="model">res.config.settings</field>
<field name="priority">99</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('settings')]" position="inside">
<div class="app_settings_block" data-string="Dashboard" string="Dashboard" data-key="system_dashboard_classic">
<h2>Dashboard Theme Settings</h2>
<div class="row mt16 o_settings_container">
<!-- Primary Color -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x" style="color: #0891b2;"/>
<i class="fa fa-paint-brush fa-stack-1x fa-inverse"/>
</span>
</div>
<div class="o_setting_right_pane">
<label for="dashboard_primary_color" string="Primary Color"/>
<div class="text-muted">
Main accent color for buttons, links, and highlights
</div>
<div class="content-group">
<div class="row mt8">
<div class="col-12 col-md-6">
<field name="dashboard_primary_color" widget="color" placeholder="#0891b2"/>
</div>
</div>
</div>
</div>
</div>
<!-- Secondary Color -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x" style="color: #1e293b;"/>
<i class="fa fa-header fa-stack-1x fa-inverse"/>
</span>
</div>
<div class="o_setting_right_pane">
<label for="dashboard_secondary_color" string="Secondary Color"/>
<div class="text-muted">
Header background and dark elements color
</div>
<div class="content-group">
<div class="row mt8">
<div class="col-12 col-md-6">
<field name="dashboard_secondary_color" widget="color" placeholder="#1e293b"/>
</div>
</div>
</div>
</div>
</div>
<!-- Success Color -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x" style="color: #10b981;"/>
<i class="fa fa-check fa-stack-1x fa-inverse"/>
</span>
</div>
<div class="o_setting_right_pane">
<label for="dashboard_success_color" string="Success / Online Color"/>
<div class="text-muted">
Online status indicator and success actions
</div>
<div class="content-group">
<div class="row mt8">
<div class="col-12 col-md-6">
<field name="dashboard_success_color" widget="color" placeholder="#10b981"/>
</div>
</div>
</div>
</div>
</div>
<!-- Warning Color -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<span class="fa-stack fa-lg">
<i class="fa fa-circle fa-stack-2x" style="color: #f59e0b;"/>
<i class="fa fa-exclamation fa-stack-1x fa-inverse"/>
</span>
</div>
<div class="o_setting_right_pane">
<label for="dashboard_warning_color" string="Warning Color"/>
<div class="text-muted">
Remaining balance and warning indicators
</div>
<div class="content-group">
<div class="row mt8">
<div class="col-12 col-md-6">
<field name="dashboard_warning_color" widget="color" placeholder="#f59e0b"/>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Dashboard Statistics Visibility -->
<h2 class="mt32">Dashboard Statistics Visibility</h2>
<div class="row mt16 o_settings_container">
<!-- Annual Leave Toggle + Chart Type -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="dashboard_show_annual_leave" widget="boolean_toggle"/>
</div>
<div class="o_setting_right_pane">
<label for="dashboard_show_annual_leave" string="Show Annual Leave"/>
<div class="text-muted">
Display Annual Leave statistics card in the dashboard header
</div>
<div class="mt8" attrs="{'invisible': [('dashboard_show_annual_leave', '=', False)]}">
<label for="dashboard_annual_leave_chart_type" string="Chart Type"/>
<field name="dashboard_annual_leave_chart_type" class="ml8"/>
</div>
</div>
</div>
<!-- Salary Slips Toggle + Chart Type -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="dashboard_show_salary_slips" widget="boolean_toggle"/>
</div>
<div class="o_setting_right_pane">
<label for="dashboard_show_salary_slips" string="Show Salary Slips"/>
<div class="text-muted">
Display Salary Slips statistics card in the dashboard header
</div>
<div class="mt8" attrs="{'invisible': [('dashboard_show_salary_slips', '=', False)]}">
<label for="dashboard_salary_slips_chart_type" string="Chart Type"/>
<field name="dashboard_salary_slips_chart_type" class="ml8"/>
</div>
</div>
</div>
<!-- Weekly Timesheet Toggle + Chart Type -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="dashboard_show_timesheet" widget="boolean_toggle"/>
</div>
<div class="o_setting_right_pane">
<label for="dashboard_show_timesheet" string="Show Weekly Timesheet"/>
<div class="text-muted">
Display Weekly Timesheet statistics card in the dashboard header
</div>
<div class="mt8" attrs="{'invisible': [('dashboard_show_timesheet', '=', False)]}">
<label for="dashboard_timesheet_chart_type" string="Chart Type"/>
<field name="dashboard_timesheet_chart_type" class="ml8"/>
</div>
</div>
</div>
<!-- Attendance Hours Toggle + Chart Type -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="dashboard_show_attendance_hours" widget="boolean_toggle"/>
</div>
<div class="o_setting_right_pane">
<label for="dashboard_show_attendance_hours" string="Show Attendance Hours"/>
<div class="text-muted">
Display Attendance Hours statistics card in the dashboard header
</div>
<div class="mt8" attrs="{'invisible': [('dashboard_show_attendance_hours', '=', False)]}">
<label for="dashboard_attendance_hours_chart_type" string="Chart Type"/>
<field name="dashboard_attendance_hours_chart_type" class="ml8"/>
</div>
</div>
</div>
</div>
<!-- Attendance Check-in/out Section Visibility -->
<h2 class="mt32">Attendance Check-in Section</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="dashboard_show_attendance_section" widget="boolean_toggle"/>
</div>
<div class="o_setting_right_pane">
<label for="dashboard_show_attendance_section" string="Show Attendance Section"/>
<div class="text-muted">
Display the Attendance Check-in/out section on the right side of dashboard header.
When hidden, statistics cards will expand to fill the available space.
</div>
</div>
</div>
<!-- Enable Attendance Button (only enabled if Attendance Section is shown) -->
<div class="col-12 col-lg-6 o_setting_box"
attrs="{'invisible': [('dashboard_show_attendance_section', '=', False)]}">
<div class="o_setting_left_pane">
<field name="dashboard_enable_attendance_button" widget="boolean_toggle"/>
</div>
<div class="o_setting_right_pane">
<label for="dashboard_enable_attendance_button" string="Enable Check-in/out Button"/>
<div class="text-muted">
Allow employees to check-in and check-out from the dashboard.
<br/><em>Note: This setting only applies when Attendance Section is visible.</em>
</div>
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
<!-- Settings Action -->
<record id="action_dashboard_configuration" model="ir.actions.act_window">
<field name="name">General Settings</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">res.config.settings</field>
<field name="view_mode">form</field>
<field name="target">inline</field>
<field name="context">{'module' : 'system_dashboard_classic'}</field>
</record>
<!-- Settings Menu -->
<menuitem id="menu_dashboard_configuration" name="General Settings" parent="base_dashboard_root"
sequence="100" action="action_dashboard_configuration" groups="base.group_system"/>
</odoo>

View File

@ -2,28 +2,10 @@
<odoo>
<data>
<record id="approval_screen" model="ir.actions.act_window">
<field name="name">System Dashboard</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">system_dashboard_classic.dashboard</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create first Dashboard !
</p>
</field>
</record>
<!-- Client Action For Approval Screen -->
<record id="action_approval_screen" model="ir.actions.client">
<field name="name">Approval Screen</field>
<field name="res_model">system_dashboard_classic.dashboard</field>
<field name="tag">system_dashboard_classic.dashboard</field>
</record>
<!-- Default View for System Dashboard, which is extended to make Dashboard View -->
<record id="self_service_dashboard" model="ir.actions.act_window">
<field name="name">System Dashboard</field>
<field name="name">Dashboard</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">system_dashboard_classic.dashboard</field>
<field name="view_mode">tree,form</field>
@ -41,18 +23,44 @@
<field name="tag">system_dashboard_classic.dashboard_self_services</field>
</record>
<menuitem id="system_dashboard_classic_menu" name="System Dashboard" web_icon="system_dashboard_classic,static/description/icon.png" groups="base.group_user" sequence="-7" />
<menuitem id="menu_approval_screen" name="Approval Screen" parent= "system_dashboard_classic_menu" action="action_approval_screen" groups="system_dashboard_classic.system_board_group_manager" sequence="-8"/>
<menuitem id="menu_self_service_service" name="Self Service Screen" parent= "system_dashboard_classic_menu" action="action_self_service_dashboard" groups="base.group_user" sequence="-9"/>
<menuitem id="system_dashboard_classic_menu" name="Dashboard" web_icon="system_dashboard_classic,static/description/icon.png" groups="base.group_user" sequence="-7" />
<menuitem id="menu_self_service_service" name="Self Service" parent= "system_dashboard_classic_menu" action="action_self_service_dashboard" groups="base.group_user" sequence="-9"/>
<template id="assets_system_backend" name="System Dashboard Assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<!-- CRITICAL: Apply cached colors BEFORE CSS loads to prevent flash of default colors -->
<script>
(function() {
try {
var cached = localStorage.getItem('dashboard_colors');
if (cached) {
var colors = JSON.parse(cached);
var root = document.documentElement;
if (colors.primary) {
root.style.setProperty('--dash-primary', colors.primary);
root.style.setProperty('--dash-primary-light', colors.primaryLight || colors.primary);
root.style.setProperty('--dash-primary-dark', colors.primaryDark || colors.primary);
}
if (colors.secondary) {
root.style.setProperty('--dash-secondary', colors.secondary);
}
if (colors.warning) {
root.style.setProperty('--dash-warning', colors.warning);
}
if (colors.success) {
root.style.setProperty('--dash-success', colors.success);
}
}
} catch(e) {}
})();
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/MaterialDesign-Webfont/5.0.45/css/materialdesignicons.min.css"/>
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/pluscharts.scss"/>
<!--LTR-->
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/variables.scss"/>
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/core.scss"/>
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/cards.scss"/>
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/genius-enhancements.scss"/>
<!--RTL-->
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/rtl-cards.scss"/>
<link rel="stylesheet" type="text/scss" href="/system_dashboard_classic/static/src/scss/rtl-core.scss"/>
@ -60,8 +68,9 @@
<!--<script type="text/javascript" src="/system_dashboard_classic/static/src/js/canvasjs.min.js"></script>-->
<script type="text/javascript" src="/system_dashboard_classic/static/src/js/d3.v5.min.js"/>
<script type="text/javascript" src="/system_dashboard_classic/static/src/js/pluscharts.js"></script>
<script type="text/javascript" src="/system_dashboard_classic/static/src/js/system_dashboard.js"/>
<script type="text/javascript" src="/system_dashboard_classic/static/src/js/system_dashboard_self_service.js"/>
<script type="text/javascript" src="/system_dashboard_classic/static/src/lib/confetti.min.js"/>
<script type="text/javascript" src="/system_dashboard_classic/static/src/js/genius_enhancements.js"/>
<script>
$(document).ready(function () {
$('.app-drawer-toggle').click();

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
{
'name': 'Tour Genius',
'version': '14.0.3.0.0',
'category': 'Human Resources/Training',
'summary': 'Create interactive training tours and quizzes for Odoo',
'description': """
Tour Genius - Interactive Training Platform
============================================
Create interactive guides and training tours for any Odoo screen.
Features:
---------
* Interactive step-by-step tours
* Visual tour recorder (Track On/Off)
* Training plans with progress tracking
* Quizzes and assessments
* Gamification with leaderboards
* Auto-start tours for new users
Author: Expert Development Team
""",
'author': 'Expert Development Team',
'website': 'https://www.expert.sa',
'license': 'LGPL-3',
'depends': [
'base',
'mail',
'web',
'web_tour',
],
'data': [
# Security
'security/security.xml',
'security/ir.model.access.csv',
# Data (Crons)
'data/cron_data.xml',
# Assets (MUST load before views for JS to register)
'views/assets.xml',
# Menu (defines root menu)
'views/menu.xml',
# Dashboard (is the default action when clicking module)
'views/dashboard_views.xml',
# Views
'views/plan_views.xml',
'views/topic_views.xml',
'views/step_views.xml',
'views/progress_views.xml',
'views/quiz_views.xml',
'views/leaderboard_views.xml',
'views/reminder_views.xml',
# Demo/Sample Data (loads on install)
'data/demo_data.xml',
'data/demo_tours_sales.xml',
'data/demo_tours_purchase.xml',
'data/demo_tours_stock.xml',
'data/demo_tours_account.xml',
'data/demo_tours_hr.xml',
'data/demo_plans_steps.xml',
],
'demo': [],
'qweb': [
'static/src/xml/systray_template.xml',
'static/src/xml/dashboard_template.xml',
'static/src/xml/recorder_template.xml',
'static/src/xml/genius_celebration.xml',
'static/src/xml/genius_quiz_popup.xml',
'static/src/xml/genius_tip.xml',
],
'images': ['static/description/icon.png'],
'installable': True,
'application': True,
'auto_install': False,
}

View File

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import main

View File

@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""
Tour Genius Controllers
"""
from odoo import http
from odoo.http import request
import base64
class TourGeniusCertificateController(http.Controller):
"""Controller for certificate operations"""
@http.route('/tour_genius/certificate/download/<int:attempt_id>', type='http', auth='user')
def download_certificate(self, attempt_id, **kwargs):
"""
Download certificate PDF for a passed quiz attempt.
Always regenerates PDF to ensure latest design is used.
"""
attempt = request.env['genius.quiz.attempt'].browse(attempt_id)
# Security check: user can only download their own certificates
if not attempt.exists() or attempt.user_id.id != request.env.user.id:
return request.not_found()
# Check if passed
if not attempt.is_passed:
return request.not_found()
# Delete any existing attachment to force regeneration with new design
old_attachments = request.env['ir.attachment'].search([
('res_model', '=', 'genius.quiz.attempt'),
('res_id', '=', attempt.id),
('mimetype', '=', 'application/pdf'),
])
if old_attachments:
old_attachments.unlink()
# Generate new certificate with latest design
attachment_id = attempt.generate_certificate_pdf()
if not attachment_id:
return request.not_found()
attachment = request.env['ir.attachment'].browse(attachment_id)
# Return PDF file
filename = f'Certificate_{attempt.quiz_id.name}_{attempt.user_id.name}.pdf'
return request.make_response(
base64.b64decode(attachment.datas),
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', f'inline; filename={filename}')
]
)
@http.route('/tour_genius/certificate/view/<int:attempt_id>', type='http', auth='user')
def view_certificate(self, attempt_id, **kwargs):
"""
View certificate PDF inline (for preview).
"""
attempt = request.env['genius.quiz.attempt'].browse(attempt_id)
# Security check
if not attempt.exists() or attempt.user_id.id != request.env.user.id:
return request.not_found()
if not attempt.is_passed:
return request.not_found()
# Check for existing attachment
attachment = request.env['ir.attachment'].search([
('res_model', '=', 'genius.quiz.attempt'),
('res_id', '=', attempt.id),
('mimetype', '=', 'application/pdf'),
], limit=1)
if not attachment:
attachment_id = attempt.generate_certificate_pdf()
if attachment_id:
attachment = request.env['ir.attachment'].browse(attachment_id)
else:
return request.not_found()
# Return PDF for inline viewing
return request.make_response(
base64.b64decode(attachment.datas),
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', f'inline; filename="Certificate.pdf"'),
]
)

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- 1. Leaderboard Update (Hourly) -->
<record id="ir_cron_genius_leaderboard_update" model="ir.cron">
<field name="name">Tour Genius: Update Leaderboard</field>
<field name="model_id" ref="model_genius_leaderboard"/>
<field name="state">code</field>
<field name="code">model.update_leaderboard(period_type='alltime')
model.update_leaderboard(period_type='monthly')
model.update_leaderboard(period_type='weekly')</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
<!-- 2. Create Reminders (Daily) -->
<record id="ir_cron_genius_reminder_create" model="ir.cron">
<field name="name">Tour Genius: Generate Reminders</field>
<field name="model_id" ref="model_genius_reminder"/>
<field name="state">code</field>
<field name="code">model._cron_create_auto_reminders()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="nextcall" eval="(DateTime.now().replace(hour=8, minute=0, second=0)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
<!-- 3. Send Reminders (Hourly) -->
<record id="ir_cron_genius_reminder_send" model="ir.cron">
<field name="name">Tour Genius: Send Reminders</field>
<field name="model_id" ref="model_genius_reminder"/>
<field name="state">code</field>
<field name="code">model._cron_send_reminders()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- =====================================================================
DEMO TAGS
===================================================================== -->
<record id="tag_beginner" model="genius.tour.tag">
<field name="name">Beginner</field>
<field name="color">10</field>
</record>
<record id="tag_intermediate" model="genius.tour.tag">
<field name="name">Intermediate</field>
<field name="color">3</field>
</record>
<record id="tag_advanced" model="genius.tour.tag">
<field name="name">Advanced</field>
<field name="color">1</field>
</record>
<record id="tag_essential" model="genius.tour.tag">
<field name="name">Essential</field>
<field name="color">4</field>
</record>
<record id="tag_workflow" model="genius.tour.tag">
<field name="name">Workflow</field>
<field name="color">5</field>
</record>
<record id="tag_configuration" model="genius.tour.tag">
<field name="name">Configuration</field>
<field name="color">6</field>
</record>
<record id="tag_reporting" model="genius.tour.tag">
<field name="name">Reporting</field>
<field name="color">7</field>
</record>
<record id="tag_best_practice" model="genius.tour.tag">
<field name="name">Best Practice</field>
<field name="color">11</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,380 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- =====================================================================
TRAINING PLANS
Organized training paths that group tours together
===================================================================== -->
<!-- Sales Training Plan -->
<record id="plan_sales_fundamentals" model="genius.plan">
<field name="name">Sales Fundamentals</field>
<field name="description"><![CDATA[
<h3>Master Odoo Sales</h3>
<p>A comprehensive training program covering all aspects of the Sales module.</p>
<ul>
<li>Creating and managing quotations</li>
<li>Customer pricelist configuration</li>
<li>Complete sales workflow</li>
</ul>
]]></field>
<field name="state">published</field>
<field name="date_start" eval="time.strftime('%Y-%m-01')"/>
<field name="date_end" eval="time.strftime('%Y-12-31')"/>
<field name="estimated_hours">2.0</field>
<field name="target_modules">sale</field>
<field name="is_public">True</field>
</record>
<!-- Inventory Training Plan -->
<record id="plan_inventory_operations" model="genius.plan">
<field name="name">Inventory Operations</field>
<field name="description"><![CDATA[
<h3>Warehouse Management Training</h3>
<p>Learn to efficiently manage your inventory with Odoo Stock.</p>
]]></field>
<field name="state">published</field>
<field name="estimated_hours">2.5</field>
<field name="target_modules">stock</field>
<field name="is_public">True</field>
</record>
<!-- Finance Training Plan -->
<record id="plan_finance_essentials" model="genius.plan">
<field name="name">Finance Essentials</field>
<field name="description"><![CDATA[
<h3>Accounting &amp; Finance Training</h3>
<p>Master financial operations from invoicing to period closing.</p>
]]></field>
<field name="state">published</field>
<field name="estimated_hours">3.0</field>
<field name="target_modules">account</field>
<field name="is_public">True</field>
</record>
<!-- =====================================================================
TOUR STEPS - Demo steps for Sales First Quotation Tour
These are essential for the tour to actually work!
===================================================================== -->
<!-- Steps for: Creating Your First Quotation -->
<record id="step_sales_1_1" model="genius.topic.step">
<field name="topic_id" ref="tour_sales_first_quotation"/>
<field name="sequence">10</field>
<field name="title">Navigate to Quotations</field>
<field name="instruction"><![CDATA[<p>Click on <strong>Sales</strong> in the main menu to access the Sales application.</p>]]></field>
<field name="css_selector">.o_app[data-menu-xmlid='sale.sale_menu_root']</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
</record>
<record id="step_sales_1_2" model="genius.topic.step">
<field name="topic_id" ref="tour_sales_first_quotation"/>
<field name="sequence">20</field>
<field name="title">Create New Quotation</field>
<field name="instruction"><![CDATA[<p>Click <strong>Create</strong> to start a new quotation.</p>]]></field>
<field name="css_selector">.o_list_button_add</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
<field name="extra_trigger">.o_sale_order</field>
</record>
<record id="step_sales_1_3" model="genius.topic.step">
<field name="topic_id" ref="tour_sales_first_quotation"/>
<field name="sequence">30</field>
<field name="title">Select Customer</field>
<field name="instruction"><![CDATA[<p>Click on the <strong>Customer</strong> field and select a customer from the dropdown.</p>]]></field>
<field name="css_selector">.o_form_editable .o_field_many2one[name='partner_id']</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
<field name="extra_trigger">.o_sale_order</field>
</record>
<record id="step_sales_1_4" model="genius.topic.step">
<field name="topic_id" ref="tour_sales_first_quotation"/>
<field name="sequence">40</field>
<field name="title">Add Order Line</field>
<field name="instruction"><![CDATA[<p>Click <strong>Add a product</strong> to add items to your quotation.</p>]]></field>
<field name="css_selector">.o_field_x2many_list_row_add > a</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
<field name="extra_trigger">.o_field_many2one[name='partner_id'] .o_external_button</field>
</record>
<record id="step_sales_1_5" model="genius.topic.step">
<field name="topic_id" ref="tour_sales_first_quotation"/>
<field name="sequence">50</field>
<field name="title">Select Product</field>
<field name="instruction"><![CDATA[<p>Click on the <strong>Product</strong> field and select a product.</p>]]></field>
<field name="css_selector">.o_field_widget[name=product_id], .o_field_widget[name=product_template_id]</field>
<field name="step_type">click</field>
<field name="position">right</field>
<field name="extra_trigger">.o_sale_order</field>
</record>
<record id="step_sales_1_6" model="genius.topic.step">
<field name="topic_id" ref="tour_sales_first_quotation"/>
<field name="sequence">60</field>
<field name="title">Save Quotation</field>
<field name="instruction"><![CDATA[<p>Click <strong>Save</strong> to save your quotation. You can also use <kbd>Ctrl+S</kbd>.</p>]]></field>
<field name="css_selector">.o_form_button_save</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
</record>
<!-- Steps for: Receiving Products (Inventory) -->
<record id="step_stock_1_1" model="genius.topic.step">
<field name="topic_id" ref="tour_stock_receiving"/>
<field name="sequence">10</field>
<field name="title">Open Inventory App</field>
<field name="instruction"><![CDATA[<p>Click on <strong>Inventory</strong> in the main menu.</p>]]></field>
<field name="css_selector">.o_app[data-menu-xmlid='stock.menu_stock_root']</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
</record>
<record id="step_stock_1_2" model="genius.topic.step">
<field name="topic_id" ref="tour_stock_receiving"/>
<field name="sequence">20</field>
<field name="title">Go to Receipts</field>
<field name="instruction"><![CDATA[<p>Click on <strong>Receipts</strong> to view incoming shipments.</p>]]></field>
<field name="css_selector">.o_kanban_record:first .o_primary, .o_kanban_record:contains('Receipts') .oe_kanban_content</field>
<field name="step_type">click</field>
<field name="position">right</field>
<field name="extra_trigger">.o_kanban_view</field>
</record>
<record id="step_stock_1_3" model="genius.topic.step">
<field name="topic_id" ref="tour_stock_receiving"/>
<field name="sequence">30</field>
<field name="title">Select a Receipt</field>
<field name="instruction"><![CDATA[<p>Click on any pending receipt to open it.</p>]]></field>
<field name="css_selector">.o_list_view tr.o_data_row:first-child</field>
<field name="step_type">click</field>
<field name="position">right</field>
</record>
<record id="step_stock_1_4" model="genius.topic.step">
<field name="topic_id" ref="tour_stock_receiving"/>
<field name="sequence">40</field>
<field name="title">Enter Received Quantity</field>
<field name="instruction"><![CDATA[<p>Enter the quantity you actually received in the <strong>Done</strong> column.</p>]]></field>
<field name="css_selector">.o_field_widget[name='qty_done'] input</field>
<field name="step_type">input</field>
<field name="input_value">1</field>
<field name="position">left</field>
</record>
<record id="step_stock_1_5" model="genius.topic.step">
<field name="topic_id" ref="tour_stock_receiving"/>
<field name="sequence">50</field>
<field name="title">Validate Receipt</field>
<field name="instruction"><![CDATA[<p>Click <strong>Validate</strong> to confirm the receipt and update inventory.</p>]]></field>
<field name="css_selector">button[name='button_validate']</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
</record>
<!-- Steps for: Creating Customer Invoices (Accounting) -->
<record id="step_acc_1_1" model="genius.topic.step">
<field name="topic_id" ref="tour_account_invoices"/>
<field name="sequence">10</field>
<field name="title">Open Accounting App</field>
<field name="instruction"><![CDATA[<p>Click on <strong>Accounting</strong> or <strong>Invoicing</strong> in the main menu.</p>]]></field>
<field name="css_selector">.o_app[data-menu-xmlid='account.menu_finance']</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
</record>
<record id="step_acc_1_2" model="genius.topic.step">
<field name="topic_id" ref="tour_account_invoices"/>
<field name="sequence">20</field>
<field name="title">Go to Invoices</field>
<field name="instruction"><![CDATA[<p>Click on <strong>Customers → Invoices</strong> in the menu.</p>]]></field>
<field name="css_selector">.o_menu_entry_lvl_2[data-menu-xmlid='account.menu_action_move_out_invoice_type'], a[data-menu-xmlid='account.menu_action_move_out_invoice_type']</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
</record>
<record id="step_acc_1_3" model="genius.topic.step">
<field name="topic_id" ref="tour_account_invoices"/>
<field name="sequence">30</field>
<field name="title">Create New Invoice</field>
<field name="instruction"><![CDATA[<p>Click the <strong>Create</strong> button to start a new invoice.</p>]]></field>
<field name="css_selector">.o_list_button_add</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
</record>
<record id="step_acc_1_4" model="genius.topic.step">
<field name="topic_id" ref="tour_account_invoices"/>
<field name="sequence">40</field>
<field name="title">Select Customer</field>
<field name="instruction"><![CDATA[<p>Click on the <strong>Customer</strong> field and select a customer.</p>]]></field>
<field name="css_selector">div[name=partner_id]</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
<field name="extra_trigger">.o_form_editable</field>
</record>
<record id="step_acc_1_5" model="genius.topic.step">
<field name="topic_id" ref="tour_account_invoices"/>
<field name="sequence">50</field>
<field name="title">Add Invoice Line</field>
<field name="instruction"><![CDATA[<p>Click <strong>Add a line</strong> to add products or services.</p>]]></field>
<field name="css_selector">div[name=invoice_line_ids] .o_field_x2many_list_row_add a:not([data-context])</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
<field name="extra_trigger">[name=move_type][raw-value=out_invoice]</field>
</record>
<record id="step_acc_1_6" model="genius.topic.step">
<field name="topic_id" ref="tour_account_invoices"/>
<field name="sequence">60</field>
<field name="title">Confirm Invoice</field>
<field name="instruction"><![CDATA[<p>Click <strong>Confirm</strong> to post the invoice.</p>]]></field>
<field name="css_selector">button[name=action_post]</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
<field name="extra_trigger">[name=move_type][raw-value=out_invoice]</field>
</record>
<!-- Steps for: Creating a Purchase Order (Purchase) -->
<record id="step_purchase_order_1" model="genius.topic.step">
<field name="topic_id" ref="tour_purchase_order"/>
<field name="sequence">10</field>
<field name="title">Open Purchase App</field>
<field name="instruction"><![CDATA[<p>Click on <strong>Purchase</strong> in the main menu to manage orders.</p>]]></field>
<field name="css_selector">.o_app[data-menu-xmlid='purchase.menu_purchase_root']</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
</record>
<record id="step_purchase_order_2" model="genius.topic.step">
<field name="topic_id" ref="tour_purchase_order"/>
<field name="sequence">20</field>
<field name="title">Create Request for Quotation</field>
<field name="instruction"><![CDATA[<p>Click <strong>Create</strong> to start a new RFQ.</p>]]></field>
<field name="css_selector">.o_list_button_add</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
<field name="extra_trigger">.o_purchase_order</field>
</record>
<record id="step_purchase_order_3" model="genius.topic.step">
<field name="topic_id" ref="tour_purchase_order"/>
<field name="sequence">30</field>
<field name="title">Select Vendor</field>
<field name="instruction"><![CDATA[<p>Click on the <strong>Vendor</strong> field and select a vendor from the dropdown.</p>]]></field>
<field name="css_selector">.o_form_editable .o_field_many2one[name='partner_id']</field>
<field name="step_type">click</field>
<field name="position">right</field>
<field name="extra_trigger">.o_purchase_order</field>
</record>
<record id="step_purchase_order_4" model="genius.topic.step">
<field name="topic_id" ref="tour_purchase_order"/>
<field name="sequence">40</field>
<field name="title">Add Product</field>
<field name="instruction"><![CDATA[<p>Click <strong>Add a product</strong> to select items.</p>]]></field>
<field name="css_selector">.o_field_x2many_list_row_add > a</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
<field name="extra_trigger">.o_field_many2one[name='partner_id'] .o_external_button</field>
</record>
<record id="step_purchase_order_5" model="genius.topic.step">
<field name="topic_id" ref="tour_purchase_order"/>
<field name="sequence">50</field>
<field name="title">Select Product</field>
<field name="instruction"><![CDATA[<p>Click on the <strong>Product</strong> field and select a product.</p>]]></field>
<field name="css_selector">.o_field_widget[name='product_id']</field>
<field name="step_type">click</field>
<field name="position">right</field>
</record>
<record id="step_purchase_order_8" model="genius.topic.step">
<field name="topic_id" ref="tour_purchase_order"/>
<field name="sequence">80</field>
<field name="title">Save Order</field>
<field name="instruction"><![CDATA[<p>Click <strong>Save</strong> to store the RFQ.</p>]]></field>
<field name="css_selector">.o_form_button_save</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
</record>
<record id="step_purchase_order_9" model="genius.topic.step">
<field name="topic_id" ref="tour_purchase_order"/>
<field name="sequence">90</field>
<field name="title">Confirm Order</field>
<field name="instruction"><![CDATA[<p>Click <strong>Confirm Order</strong> to finalize the purchase.</p>]]></field>
<field name="css_selector">button[name='button_confirm']</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
</record>
<!-- Steps for: Employee Onboarding (HR) -->
<record id="step_hr_1_1" model="genius.topic.step">
<field name="topic_id" ref="tour_hr_onboarding"/>
<field name="sequence">10</field>
<field name="title">Open Employees App</field>
<field name="instruction"><![CDATA[<p>Click on <strong>Employees</strong> in the main menu.</p>]]></field>
<field name="css_selector">.o_app[data-menu-xmlid='hr.menu_hr_root']</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
</record>
<record id="step_hr_1_2" model="genius.topic.step">
<field name="topic_id" ref="tour_hr_onboarding"/>
<field name="sequence">20</field>
<field name="title">Create New Employee</field>
<field name="instruction"><![CDATA[<p>Click <strong>Create</strong> to add a new employee.</p>]]></field>
<field name="css_selector">.o-kanban-button-new</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
</record>
<record id="step_hr_1_3" model="genius.topic.step">
<field name="topic_id" ref="tour_hr_onboarding"/>
<field name="sequence">30</field>
<field name="title">Enter Employee Name</field>
<field name="instruction"><![CDATA[<p>Type the employee's <strong>full name</strong>.</p>]]></field>
<field name="css_selector">.o_field_widget[name='name'] input</field>
<field name="step_type">input</field>
<field name="input_value">John Smith</field>
<field name="position">right</field>
</record>
<record id="step_hr_1_4" model="genius.topic.step">
<field name="topic_id" ref="tour_hr_onboarding"/>
<field name="sequence">40</field>
<field name="title">Select Job Position</field>
<field name="instruction"><![CDATA[<p>Click on <strong>Job Position</strong> and select a role.</p>]]></field>
<field name="css_selector">.o_field_widget[name='job_id']</field>
<field name="step_type">click</field>
<field name="position">right</field>
</record>
<record id="step_hr_1_5" model="genius.topic.step">
<field name="topic_id" ref="tour_hr_onboarding"/>
<field name="sequence">50</field>
<field name="title">Select Department</field>
<field name="instruction"><![CDATA[<p>Click on <strong>Department</strong> and select one.</p>]]></field>
<field name="css_selector">.o_field_widget[name='department_id']</field>
<field name="step_type">click</field>
<field name="position">right</field>
</record>
<record id="step_hr_1_6" model="genius.topic.step">
<field name="topic_id" ref="tour_hr_onboarding"/>
<field name="sequence">60</field>
<field name="title">Save Employee</field>
<field name="instruction"><![CDATA[<p>Click <strong>Save</strong> to create the employee record.</p>]]></field>
<field name="css_selector">.o_form_button_save</field>
<field name="step_type">click</field>
<field name="position">bottom</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- =====================================================================
ACCOUNTING TOURS AND QUIZZES
Module: account
===================================================================== -->
<!-- ===================== QUIZ 1: Customer Invoices ===================== -->
<record id="quiz_account_invoices" model="genius.quiz">
<field name="name">Customer Invoices Quiz</field>
<field name="time_limit_minutes">10</field>
<field name="passing_score">70</field>
<field name="max_attempts">3</field>
<field name="shuffle_questions">True</field>
<field name="show_correct_answers">True</field>
<field name="success_message"><![CDATA[<p>🎉 Great! You understand invoicing basics!</p>]]></field>
<field name="fail_message"><![CDATA[<p>💼 Review the invoicing process and try again.</p>]]></field>
</record>
<record id="q_acc_1_1" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_account_invoices"/>
<field name="sequence">10</field>
<field name="question_text">Where do you create a new customer invoice?</field>
<field name="question_type">single</field>
<field name="points">1</field>
</record>
<record id="a_acc_1_1_a" model="genius.quiz.answer">
<field name="question_id" ref="q_acc_1_1"/>
<field name="answer_text">Accounting → Customers → Invoices</field>
<field name="is_correct">True</field>
</record>
<record id="a_acc_1_1_b" model="genius.quiz.answer">
<field name="question_id" ref="q_acc_1_1"/>
<field name="answer_text">Sales → Orders → Invoices</field>
<field name="is_correct">False</field>
</record>
<record id="a_acc_1_1_c" model="genius.quiz.answer">
<field name="question_id" ref="q_acc_1_1"/>
<field name="answer_text">CRM → Leads</field>
<field name="is_correct">False</field>
</record>
<record id="q_acc_1_2" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_account_invoices"/>
<field name="sequence">20</field>
<field name="question_text">A draft invoice can be edited before posting.</field>
<field name="question_type">single</field>
<field name="points">1</field>
</record>
<record id="a_acc_1_2_true" model="genius.quiz.answer">
<field name="question_id" ref="q_acc_1_2"/>
<field name="answer_text">True</field>
<field name="is_correct">True</field>
</record>
<record id="a_acc_1_2_false" model="genius.quiz.answer">
<field name="question_id" ref="q_acc_1_2"/>
<field name="answer_text">False</field>
<field name="is_correct">False</field>
</record>
<record id="q_acc_1_3" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_account_invoices"/>
<field name="sequence">30</field>
<field name="question_text">What are the invoice states?</field>
<field name="question_type">multiple</field>
<field name="points">2</field>
</record>
<record id="a_acc_1_3_a" model="genius.quiz.answer">
<field name="question_id" ref="q_acc_1_3"/>
<field name="answer_text">Draft</field>
<field name="is_correct">True</field>
</record>
<record id="a_acc_1_3_b" model="genius.quiz.answer">
<field name="question_id" ref="q_acc_1_3"/>
<field name="answer_text">Posted</field>
<field name="is_correct">True</field>
</record>
<record id="a_acc_1_3_c" model="genius.quiz.answer">
<field name="question_id" ref="q_acc_1_3"/>
<field name="answer_text">Cancelled</field>
<field name="is_correct">True</field>
</record>
<record id="a_acc_1_3_d" model="genius.quiz.answer">
<field name="question_id" ref="q_acc_1_3"/>
<field name="answer_text">Approved</field>
<field name="is_correct">False</field>
</record>
<record id="q_acc_1_4" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_account_invoices"/>
<field name="sequence">40</field>
<field name="question_text">What action makes an invoice official and creates journal entries?</field>
<field name="question_type">short_answer</field>
<field name="correct_short_answer">Post</field>
<field name="case_sensitive">False</field>
<field name="points">1</field>
</record>
<record id="q_acc_1_5" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_account_invoices"/>
<field name="sequence">50</field>
<field name="question_text">Credit notes are used to reverse or correct invoices.</field>
<field name="question_type">single</field>
<field name="points">1</field>
</record>
<record id="a_acc_1_5_true" model="genius.quiz.answer">
<field name="question_id" ref="q_acc_1_5"/>
<field name="answer_text">True</field>
<field name="is_correct">True</field>
</record>
<record id="a_acc_1_5_false" model="genius.quiz.answer">
<field name="question_id" ref="q_acc_1_5"/>
<field name="answer_text">False</field>
<field name="is_correct">False</field>
</record>
<!-- ===================== TOUR 1: Customer Invoices ===================== -->
<record id="tour_account_invoices" model="genius.topic">
<field name="name">Creating Customer Invoices</field>
<field name="sequence">10</field>
<field name="starting_url">/web</field>
<field name="module_xml_id">account</field>
<field name="tag_ids" eval="[(6, 0, [ref('tag_beginner'), ref('tag_essential')])]"/>
<field name="quiz_id" ref="quiz_account_invoices"/>
<field name="duration_minutes">15</field>
<field name="icon">fa-file-text</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,134 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- =====================================================================
HR TOURS AND QUIZZES
Module: hr
===================================================================== -->
<!-- ===================== QUIZ 1: Employee Onboarding ===================== -->
<record id="quiz_hr_onboarding" model="genius.quiz">
<field name="name">Employee Onboarding Quiz</field>
<field name="time_limit_minutes">10</field>
<field name="passing_score">70</field>
<field name="max_attempts">3</field>
<field name="shuffle_questions">True</field>
<field name="show_correct_answers">True</field>
<field name="success_message"><![CDATA[<p>🎉 Excellent! You're ready to onboard employees!</p>]]></field>
<field name="fail_message"><![CDATA[<p>👥 Review the HR basics and try again.</p>]]></field>
</record>
<record id="q_hr_1_1" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_hr_onboarding"/>
<field name="sequence">10</field>
<field name="question_text">Where do you create a new employee?</field>
<field name="question_type">single</field>
<field name="points">1</field>
</record>
<record id="a_hr_1_1_a" model="genius.quiz.answer">
<field name="question_id" ref="q_hr_1_1"/>
<field name="answer_text">Employees → Employees</field>
<field name="is_correct">True</field>
</record>
<record id="a_hr_1_1_b" model="genius.quiz.answer">
<field name="question_id" ref="q_hr_1_1"/>
<field name="answer_text">Settings → Users</field>
<field name="is_correct">False</field>
</record>
<record id="a_hr_1_1_c" model="genius.quiz.answer">
<field name="question_id" ref="q_hr_1_1"/>
<field name="answer_text">Contacts → Create</field>
<field name="is_correct">False</field>
</record>
<record id="q_hr_1_2" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_hr_onboarding"/>
<field name="sequence">20</field>
<field name="question_text">An employee record can be linked to a user account.</field>
<field name="question_type">single</field>
<field name="points">1</field>
</record>
<record id="a_hr_1_2_true" model="genius.quiz.answer">
<field name="question_id" ref="q_hr_1_2"/>
<field name="answer_text">True</field>
<field name="is_correct">True</field>
</record>
<record id="a_hr_1_2_false" model="genius.quiz.answer">
<field name="question_id" ref="q_hr_1_2"/>
<field name="answer_text">False</field>
<field name="is_correct">False</field>
</record>
<record id="q_hr_1_3" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_hr_onboarding"/>
<field name="sequence">30</field>
<field name="question_text">What information is typically stored on an employee record?</field>
<field name="question_type">multiple</field>
<field name="points">2</field>
</record>
<record id="a_hr_1_3_a" model="genius.quiz.answer">
<field name="question_id" ref="q_hr_1_3"/>
<field name="answer_text">Department</field>
<field name="is_correct">True</field>
</record>
<record id="a_hr_1_3_b" model="genius.quiz.answer">
<field name="question_id" ref="q_hr_1_3"/>
<field name="answer_text">Job Position</field>
<field name="is_correct">True</field>
</record>
<record id="a_hr_1_3_c" model="genius.quiz.answer">
<field name="question_id" ref="q_hr_1_3"/>
<field name="answer_text">Manager</field>
<field name="is_correct">True</field>
</record>
<record id="a_hr_1_3_d" model="genius.quiz.answer">
<field name="question_id" ref="q_hr_1_3"/>
<field name="answer_text">Product prices</field>
<field name="is_correct">False</field>
</record>
<record id="q_hr_1_4" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_hr_onboarding"/>
<field name="sequence">40</field>
<field name="question_text">What tab contains emergency contact information?</field>
<field name="question_type">short_answer</field>
<field name="correct_short_answer">Private Information</field>
<field name="case_sensitive">False</field>
<field name="points">1</field>
</record>
<record id="q_hr_1_5" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_hr_onboarding"/>
<field name="sequence">50</field>
<field name="question_text">Employees can have multiple jobs assigned.</field>
<field name="question_type">single</field>
<field name="points">1</field>
<field name="explanation"><![CDATA[<p>An employee typically has one main job position, but can be assigned to multiple departments via contracts.</p>]]></field>
</record>
<record id="a_hr_1_5_true" model="genius.quiz.answer">
<field name="question_id" ref="q_hr_1_5"/>
<field name="answer_text">True</field>
<field name="is_correct">False</field>
</record>
<record id="a_hr_1_5_false" model="genius.quiz.answer">
<field name="question_id" ref="q_hr_1_5"/>
<field name="answer_text">False</field>
<field name="is_correct">True</field>
</record>
<!-- ===================== TOUR 1: Employee Onboarding ===================== -->
<record id="tour_hr_onboarding" model="genius.topic">
<field name="name">Employee Onboarding</field>
<field name="sequence">10</field>
<field name="starting_url">/web</field>
<field name="module_xml_id">hr</field>
<field name="tag_ids" eval="[(6, 0, [ref('tag_beginner'), ref('tag_essential')])]"/>
<field name="quiz_id" ref="quiz_hr_onboarding"/>
<field name="duration_minutes">15</field>
<field name="icon">fa-users</field>
<!-- Auto-start for new users landing on HR dashboard -->
</record>
</data>
</odoo>

View File

@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- =====================================================================
PURCHASE TOURS AND QUIZZES
Module: purchase
===================================================================== -->
<!-- ===================== QUIZ 1: Purchase Order ===================== -->
<record id="quiz_purchase_order" model="genius.quiz">
<field name="name">Purchase Order Basics Quiz</field>
<field name="time_limit_minutes">10</field>
<field name="passing_score">70</field>
<field name="max_attempts">3</field>
<field name="shuffle_questions">True</field>
<field name="show_correct_answers">True</field>
<field name="success_message"><![CDATA[<p>🎉 Great job! You understand purchase order basics!</p>]]></field>
<field name="fail_message"><![CDATA[<p>📚 Review the material and try again.</p>]]></field>
</record>
<record id="q_pur_1_1" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_purchase_order"/>
<field name="sequence">10</field>
<field name="question_text">Where do you create a new Purchase Order?</field>
<field name="question_type">single</field>
<field name="points">1</field>
</record>
<record id="a_pur_1_1_a" model="genius.quiz.answer">
<field name="question_id" ref="q_pur_1_1"/>
<field name="answer_text">Purchase → Orders → Purchase Orders</field>
<field name="is_correct">True</field>
</record>
<record id="a_pur_1_1_b" model="genius.quiz.answer">
<field name="question_id" ref="q_pur_1_1"/>
<field name="answer_text">Sales → Orders</field>
<field name="is_correct">False</field>
</record>
<record id="a_pur_1_1_c" model="genius.quiz.answer">
<field name="question_id" ref="q_pur_1_1"/>
<field name="answer_text">Inventory → Operations</field>
<field name="is_correct">False</field>
</record>
<record id="q_pur_1_2" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_purchase_order"/>
<field name="sequence">20</field>
<field name="question_text">A Request for Quotation (RFQ) becomes a Purchase Order when confirmed.</field>
<field name="question_type">single</field>
<field name="points">1</field>
</record>
<record id="a_pur_1_2_true" model="genius.quiz.answer">
<field name="question_id" ref="q_pur_1_2"/>
<field name="answer_text">True</field>
<field name="is_correct">True</field>
</record>
<record id="a_pur_1_2_false" model="genius.quiz.answer">
<field name="question_id" ref="q_pur_1_2"/>
<field name="answer_text">False</field>
<field name="is_correct">False</field>
</record>
<record id="q_pur_1_3" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_purchase_order"/>
<field name="sequence">30</field>
<field name="question_text">Which fields are required when creating a purchase order?</field>
<field name="question_type">multiple</field>
<field name="points">2</field>
</record>
<record id="a_pur_1_3_a" model="genius.quiz.answer">
<field name="question_id" ref="q_pur_1_3"/>
<field name="answer_text">Vendor</field>
<field name="is_correct">True</field>
</record>
<record id="a_pur_1_3_b" model="genius.quiz.answer">
<field name="question_id" ref="q_pur_1_3"/>
<field name="answer_text">Product lines</field>
<field name="is_correct">True</field>
</record>
<record id="a_pur_1_3_c" model="genius.quiz.answer">
<field name="question_id" ref="q_pur_1_3"/>
<field name="answer_text">Delivery address</field>
<field name="is_correct">False</field>
</record>
<record id="q_pur_1_4" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_purchase_order"/>
<field name="sequence">40</field>
<field name="question_text">What is the abbreviation for Request for Quotation?</field>
<field name="question_type">short_answer</field>
<field name="correct_short_answer">RFQ</field>
<field name="case_sensitive">False</field>
<field name="points">1</field>
</record>
<record id="q_pur_1_5" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_purchase_order"/>
<field name="sequence">50</field>
<field name="question_text">Receiving products creates inventory movements automatically.</field>
<field name="question_type">single</field>
<field name="points">1</field>
</record>
<record id="a_pur_1_5_true" model="genius.quiz.answer">
<field name="question_id" ref="q_pur_1_5"/>
<field name="answer_text">True</field>
<field name="is_correct">True</field>
</record>
<record id="a_pur_1_5_false" model="genius.quiz.answer">
<field name="question_id" ref="q_pur_1_5"/>
<field name="answer_text">False</field>
<field name="is_correct">False</field>
</record>
<!-- ===================== TOUR 1: Purchase Order Basics ===================== -->
<record id="tour_purchase_order" model="genius.topic">
<field name="name">Creating a Purchase Order</field>
<field name="sequence">10</field>
<field name="starting_url">/web</field>
<field name="module_xml_id">purchase</field>
<field name="tag_ids" eval="[(6, 0, [ref('tag_beginner'), ref('tag_essential')])]"/>
<field name="quiz_id" ref="quiz_purchase_order"/>
<field name="duration_minutes">15</field>
<field name="icon">fa-shopping-cart</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,150 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- =====================================================================
SALES TOURS AND QUIZZES
Module: sale
===================================================================== -->
<!-- ===================== QUIZ 1: First Quotation ===================== -->
<record id="quiz_sales_first_quotation" model="genius.quiz">
<field name="name">First Quotation Quiz</field>
<field name="description"><![CDATA[
<p>Test your knowledge on creating quotations in Odoo Sales.</p>
]]></field>
<field name="time_limit_minutes">10</field>
<field name="passing_score">70</field>
<field name="max_attempts">3</field>
<field name="shuffle_questions">True</field>
<field name="show_correct_answers">True</field>
<field name="success_message"><![CDATA[
<p>🎉 <strong>Congratulations!</strong> You've mastered the basics of creating quotations!</p>
]]></field>
<field name="fail_message"><![CDATA[
<p>📚 Keep practicing! Review the tour and try again.</p>
]]></field>
</record>
<!-- Quiz 1 Questions -->
<record id="q_sales_1_1" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_sales_first_quotation"/>
<field name="sequence">10</field>
<field name="question_text">Where do you create a new quotation in Odoo?</field>
<field name="question_type">single</field>
<field name="points">1</field>
<field name="explanation"><![CDATA[<p>Quotations are created from Sales → Orders → Quotations menu.</p>]]></field>
</record>
<record id="a_sales_1_1_a" model="genius.quiz.answer">
<field name="question_id" ref="q_sales_1_1"/>
<field name="answer_text">Sales → Orders → Quotations</field>
<field name="is_correct">True</field>
<field name="feedback">Correct! This is the main location for managing quotations.</field>
</record>
<record id="a_sales_1_1_b" model="genius.quiz.answer">
<field name="question_id" ref="q_sales_1_1"/>
<field name="answer_text">Inventory → Operations</field>
<field name="is_correct">False</field>
</record>
<record id="a_sales_1_1_c" model="genius.quiz.answer">
<field name="question_id" ref="q_sales_1_1"/>
<field name="answer_text">Accounting → Invoices</field>
<field name="is_correct">False</field>
</record>
<record id="q_sales_1_2" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_sales_first_quotation"/>
<field name="sequence">20</field>
<field name="question_text">A quotation becomes a Sales Order when confirmed.</field>
<field name="question_type">single</field>
<field name="points">1</field>
<field name="explanation"><![CDATA[<p>When you confirm a quotation, it automatically converts to a Sales Order.</p>]]></field>
</record>
<record id="a_sales_1_2_true" model="genius.quiz.answer">
<field name="question_id" ref="q_sales_1_2"/>
<field name="answer_text">True</field>
<field name="is_correct">True</field>
</record>
<record id="a_sales_1_2_false" model="genius.quiz.answer">
<field name="question_id" ref="q_sales_1_2"/>
<field name="answer_text">False</field>
<field name="is_correct">False</field>
</record>
<record id="q_sales_1_3" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_sales_first_quotation"/>
<field name="sequence">30</field>
<field name="question_text">Which fields are mandatory when creating a quotation?</field>
<field name="question_type">multiple</field>
<field name="points">2</field>
<field name="explanation"><![CDATA[<p>Customer is required, and at least one order line with a product.</p>]]></field>
</record>
<record id="a_sales_1_3_a" model="genius.quiz.answer">
<field name="question_id" ref="q_sales_1_3"/>
<field name="answer_text">Customer</field>
<field name="is_correct">True</field>
</record>
<record id="a_sales_1_3_b" model="genius.quiz.answer">
<field name="question_id" ref="q_sales_1_3"/>
<field name="answer_text">Product in order lines</field>
<field name="is_correct">True</field>
</record>
<record id="a_sales_1_3_c" model="genius.quiz.answer">
<field name="question_id" ref="q_sales_1_3"/>
<field name="answer_text">Payment Terms</field>
<field name="is_correct">False</field>
</record>
<record id="a_sales_1_3_d" model="genius.quiz.answer">
<field name="question_id" ref="q_sales_1_3"/>
<field name="answer_text">Fiscal Position</field>
<field name="is_correct">False</field>
</record>
<record id="q_sales_1_4" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_sales_first_quotation"/>
<field name="sequence">40</field>
<field name="question_text">What is the keyboard shortcut to save a record in Odoo?</field>
<field name="question_type">short_answer</field>
<field name="correct_short_answer">Ctrl+S</field>
<field name="case_sensitive">False</field>
<field name="points">1</field>
</record>
<record id="q_sales_1_5" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_sales_first_quotation"/>
<field name="sequence">50</field>
<field name="question_text">What happens to the quotation expiration date if left empty?</field>
<field name="question_type">single</field>
<field name="points">1</field>
</record>
<record id="a_sales_1_5_a" model="genius.quiz.answer">
<field name="question_id" ref="q_sales_1_5"/>
<field name="answer_text">The quotation never expires</field>
<field name="is_correct">True</field>
</record>
<record id="a_sales_1_5_b" model="genius.quiz.answer">
<field name="question_id" ref="q_sales_1_5"/>
<field name="answer_text">It expires in 30 days</field>
<field name="is_correct">False</field>
</record>
<record id="a_sales_1_5_c" model="genius.quiz.answer">
<field name="question_id" ref="q_sales_1_5"/>
<field name="answer_text">The system shows an error</field>
<field name="is_correct">False</field>
</record>
<!-- ===================== TOUR 1: First Quotation ===================== -->
<record id="tour_sales_first_quotation" model="genius.topic">
<field name="name">Creating Your First Quotation</field>
<field name="sequence">10</field>
<field name="starting_url">/web</field>
<field name="module_xml_id">sale</field>
<field name="tag_ids" eval="[(6, 0, [ref('tag_beginner'), ref('tag_essential')])]"/>
<field name="quiz_id" ref="quiz_sales_first_quotation"/>
<field name="duration_minutes">10</field>
<field name="icon">fa-file-text-o</field>
<field name="video_url">https://www.youtube.com/watch?v=dQw4w9WgXcQ</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- =====================================================================
INVENTORY TOURS AND QUIZZES
Module: stock
===================================================================== -->
<!-- ===================== QUIZ 1: Receiving Products ===================== -->
<record id="quiz_stock_receiving" model="genius.quiz">
<field name="name">Receiving Products Quiz</field>
<field name="time_limit_minutes">10</field>
<field name="passing_score">70</field>
<field name="max_attempts">3</field>
<field name="shuffle_questions">True</field>
<field name="show_correct_answers">True</field>
<field name="success_message"><![CDATA[<p>🎉 Excellent! You've mastered product receiving!</p>]]></field>
<field name="fail_message"><![CDATA[<p>📦 Practice more with inventory operations.</p>]]></field>
</record>
<record id="q_stock_1_1" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_stock_receiving"/>
<field name="sequence">10</field>
<field name="question_text">Where do you find incoming shipments to receive?</field>
<field name="question_type">single</field>
<field name="points">1</field>
</record>
<record id="a_stock_1_1_a" model="genius.quiz.answer">
<field name="question_id" ref="q_stock_1_1"/>
<field name="answer_text">Inventory → Operations → Receipts</field>
<field name="is_correct">True</field>
</record>
<record id="a_stock_1_1_b" model="genius.quiz.answer">
<field name="question_id" ref="q_stock_1_1"/>
<field name="answer_text">Purchase → Order Lines</field>
<field name="is_correct">False</field>
</record>
<record id="a_stock_1_1_c" model="genius.quiz.answer">
<field name="question_id" ref="q_stock_1_1"/>
<field name="answer_text">Sales → Deliveries</field>
<field name="is_correct">False</field>
</record>
<record id="q_stock_1_2" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_stock_receiving"/>
<field name="sequence">20</field>
<field name="question_text">Validating a receipt increases the stock quantity automatically.</field>
<field name="question_type">single</field>
<field name="points">1</field>
</record>
<record id="a_stock_1_2_true" model="genius.quiz.answer">
<field name="question_id" ref="q_stock_1_2"/>
<field name="answer_text">True</field>
<field name="is_correct">True</field>
</record>
<record id="a_stock_1_2_false" model="genius.quiz.answer">
<field name="question_id" ref="q_stock_1_2"/>
<field name="answer_text">False</field>
<field name="is_correct">False</field>
</record>
<record id="q_stock_1_3" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_stock_receiving"/>
<field name="sequence">30</field>
<field name="question_text">What can you do if you receive fewer items than expected?</field>
<field name="question_type">multiple</field>
<field name="points">2</field>
</record>
<record id="a_stock_1_3_a" model="genius.quiz.answer">
<field name="question_id" ref="q_stock_1_3"/>
<field name="answer_text">Update the "Done" quantity</field>
<field name="is_correct">True</field>
</record>
<record id="a_stock_1_3_b" model="genius.quiz.answer">
<field name="question_id" ref="q_stock_1_3"/>
<field name="answer_text">Create a backorder for remaining quantity</field>
<field name="is_correct">True</field>
</record>
<record id="a_stock_1_3_c" model="genius.quiz.answer">
<field name="question_id" ref="q_stock_1_3"/>
<field name="answer_text">Cancel and recreate the order</field>
<field name="is_correct">False</field>
</record>
<record id="q_stock_1_4" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_stock_receiving"/>
<field name="sequence">40</field>
<field name="question_text">Barcode scanning can speed up receiving operations.</field>
<field name="question_type">single</field>
<field name="points">1</field>
</record>
<record id="a_stock_1_4_true" model="genius.quiz.answer">
<field name="question_id" ref="q_stock_1_4"/>
<field name="answer_text">True</field>
<field name="is_correct">True</field>
</record>
<record id="a_stock_1_4_false" model="genius.quiz.answer">
<field name="question_id" ref="q_stock_1_4"/>
<field name="answer_text">False</field>
<field name="is_correct">False</field>
</record>
<record id="q_stock_1_5" model="genius.quiz.question">
<field name="quiz_id" ref="quiz_stock_receiving"/>
<field name="sequence">50</field>
<field name="question_text">What is the default destination location for receipts?</field>
<field name="question_type">short_answer</field>
<field name="correct_short_answer">WH/Stock</field>
<field name="case_sensitive">False</field>
<field name="points">1</field>
</record>
<!-- ===================== TOUR 1: Receiving Products ===================== -->
<record id="tour_stock_receiving" model="genius.topic">
<field name="name">Receiving Products</field>
<field name="sequence">10</field>
<field name="starting_url">/web</field>
<field name="module_xml_id">stock</field>
<field name="tag_ids" eval="[(6, 0, [ref('tag_beginner'), ref('tag_essential')])]"/>
<field name="quiz_id" ref="quiz_stock_receiving"/>
<field name="duration_minutes">15</field>
<field name="icon">fa-truck</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""
Tour Genius Models - Flat Structure (Odoo Standard)
====================================================
This module follows Odoo standard flat structure.
No subfolders in models/ - all .py files at root level.
"""
# Mixins (load first)
from . import tour_mixin
# Core Models
from . import tour
from . import tour_step
from . import tour_plan
from . import tour_progress
from . import tour_tag
# Quiz (simplified)
from . import quiz
# Gamification
from . import leaderboard
# Reminders
from . import reminder
# User Extension
from . import res_users

View File

@ -0,0 +1,339 @@
# -*- coding: utf-8 -*-
"""
Leaderboard Model.
Gamification feature to rank users by training achievements.
"""
from odoo import models, fields, api
from datetime import datetime, timedelta
class GeniusLeaderboard(models.Model):
_name = 'genius.leaderboard'
_description = 'Training Leaderboard'
_order = 'points desc, topics_completed desc'
user_id = fields.Many2one(
'res.users',
string='User',
required=True,
ondelete='cascade',
index=True
)
# Period
period_type = fields.Selection([
('weekly', 'Weekly'),
('monthly', 'Monthly'),
('alltime', 'All Time'),
], string='Period', required=True, default='alltime', index=True)
period_start = fields.Date(
string='Period Start',
help='Start date of this period'
)
period_end = fields.Date(
string='Period End',
help='End date of this period'
)
# Ranking
rank = fields.Integer(
string='Rank',
help='User rank in this period'
)
# Metrics
points = fields.Integer(
string='Points',
default=0,
help='Total points earned'
)
topics_completed = fields.Integer(
string='Topics Completed',
default=0
)
quizzes_passed = fields.Integer(
string='Quizzes Passed',
default=0
)
time_spent_hours = fields.Float(
string='Time Spent (hours)',
default=0
)
streak_days = fields.Integer(
string='Streak (days)',
default=0,
help='Consecutive days of training'
)
# Badges/Achievements
badge_count = fields.Integer(
string='Badges',
default=0
)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company
)
_sql_constraints = [
('unique_user_period', 'UNIQUE(user_id, period_type, period_start, company_id)',
'Leaderboard entry already exists for this user and period!')
]
# -------------------------------------------------------------------------
# Points System
# -------------------------------------------------------------------------
POINTS = {
'topic_completed': 10,
'quiz_passed': 25,
'quiz_perfect': 50, # 100% score
'streak_7_days': 30,
'streak_30_days': 100,
'first_topic': 5,
}
# -------------------------------------------------------------------------
# Compute/Update Methods
# -------------------------------------------------------------------------
@api.model
def calculate_user_stats(self, user_id, period_type='alltime', company_id=None):
"""Calculate stats for a user in a period"""
company_id = company_id or self.env.company.id
user = self.env['res.users'].browse(user_id)
# Determine date range
today = fields.Date.today()
period_start = False
period_end = False
if period_type == 'weekly':
period_start = today - timedelta(days=today.weekday())
period_end = period_start + timedelta(days=6)
elif period_type == 'monthly':
period_start = today.replace(day=1)
next_month = today.replace(day=28) + timedelta(days=4)
period_end = next_month - timedelta(days=next_month.day)
# Build progress domain
domain = [
('user_id', '=', user_id),
('state', 'in', ['done', 'verified']),
]
if period_start:
domain.append(('date_completed', '>=', period_start))
if period_end:
domain.append(('date_completed', '<=', period_end))
progress_records = self.env['genius.progress'].search(domain)
# Calculate metrics
topics_completed = len(progress_records)
# Quiz stats
quiz_domain = [('user_id', '=', user_id), ('is_passed', '=', True)]
if period_start:
quiz_domain.append(('submitted_at', '>=', period_start))
if period_end:
quiz_domain.append(('submitted_at', '<=', period_end))
quiz_attempts = self.env['genius.quiz.attempt'].search(quiz_domain)
quizzes_passed = len(quiz_attempts)
# Use integer comparison for robustness (Float 100.0 vs 100 issue)
perfect_quizzes = len(quiz_attempts.filtered(lambda q: q.points_possible > 0 and q.points_earned == q.points_possible))
# Time spent
time_spent = sum(progress_records.mapped('time_spent_minutes')) / 60.0
# Calculate points
points = (
topics_completed * self.POINTS['topic_completed'] +
quizzes_passed * self.POINTS['quiz_passed'] +
perfect_quizzes * self.POINTS['quiz_perfect']
)
# First topic bonus
if topics_completed > 0:
first_progress = self.env['genius.progress'].search([
('user_id', '=', user_id),
], order='date_completed', limit=1)
if first_progress and period_start and first_progress.date_completed:
if first_progress.date_completed.date() >= period_start:
points += self.POINTS['first_topic']
return {
'points': points,
'topics_completed': topics_completed,
'quizzes_passed': quizzes_passed,
'time_spent_hours': round(time_spent, 2),
'period_start': period_start,
'period_end': period_end,
}
@api.model
def update_leaderboard(self, period_type='alltime', company_id=None):
"""
Update leaderboard rankings for the given period.
Optimized with read_group for scalability.
"""
company_id = company_id or self.env.company.id
today = fields.Date.today()
# 1. Determine Date Range
period_start = False
period_end = False
if period_type == 'weekly':
# Start of week (Monday)
period_start = today - timedelta(days=today.weekday())
period_end = period_start + timedelta(days=6)
elif period_type == 'monthly':
period_start = today.replace(day=1)
# End of month
next_month = today.replace(day=28) + timedelta(days=4)
period_end = next_month - timedelta(days=next_month.day)
# 2. Build Domains
# Base domain: Completed topics for this company
# We assume GeniusProgress.user_id.company_id match? usually company_id isn't on progress.
# But user_id is. Filter users by company?
# For simplicity, we filter progress by date. User company check can be done after.
base_domain = [('state', 'in', ['done', 'verified'])]
if period_start:
base_domain.append(('date_completed', '>=', period_start))
if period_end:
base_domain.append(('date_completed', '<=', period_end))
# 3. Batch Aggregation (Main Stats)
# fields: points_earned (sum), time_spent_seconds (sum), count (topics)
main_stats = self.env['genius.progress'].read_group(
base_domain,
['user_id', 'points_earned:sum', 'time_spent_seconds:sum'],
['user_id']
)
# 4. Batch Aggregation (Quiz Pass Count)
# Need separate query for "how many quizzes passed"
quiz_domain = base_domain + [('is_quiz_passed', '=', True)]
quiz_stats = self.env['genius.progress'].read_group(
quiz_domain,
['user_id'],
['user_id']
)
quiz_counts = {r['user_id'][0]: r['user_id_count'] for r in quiz_stats}
# 5. Process Users
entries_to_create = []
entries_to_update = {} # ID -> Vals
# Prepare existing entries map to update efficiently
existing_entries = self.search([
('period_type', '=', period_type),
('period_start', '=', period_start),
('company_id', '=', company_id)
])
existing_map = {e.user_id.id: e for e in existing_entries}
entries = [] # For ranking
for group in main_stats:
user_id = group['user_id'][0]
# Skip if user not in company?
# user = self.env['res.users'].browse(user_id) # Costly
# We skip strict company check for speed, assumming visibility rules handle it.
completed_count = group['user_id_count']
points = group['points_earned']
time_seconds = group['time_spent_seconds']
quizzes = quiz_counts.get(user_id, 0)
# Add First Topic Bonus (Legacy support, optional, skipping for batch speed)
# If strictly required, query here.
vals = {
'points': points,
'topics_completed': completed_count,
'quizzes_passed': quizzes,
'time_spent_hours': time_seconds / 3600.0,
'period_start': period_start,
'period_end': period_end,
'user_id': user_id,
'period_type': period_type,
'company_id': company_id,
}
existing = existing_map.get(user_id)
if existing:
existing.write(vals)
entries.append(existing)
else:
entries_to_create.append(vals)
# Bulk Create
if entries_to_create:
created = self.create(entries_to_create)
entries.extend(created)
# 6. Rank Update
sorted_entries = sorted(entries, key=lambda e: (-e.points, -e.topics_completed))
for rank, entry in enumerate(sorted_entries, 1):
entry.rank = rank
return True
# -------------------------------------------------------------------------
# API Methods
# -------------------------------------------------------------------------
@api.model
def get_leaderboard(self, period_type='alltime', limit=10):
"""Get leaderboard for display"""
entries = self.search([
('period_type', '=', period_type),
('company_id', '=', self.env.company.id),
], order='rank', limit=limit)
return [{
'rank': e.rank,
'user_id': e.user_id.id,
'user_name': e.user_id.name,
'user_image': e.user_id.image_128,
'points': e.points,
'topics_completed': e.topics_completed,
'quizzes_passed': e.quizzes_passed,
'time_spent_hours': e.time_spent_hours,
} for e in entries]
@api.model
def get_user_rank(self, user_id=None, period_type='alltime'):
"""Get rank for a specific user"""
user_id = user_id or self.env.user.id
entry = self.search([
('user_id', '=', user_id),
('period_type', '=', period_type),
('company_id', '=', self.env.company.id),
], limit=1)
if not entry:
return {
'rank': None,
'points': 0,
'message': 'Not ranked yet',
}
total_entries = self.search_count([
('period_type', '=', period_type),
('company_id', '=', self.env.company.id),
])
return {
'rank': entry.rank,
'points': entry.points,
'total_users': total_entries,
'percentile': round((1 - (entry.rank / total_entries)) * 100, 1) if total_entries else 0,
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,278 @@
# -*- coding: utf-8 -*-
"""
Reminder Model.
Automated reminders for incomplete training.
"""
from odoo import models, fields, api, _
from datetime import timedelta
import logging
_logger = logging.getLogger(__name__)
class GeniusReminder(models.Model):
_name = 'genius.reminder'
_description = 'Training Reminder'
_order = 'scheduled_date'
_inherit = ['mail.thread']
name = fields.Char(
string='Subject',
required=True,
default=_("Training Reminder")
)
user_id = fields.Many2one(
'res.users',
string='User',
required=True,
ondelete='cascade',
index=True
)
# What to remind about
reminder_type = fields.Selection([
('incomplete_topic', 'Incomplete Topic'),
('incomplete_plan', 'Incomplete Plan'),
('new_topic', 'New Topic Available'),
('quiz_due', 'Quiz Due'),
('streak', 'Keep Your Streak'),
('custom', 'Custom Message'),
], string='Type', required=True, default='incomplete_topic')
topic_id = fields.Many2one(
'genius.topic',
string='Topic',
help='Related topic (if applicable)'
)
plan_id = fields.Many2one(
'genius.plan',
string='Plan',
help='Related plan (if applicable)'
)
# Scheduling
scheduled_date = fields.Datetime(
string='Scheduled For',
required=True,
default=lambda self: fields.Datetime.now() + timedelta(days=1)
)
# Status
state = fields.Selection([
('pending', 'Pending'),
('sent', 'Sent'),
('cancelled', 'Cancelled'),
('failed', 'Failed'),
], string='Status', default='pending', required=True, index=True)
sent_date = fields.Datetime(string='Sent At')
# Message Content
message_body = fields.Html(
string='Message',
compute='_compute_message_body',
store=True
)
custom_message = fields.Html(
string='Custom Message',
help='Custom message for custom reminder type'
)
# Repeat Settings
is_recurring = fields.Boolean(
string='Recurring',
default=False
)
recurrence_interval = fields.Integer(
string='Repeat Every (days)',
default=7
)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company
)
# -------------------------------------------------------------------------
# Computed Fields
# -------------------------------------------------------------------------
@api.depends('reminder_type', 'topic_id', 'plan_id', 'user_id', 'custom_message')
def _compute_message_body(self):
for rec in self:
if rec.reminder_type == 'custom' and rec.custom_message:
rec.message_body = rec.custom_message
elif rec.reminder_type == 'incomplete_topic' and rec.topic_id:
rec.message_body = _("""
<p>Hi %(user)s,</p>
<p>You haven't completed the training topic: <strong>%(topic)s</strong></p>
<p>Continue your learning journey today!</p>
""") % {
'user': rec.user_id.name,
'topic': rec.topic_id.name,
}
elif rec.reminder_type == 'incomplete_plan' and rec.plan_id:
rec.message_body = _("""
<p>Hi %(user)s,</p>
<p>You have an incomplete training plan: <strong>%(plan)s</strong></p>
<p>Keep up your progress!</p>
""") % {
'user': rec.user_id.name,
'plan': rec.plan_id.name,
}
elif rec.reminder_type == 'streak':
rec.message_body = _("""
<p>Hi %(user)s,</p>
<p>Don't break your learning streak!</p>
<p>Complete a quick topic today to keep your momentum.</p>
""") % {'user': rec.user_id.name}
elif rec.reminder_type == 'new_topic' and rec.topic_id:
rec.message_body = _("""
<p>Hi %(user)s,</p>
<p>A new training topic is available: <strong>%(topic)s</strong></p>
<p>Check it out!</p>
""") % {
'user': rec.user_id.name,
'topic': rec.topic_id.name,
}
elif rec.reminder_type == 'quiz_due' and rec.topic_id:
rec.message_body = _("""
<p>Hi %(user)s,</p>
<p>You have completed the tour <strong>%(topic)s</strong>, but haven't passed the quiz yet.</p>
<p>Take the quiz now to get Certified!</p>
""") % {
'user': rec.user_id.name,
'topic': rec.topic_id.name,
}
else:
rec.message_body = _("<p>You have a training reminder.</p>")
# -------------------------------------------------------------------------
# Actions
# -------------------------------------------------------------------------
def action_send(self):
"""Send the reminder now"""
for rec in self:
rec._send_reminder()
def action_cancel(self):
"""Cancel the reminder"""
self.write({'state': 'cancelled'})
def _send_reminder(self):
"""Actually send the reminder email"""
self.ensure_one()
# Validate email before attempting to send
if not self.user_id.email:
_logger.warning("Cannot send reminder %s: user %s has no email",
self.id, self.user_id.name)
self.write({'state': 'failed'})
return False
try:
template = self.env.ref('tour_genius.email_template_training_reminder', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=True)
else:
# Fallback: send simple email
self.env['mail.mail'].create({
'subject': self.name,
'body_html': self.message_body,
'email_to': self.user_id.email,
'auto_delete': True,
}).send()
self.write({
'state': 'sent',
'sent_date': fields.Datetime.now(),
})
# Handle recurrence
if self.is_recurring:
self.copy({
'scheduled_date': fields.Datetime.now() + timedelta(days=self.recurrence_interval),
'state': 'pending',
'sent_date': False,
})
except Exception as e:
self.write({'state': 'failed'})
raise
# -------------------------------------------------------------------------
# Scheduled Actions
# -------------------------------------------------------------------------
@api.model
def _cron_send_reminders(self):
"""Cron job to send pending reminders"""
pending = self.search([
('state', '=', 'pending'),
('scheduled_date', '<=', fields.Datetime.now()),
])
for reminder in pending:
try:
reminder._send_reminder()
except Exception as e:
_logger.exception("Failed to send reminder %s: %s", reminder.id, str(e))
# State already marked as 'failed' in _send_reminder
@api.model
def _cron_create_auto_reminders(self):
"""Create automatic reminders for users with incomplete training or pending quizzes"""
# 1. Stale Progress (Incomplete > 3 days)
three_days_ago = fields.Datetime.now() - timedelta(days=3)
stale_progress = self.env['genius.progress'].search([
('state', '=', 'in_progress'),
('date_started', '<=', three_days_ago),
])
for progress in stale_progress:
# Check if reminder already exists
existing = self.search([
('user_id', '=', progress.user_id.id),
('topic_id', '=', progress.topic_id.id),
('reminder_type', '=', 'incomplete_topic'),
('state', '=', 'pending'),
], limit=1)
if not existing:
self.create({
'name': _("Continue your training: %s") % progress.topic_id.name,
'user_id': progress.user_id.id,
'reminder_type': 'incomplete_topic',
'topic_id': progress.topic_id.id,
'plan_id': progress.plan_id.id if progress.plan_id else False,
'scheduled_date': fields.Datetime.now() + timedelta(hours=24),
})
# 2. Quiz Reminders (Completed > 1 day but Not Verified, and Quiz exists)
one_day_ago = fields.Datetime.now() - timedelta(days=1)
unverified_done = self.env['genius.progress'].search([
('state', '=', 'done'),
('date_completed', '<=', one_day_ago),
])
for progress in unverified_done:
# Check if Topic has a Quiz
if progress.topic_id.quiz_id:
# Check if already reminded
existing = self.search([
('user_id', '=', progress.user_id.id),
('topic_id', '=', progress.topic_id.id),
('reminder_type', '=', 'quiz_due'),
('state', 'in', ['pending', 'sent']), # Don't spam if sent
], limit=1)
if not existing:
self.create({
'name': _("Get Certified: %s") % progress.topic_id.name,
'user_id': progress.user_id.id,
'reminder_type': 'quiz_due',
'topic_id': progress.topic_id.id,
'scheduled_date': fields.Datetime.now() + timedelta(hours=24),
})

View File

@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class ResUsers(models.Model):
"""
Extension of res.users to add accessible modules helper.
"""
_inherit = 'res.users'
def _get_accessible_modules(self):
"""
Get list of module names this user can access.
Called from record rules for dynamic domain filtering.
:return: list of module technical names
"""
self.ensure_one()
return self.env['genius.module.access.mixin']._get_user_accessible_modules(self)
# Training progress summary
# NOTE: These are non-stored computed fields that query genius.progress
# on every access. They have no dependencies because they read from
# a different model (genius.progress), not fields on res.users.
genius_completed_topics = fields.Integer(
string='Completed Topics',
compute='_compute_genius_stats',
help='Number of training topics completed by this user'
)
genius_in_progress_topics = fields.Integer(
string='In Progress Topics',
compute='_compute_genius_stats',
help='Number of training topics currently in progress'
)
genius_total_time_hours = fields.Float(
string='Total Training Time (Hours)',
compute='_compute_genius_stats',
help='Total time spent on training'
)
@api.depends() # No dependencies: always recomputes when accessed (non-stored)
def _compute_genius_stats(self):
"""
Compute training statistics for users.
Note: Uses @api.depends() with no arguments because these stats
depend on genius.progress records, not res.users fields. Since the
fields are non-stored, they recompute fresh on every read.
"""
Progress = self.env['genius.progress'].sudo()
for user in self:
completed = Progress.search_count([
('user_id', '=', user.id),
('state', 'in', ('done', 'verified')),
])
in_progress = Progress.search_count([
('user_id', '=', user.id),
('state', '=', 'in_progress'),
])
total_time = sum(Progress.search([
('user_id', '=', user.id),
]).mapped('time_spent_minutes'))
user.genius_completed_topics = completed
user.genius_in_progress_topics = in_progress
user.genius_total_time_hours = total_time / 60.0 if total_time else 0.0

View File

@ -0,0 +1,949 @@
# -*- coding: utf-8 -*-
"""
Tour Model (Simplified)
=======================
Main model for training tours.
Removed complex dependencies on registry models.
Uses standard Odoo models (ir.module.module, ir.model, ir.actions.act_window).
"""
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class GeniusTour(models.Model):
"""
Training Tour (previously genius.topic).
Simplified model without registry dependencies.
"""
_name = 'genius.topic'
_description = 'Training Tour'
_order = 'sequence, id'
name = fields.Char(string='Tour Title', required=True, translate=True)
sequence = fields.Integer(string='Order', default=10)
# Plan link (optional - tour can exist standalone)
plan_id = fields.Many2one('genius.plan', string='Training Plan', ondelete='set null')
# Starting URL (main tour entry point)
starting_url = fields.Char(
string='Starting URL',
help='URL where this tour begins (e.g., /web#action=123)'
)
# Media
image = fields.Binary(string='Image', attachment=True)
video_url = fields.Char(string='Video URL')
document = fields.Binary(string='Document (PDF)', attachment=True)
# Target Module and Model (Many2one for searchability)
module_id = fields.Many2one(
'ir.module.module',
string='Target Module',
domain="[('state', '=', 'installed')]",
help='Select the target module for this tour'
)
module_xml_id = fields.Char(related='module_id.name', string='Module Technical Name', store=True)
# Computed display name for module
module_name = fields.Char(
string='Module Display Name',
compute='_compute_module_name',
store=True
)
action_id = fields.Many2one(
'ir.actions.act_window',
string='Navigation Action',
help='Action to open when starting this tour'
)
# Tags
tag_ids = fields.Many2many('genius.tour.tag', 'genius_topic_tag_rel',
'topic_id', 'tag_id', string='Tags')
# Tour Steps
step_ids = fields.One2many('genius.topic.step', 'topic_id', string='Tour Steps')
step_count = fields.Integer(string='Step Count', compute='_compute_step_count', store=True)
# Consumed By Tracking
consumed_user_ids = fields.Many2many('res.users', 'genius_topic_consumed_rel',
'topic_id', 'user_id', string='Completed By')
consumed_count = fields.Integer(string='Completions', compute='_compute_consumed_count', store=True)
# Prerequisites
prerequisite_ids = fields.Many2many('genius.topic', 'genius_topic_prereq_rel',
'topic_id', 'prereq_id', string='Prerequisites')
# Quiz Link
quiz_id = fields.Many2one('genius.quiz', string='Quiz')
# Metadata
duration_minutes = fields.Integer(string='Duration (Minutes)', default=15)
icon = fields.Char(string='Icon', default='fa-graduation-cap', help='Font Awesome icon class (e.g., fa-book, fa-cog)')
# Progress
progress_ids = fields.One2many('genius.progress', 'topic_id', string='Progress Records')
# State Workflow
state = fields.Selection([
('draft', 'Draft'),
('published', 'Published'),
], string='Status', default='draft', tracking=True,
help='Draft: Only visible to admins. Published: Visible to all users.')
active = fields.Boolean(string='Active', default=True)
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
# -------------------------------------------------------------------------
# Compute Methods
# -------------------------------------------------------------------------
@api.depends('step_ids')
def _compute_step_count(self):
for tour in self:
tour.step_count = len(tour.step_ids)
@api.depends('consumed_user_ids')
def _compute_consumed_count(self):
for tour in self:
tour.consumed_count = len(tour.consumed_user_ids)
def unlink(self):
"""Prevent deleting tours that have user progress or completions."""
for tour in self:
# Check for completions
if tour.consumed_count > 0:
raise UserError(
_('Cannot delete tour "%s" because it has been completed by %d user(s). '
'Archive it instead (uncheck Active).') % (tour.name, tour.consumed_count)
)
# Check for ANY progress records (started, in-progress, etc.)
if tour.progress_ids:
raise UserError(
_('Cannot delete tour "%s" because it has %d progress record(s). '
'Archive it instead.') % (tour.name, len(tour.progress_ids))
)
# Check for linked quiz with attempts
if tour.quiz_id and tour.quiz_id.attempt_count > 0:
raise UserError(
_('Cannot delete tour "%s" because its linked quiz has user attempts. '
'Archive it instead.') % tour.name
)
return super(GeniusTour, self).unlink()
@api.depends('module_id')
def _compute_module_name(self):
for tour in self:
tour.module_name = tour.module_id.name if tour.module_id else ''
# -------------------------------------------------------------------------
# Action Methods
# -------------------------------------------------------------------------
def action_publish(self):
"""Publish tour - makes it visible to all users."""
return self.write({'state': 'published'})
def action_set_draft(self):
"""Set tour back to draft - only visible to admins."""
return self.write({'state': 'draft'})
def action_test_tour(self):
"""
Test tour without tracking progress (Admin/Instructor only).
Allows testing tours before publishing without affecting statistics.
"""
self.ensure_one()
# Security check - only instructors can test
if not self.env.user.has_group('tour_genius.group_genius_instructor'):
raise UserError(_('Only instructors can test tours.'))
# Check if tour has steps
if not self.step_ids:
raise UserError(_('Cannot test a tour without steps. Add steps first.'))
# Get tour data for dynamic registration
tour_data = self.get_single_tour_for_test()
if tour_data.get('error'):
raise UserError(_(tour_data['error']))
# Return client action with tour data for dynamic registration
return {
'type': 'ir.actions.client',
'tag': 'tour_genius_run_tour',
'params': {
'tour_name': 'genius_tour_' + str(self.id),
'test_mode': True,
'tour_data': tour_data, # Full tour data for dynamic JS registration
},
}
def action_open_topic(self):
"""Start tour using Odoo's native tour.reset() via client action"""
self.ensure_one()
# Check prerequisites
can_start, missing = self.check_prerequisites()
if not can_start:
raise UserError(_('You must complete "%s" before starting this tour.') % missing.name)
# Mark as in progress
self._update_user_progress('in_progress')
# Return client action that will call tour.reset()
# This is the canonical Odoo pattern - no URL params needed
return {
'type': 'ir.actions.client',
'tag': 'tour_genius_run_tour',
'params': {
'tour_name': 'genius_tour_' + str(self.id),
},
}
def action_mark_consumed(self):
"""Mark this tour as completed by current user"""
user = self.env.user
for tour in self:
if user not in tour.consumed_user_ids:
tour.write({'consumed_user_ids': [(4, user.id)]})
# ALWAYS update progress to track time/count/date for every completion
# The _update_user_progress method handles incrementing completion_count
tour._update_user_progress('done')
return True
def action_mark_skipped(self):
"""Mark this tour as skipped by current user (not completed)"""
user = self.env.user
for tour in self:
# Skipped tours are NOT added to consumed_user_ids
# They can try again later
tour._update_user_progress('skipped')
return True
def action_quick_preview(self):
"""Preview the tour"""
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': f'/web?genius_preview_tour={self.id}',
'target': 'new',
}
def action_view_steps(self):
"""Open steps for this tour"""
self.ensure_one()
# Use specific embedded tree view for cleaner display
tree_view_id = self.env.ref('tour_genius.view_genius_topic_step_tree_embedded').id
return {
'type': 'ir.actions.act_window',
'name': f'Steps - {self.name}',
'res_model': 'genius.topic.step',
'view_mode': 'tree,form',
'views': [(tree_view_id, 'tree'), (False, 'form')],
'domain': [('topic_id', '=', self.id)],
'context': {'default_topic_id': self.id},
}
def action_view_completions(self):
"""Open user progress records for this tour (Admin view)"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Completions - {self.name}',
'res_model': 'genius.progress',
'view_mode': 'tree,form',
'domain': [('topic_id', '=', self.id)],
'context': {'default_topic_id': self.id},
}
def action_start_recording(self):
"""Start the inline recorder panel"""
self.ensure_one()
if self.starting_url:
target_url = self.starting_url
elif self.action_id:
target_url = f'/web#action={self.action_id.id}'
else:
target_url = '/web'
# We MUST inject the parameter into the query string (before the #)
# to force a full page reload, otherwise $(document).ready won't fire
if '#' in target_url:
base, fragment = target_url.split('#', 1)
sep = '&' if '?' in base else '?'
url = f"{base}{sep}genius_recorder={self.id}#{fragment}"
else:
sep = '&' if '?' in target_url else '?'
url = f"{target_url}{sep}genius_recorder={self.id}"
return {
'type': 'ir.actions.act_url',
'url': url,
'target': 'self',
}
def action_start_quiz(self):
"""
Start a quiz for this tour.
Creates a new attempt or resumes existing in-progress one.
Returns action to open the quiz form.
"""
self.ensure_one()
if not self.quiz_id:
return False
# Check for in-progress attempt
attempt = self.env['genius.quiz.attempt'].search([
('quiz_id', '=', self.quiz_id.id),
('user_id', '=', self.env.user.id),
('state', '=', 'in_progress')
], limit=1)
if not attempt:
# Check Max Attempts
if self.quiz_id.max_attempts > 0:
attempt_count = self.env['genius.quiz.attempt'].search_count([
('quiz_id', '=', self.quiz_id.id),
('user_id', '=', self.env.user.id)
])
if attempt_count >= self.quiz_id.max_attempts:
raise UserError(_("You have reached the maximum number of attempts (%s) for this quiz.") % self.quiz_id.max_attempts)
# Create new attempt using Quiz Logic (handles shuffle, sample)
attempt = self.quiz_id.create_attempt(self.env.user.id)
return {
'type': 'ir.actions.act_window',
'res_model': 'genius.quiz.attempt',
'res_id': attempt.id,
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'new', # Contextual window (modal-like)
}
def action_start_quiz_popup(self):
"""
Start a quiz for the popup widget.
Returns quiz data (questions, answers) for frontend rendering.
"""
self.ensure_one()
if not self.quiz_id:
return {'error': _('No quiz linked to this tour.')}
# Check Max Attempts
if self.quiz_id.max_attempts > 0:
attempt_count = self.env['genius.quiz.attempt'].search_count([
('quiz_id', '=', self.quiz_id.id),
('user_id', '=', self.env.user.id),
('state', '=', 'submitted'),
('is_preview', '=', False) # CRITICAL: Exclude preview attempts
])
if attempt_count >= self.quiz_id.max_attempts:
return {'error': _('Maximum attempts reached (%s).') % self.quiz_id.max_attempts}
# Check if already achieved 100% (Genius Mode: Allow retry even if passed, unless perfect)
perfect_attempts = self.env['genius.quiz.attempt'].search_count([
('quiz_id', '=', self.quiz_id.id),
('user_id', '=', self.env.user.id),
('state', '=', 'submitted'),
('score', '>=', 100.0),
('is_preview', '=', False)
])
if perfect_attempts > 0:
return {'error': _('You have already achieved a perfect score (100%)! Genius!')}
# Check for in-progress attempt (with race condition protection)
# Use SQL lock to prevent duplicate attempts from multiple tabs
self.env.cr.execute("""
SELECT id FROM genius_quiz_attempt
WHERE quiz_id = %s AND user_id = %s AND state = 'in_progress'
LIMIT 1
FOR UPDATE NOWAIT
""", (self.quiz_id.id, self.env.user.id))
result = self.env.cr.fetchone()
if result:
attempt = self.env['genius.quiz.attempt'].browse(result[0])
else:
# Create new attempt
attempt = self.quiz_id.create_attempt(self.env.user.id)
# Build questions data from attempt's responses (respects shuffle/sample)
questions = []
for response in attempt.response_ids:
q = response.question_id
question_data = {
'id': q.id,
'text': q.question_text.strip() if q.question_text else '',
'type': q.question_type,
'image': q.image.decode('utf-8') if q.image else None,
'answers': []
}
# Add answers (for single/multiple choice and ordering)
answers_list = list(q.answer_ids)
# CRITICAL: For ordering questions, shuffle answers so user doesn't see correct order
if q.question_type == 'ordering':
import random
random.shuffle(answers_list)
for ans in answers_list:
question_data['answers'].append({
'id': ans.id,
'text': ans.answer_text,
})
questions.append(question_data)
return {
'attempt_id': attempt.id,
'quiz_name': self.quiz_id.name,
'questions': questions,
'time_limit_minutes': self.quiz_id.time_limit_minutes,
'passing_score': self.quiz_id.passing_score,
# New fields for enhanced UX
'description': self.quiz_id.description or '',
'success_message': self.quiz_id.success_message or '',
'fail_message': self.quiz_id.fail_message or '',
'show_correct_answers': self.quiz_id.show_correct_answers,
}
def get_certificate_data(self):
"""
Get certificate data for displaying in the popup.
Returns the best attempt info for certificate display.
"""
self.ensure_one()
if not self.quiz_id:
return {'error': _('No quiz linked to this tour.')}
# Get best passed attempt
attempt = self.env['genius.quiz.attempt'].search([
('quiz_id', '=', self.quiz_id.id),
('user_id', '=', self.env.user.id),
('state', '=', 'submitted'),
('is_passed', '=', True),
], order='score desc', limit=1)
if not attempt:
return {'error': _('No passed attempt found.')}
# Calculate time taken
time_taken = 0
if attempt.started_at and attempt.submitted_at:
delta = attempt.submitted_at - attempt.started_at
time_taken = int(delta.total_seconds() / 60)
return {
'attempt_id': attempt.id, # Required for PDF download
'score': attempt.score,
'points_earned': attempt.points_earned,
'points_possible': attempt.points_possible,
'time_taken': time_taken,
'user_name': self.env.user.name,
'date': attempt.submitted_at.strftime('%B %d, %Y') if attempt.submitted_at else '',
'passing_score': self.quiz_id.passing_score,
}
# -------------------------------------------------------------------------
# Helper Methods
# -------------------------------------------------------------------------
def check_prerequisites(self, user=None):
"""Check if user has completed prerequisites"""
user = user or self.env.user
for prereq in self.prerequisite_ids:
if user not in prereq.consumed_user_ids:
return False, prereq
return True, None
def action_track_start(self):
"""Called by JS when tour starts to ensure start time is recorded"""
# Call update progress without 'ensure_one' since JS might send ID list
# but typically it sends one ID
self._update_user_progress('in_progress')
return True
def _update_user_progress(self, state):
"""Update or create progress record for current user"""
user = self.env.user
for tour in self:
progress = self.env['genius.progress'].search([
('user_id', '=', user.id),
('topic_id', '=', tour.id),
], limit=1)
vals = {'state': state}
if state == 'in_progress':
# Set date_started for the CURRENT attempt
vals['date_started'] = fields.Datetime.now()
# PROTECTION: Don't downgrade done/verified to in_progress
# and don't clear date_completed for already-completed tours
if not progress or progress.state not in ('done', 'verified'):
vals['date_completed'] = False
else:
# User is re-running a completed tour - don't change state
vals.pop('state', None)
elif state == 'done':
vals['date_completed'] = fields.Datetime.now()
# Increment completion count
if progress:
vals['completion_count'] = progress.completion_count + 1
else:
vals['completion_count'] = 1
elif state == 'skipped':
vals['date_skipped'] = fields.Datetime.now()
if progress:
# Don't downgrade from 'done' or 'verified' to 'skipped'
# User might be re-taking a tour they already finished
if state == 'skipped' and progress.state in ('done', 'verified'):
vals.pop('state', None)
progress.write(vals)
else:
if state == 'done':
vals['completion_count'] = 1
self.env['genius.progress'].create({
'user_id': user.id,
'topic_id': tour.id,
'plan_id': tour.plan_id.id if tour.plan_id else False,
**vals,
})
# -------------------------------------------------------------------------
# Tour Registration API (for JS tour_loader)
# -------------------------------------------------------------------------
def get_single_tour_for_test(self):
"""
Return a single tour's data for testing (bypasses state filter).
Used by action_test_tour to allow testing draft tours.
"""
self.ensure_one()
# Get active steps (use sudo to ensure access to all fields/translations)
active_steps = self.sudo().step_ids.filtered('active')
if not active_steps:
return {'error': 'No steps defined for this tour.'}
# Build steps data for JS
steps = []
for step in active_steps.sorted('sequence'):
step_data = step.to_tour_step_dict()
if step_data:
steps.append(step_data)
if not steps:
return {'error': 'No valid steps found.'}
return {
'id': self.id,
'name': 'genius_tour_' + str(self.id),
'url': self.starting_url or '/web',
'steps': steps,
'test_mode': True, # Flag for JS to skip progress tracking
}
@api.model
def get_tours_for_registration(self):
"""
Return all active, published tours for static JS registration.
Called by tour_loader.js on every page load.
Returns tours in Odoo web_tour format.
"""
# Only register published tours (draft tours are admin-only and not runnable)
topics = self.sudo().search([('active', '=', True), ('state', '=', 'published')])
result = []
user = self.env.user
for topic in topics:
# Get active steps
active_steps = topic.step_ids.filtered('active')
if not active_steps:
continue # Skip tours without steps
# Check Quiz Status and Attempts
quiz_status = 'none'
quiz_score = 0
attempts_count = 0
attempts_remaining = -1 # -1 means unlimited
if topic.quiz_id:
# Single query to get all submitted attempts (reused for count and best score)
all_attempts = self.env['genius.quiz.attempt'].sudo().search([
('quiz_id', '=', topic.quiz_id.id),
('user_id', '=', user.id),
('state', '=', 'submitted'),
('is_preview', '=', False), # CRITICAL: Exclude test/preview attempts
])
attempts_count = len(all_attempts)
if all_attempts:
# Get best attempt (highest score)
best_attempt = all_attempts.sorted('score', reverse=True)[:1]
quiz_status = 'passed' if best_attempt.is_passed else 'failed'
quiz_score = best_attempt.score
else:
quiz_status = 'not_attempted'
# Calculate remaining attempts
if topic.quiz_id.max_attempts > 0:
attempts_remaining = max(0, topic.quiz_id.max_attempts - attempts_count)
# Build steps in Odoo tour format
steps = []
for step in active_steps.sorted('sequence'):
step_data = step.to_tour_step_dict()
if step_data:
steps.append(step_data)
result.append({
'id': topic.id,
'name': topic.name,
'starting_url': topic.starting_url or '/web',
'steps': steps,
'quiz': {
'id': topic.quiz_id.id if topic.quiz_id else False,
'status': quiz_status,
'score': quiz_score,
'name': topic.quiz_id.name if topic.quiz_id else '',
'source_tour_id': topic.id,
'max_attempts': topic.quiz_id.max_attempts if topic.quiz_id else 0,
'attempts_count': attempts_count,
'attempts_remaining': attempts_remaining,
}
})
return result
# -------------------------------------------------------------------------
# Dashboard API
# -------------------------------------------------------------------------
@api.model
def get_dashboard_data(self):
"""Get data for Dashboard view"""
user = self.env.user
is_admin = user.has_group('tour_genius.group_genius_admin')
# Admins see all active tours (including draft), regular users only see published
domain = [('active', '=', True)]
if not is_admin:
domain.append(('state', '=', 'published'))
all_tours = self.search(domain)
tours = []
certified_count = 0
for tour in all_tours:
# Check Progress specifically for verified state
progress = self.env['genius.progress'].search([
('user_id', '=', user.id),
('topic_id', '=', tour.id)
], limit=1)
# Check if user has passed quiz for this tour
quiz_passed = False
best_attempt_id = False
if tour.quiz_id:
passed_attempt = self.env['genius.quiz.attempt'].search([
('quiz_id', '=', tour.quiz_id.id),
('user_id', '=', user.id),
('state', '=', 'submitted'),
('is_passed', '=', True),
('is_preview', '=', False), # CRITICAL: Exclude test/preview attempts for certification
], order='score desc', limit=1)
if passed_attempt:
quiz_passed = True
best_attempt_id = passed_attempt.id
user_status = 'new'
if progress:
# Use the state from progress record
if progress.state == 'verified':
user_status = 'verified'
certified_count += 1
elif progress.state == 'done':
# If done AND passed quiz → verified, else completed
if quiz_passed:
user_status = 'verified'
certified_count += 1
else:
user_status = 'completed'
elif progress.state == 'in_progress':
user_status = 'in_progress'
# Check consumed_user_ids (legacy fallback)
elif user in tour.consumed_user_ids:
if quiz_passed:
user_status = 'verified'
certified_count += 1
else:
user_status = 'completed'
elif user in tour.consumed_user_ids:
if quiz_passed:
user_status = 'verified'
certified_count += 1
else:
user_status = 'completed'
# Get quiz status for this tour
quiz_data = {
'quiz_id': False,
'quiz_status': 'none',
'quiz_score': 0,
'attempts_remaining': -1,
'can_retry': False,
'is_perfect': False, # True when score = 100%
'has_certificate': False, # True when user passed any quiz
'certificate_attempt_id': False, # For download link
}
if tour.quiz_id:
quiz_data['quiz_id'] = tour.quiz_id.id
quiz_data['has_certificate'] = quiz_passed
quiz_data['certificate_attempt_id'] = best_attempt_id
# Get user's quiz attempts (EXCLUDING PREVIEWS)
attempts = self.env['genius.quiz.attempt'].search([
('quiz_id', '=', tour.quiz_id.id),
('user_id', '=', user.id),
('state', '=', 'submitted'),
('is_preview', '=', False), # CRITICAL: Exclude test/preview attempts
], order='score desc')
if attempts:
best_attempt = attempts[0]
quiz_data['quiz_score'] = round(best_attempt.score)
quiz_data['quiz_status'] = 'passed' if best_attempt.is_passed else 'failed'
quiz_data['is_perfect'] = best_attempt.score >= 100
# Check if can retry (allow retry even if passed but not perfect)
if tour.quiz_id.max_attempts > 0:
quiz_data['attempts_remaining'] = max(0, tour.quiz_id.max_attempts - len(attempts))
# Can retry if: not perfect AND has remaining attempts
quiz_data['can_retry'] = quiz_data['attempts_remaining'] > 0 and not quiz_data['is_perfect']
else:
quiz_data['attempts_remaining'] = -1 # Unlimited
# Can retry if not perfect (unlimited attempts)
quiz_data['can_retry'] = not quiz_data['is_perfect']
else:
quiz_data['quiz_status'] = 'not_attempted'
quiz_data['can_retry'] = True
if tour.quiz_id.max_attempts > 0:
quiz_data['attempts_remaining'] = tour.quiz_id.max_attempts
else:
quiz_data['attempts_remaining'] = -1
tours.append({
'id': tour.id,
'name': tour.name,
'icon': tour.icon or '*',
'module_name': tour.module_name or '',
'step_count': tour.step_count,
'user_status': user_status,
'duration_minutes': tour.duration_minutes,
'state': tour.state, # draft or published
# Tags for display
'tags': [{'id': t.id, 'name': t.name, 'color': t.color} for t in tour.tag_ids],
# Admin info: how many users have verified this tour
'verified_count': self.env['genius.progress'].search_count([
('topic_id', '=', tour.id),
('state', '=', 'verified')
]),
# Quiz data
'quiz_id': quiz_data['quiz_id'],
'quiz_status': quiz_data['quiz_status'],
'quiz_score': quiz_data['quiz_score'],
'attempts_remaining': quiz_data['attempts_remaining'],
'can_retry': quiz_data['can_retry'],
'is_perfect': quiz_data['is_perfect'],
'has_certificate': quiz_data['has_certificate'],
'certificate_attempt_id': quiz_data['certificate_attempt_id'],
})
# Recalculate counts based on user_status logic above
completed_count = len([t for t in tours if t['user_status'] in ['completed', 'verified']])
in_progress_count = len([t for t in tours if t['user_status'] == 'in_progress'])
total_count = len(tours)
progress = (completed_count / total_count * 100) if total_count else 0
return {
'tours': tours,
'is_admin': is_admin,
'progress': progress,
'stats': {
'total_tours': total_count,
'completed': completed_count,
'in_progress': in_progress_count,
'certified': certified_count,
}
}
# -------------------------------------------------------------------------
# Contextual Training API
# -------------------------------------------------------------------------
@api.model
def get_contextual_training(self, context):
"""Get training relevant to current screen with genius-level detail"""
user = self.env.user
# 1. Parse Context
model_name = context.get('model')
action_id = context.get('action_id')
menu_id = context.get('menu_id')
# Resolve Action -> Model
if action_id and not model_name:
try:
action = self.env['ir.actions.act_window'].browse(int(action_id))
if action.exists() and action.res_model:
model_name = action.res_model
except:
pass
# NEW: Resolve Menu -> Module (most reliable for app context)
menu_module = None
if menu_id:
try:
menu = self.env['ir.ui.menu'].browse(int(menu_id))
if menu.exists():
# Get root menu (app) by traversing parent chain
root_menu = menu
while root_menu.parent_id:
root_menu = root_menu.parent_id
# Get module from XML ID of root menu
xml_id = self.env['ir.model.data'].sudo().search([
('model', '=', 'ir.ui.menu'),
('res_id', '=', root_menu.id)
], limit=1)
if xml_id:
menu_module = xml_id.module
except:
pass
relevant_tours = self.env['genius.topic']
# 2. Strict Match (High Priority) - by action_id only since model_id was removed
strict_tours = self.env['genius.topic']
if action_id:
strict_tours |= self.search([('active', '=', True), ('state', '=', 'published'), ('action_id', '=', int(action_id))])
relevant_tours |= strict_tours
# 3. Module Match (Broad Context - Lower Priority)
module_names_to_search = []
# 3a. From model's module
if model_name:
ir_model = self.env['ir.model'].search([('model', '=', model_name)], limit=1)
if ir_model and ir_model.modules:
module_names_to_search.extend([m.strip() for m in ir_model.modules.split(',')])
# 3b. From menu module (NEW - most reliable for app context)
if menu_module and menu_module not in module_names_to_search:
module_names_to_search.append(menu_module)
if module_names_to_search:
module_tours = self.search([
('active', '=', True),
('state', '=', 'published'),
('module_xml_id', 'in', module_names_to_search),
('id', 'not in', strict_tours.ids) # Avoid duplicates
])
relevant_tours |= module_tours
# 4. Fallback: If no context, show "featured" tours (limited)
if not relevant_tours:
# Show top 5 most recent published tours the user hasn't completed
featured_tours = self.search([
('active', '=', True),
('state', '=', 'published'),
], order='create_date desc', limit=5)
relevant_tours = featured_tours
# 4. Process Status & Progress for each tour
result_topics = []
new_count = 0
for t in relevant_tours:
# Get Progress
progress = self.env['genius.progress'].search([
('user_id', '=', user.id),
('topic_id', '=', t.id)
], limit=1)
# Determine Status
status = 'new'
progress_percent = 0
if progress:
status = progress.state
if status == 'in_progress' and t.step_count > 0:
# Generic 50% for in_progress
progress_percent = 50
elif status in ('done', 'verified'):
progress_percent = 100
elif user in t.consumed_user_ids:
status = 'done'
progress_percent = 100
# Count "New" items for badge (strict + module)
if status == 'new':
new_count += 1
result_topics.append({
'id': t.id,
'name': t.name,
'icon': t.icon or 'fa-graduation-cap',
'module_name': t.module_xml_id,
'step_count': t.step_count,
'status': status, # new, in_progress, done, verified, skipped
'progress': progress_percent,
'is_strict_match': t in strict_tours, # Use this for "Recommended" badge
'duration_minutes': t.duration_minutes,
})
# 5. Sort: In Progress > New (Strict) > New (Module) > Completed
def sort_key(item):
# Sort order (lower is better)
# 1. In Progress (Resume!) -> 0
# 2. New Strict Match (Recommended) -> 1
# 3. New Broad Match -> 2
# 4. Skipped -> 3
# 5. Done/Verified -> 4
if item['status'] == 'in_progress':
return 0
elif item['status'] == 'new':
return 1 if item['is_strict_match'] else 2
elif item['status'] == 'skipped':
return 3
return 4
result_topics.sort(key=sort_key)
return {
'topics': result_topics,
'context': context,
'total_count': len(relevant_tours),
'new_count': new_count,
}

View File

@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""
Tour Mixin (Simplified)
=======================
Mixin for module access detection.
Uses standard Odoo ir.module.module instead of registry.
"""
from odoo import models, api
import logging
_logger = logging.getLogger(__name__)
class GeniusModuleAccessMixin(models.AbstractModel):
"""Mixin for detecting which modules a user has access to."""
_name = 'genius.module.access.mixin'
_description = 'Module Access Detection Mixin'
@api.model
def _get_user_accessible_modules(self, user=None):
"""
Detect which modules a user has access to.
Returns list of module technical names.
"""
user = user or self.env.user
# Admin sees everything
if user.has_group('tour_genius.group_genius_admin'):
return self.env['ir.module.module'].search([
('state', '=', 'installed')
]).mapped('name')
accessible_modules = set()
try:
# Check model access rights
model_accesses = self.env['ir.model.access'].sudo().search([
('group_id', 'in', user.groups_id.ids),
('perm_read', '=', True),
])
for access in model_accesses:
if access.model_id and access.model_id.modules:
module_names = [m.strip() for m in access.model_id.modules.split(',')]
accessible_modules.update(module_names)
except Exception as e:
_logger.warning("Error detecting accessible modules: %s", str(e))
# Fallback: return all installed modules
return self.env['ir.module.module'].sudo().search([
('state', '=', 'installed')
]).mapped('name')
return list(accessible_modules)
def filter_by_user_access(self, records=None, user=None):
"""Filter records to only those the user can access based on module."""
records = records if records is not None else self
user = user or self.env.user
# Admin sees everything
if user.has_group('tour_genius.group_genius_admin'):
return records
accessible_modules = self._get_user_accessible_modules(user)
return records.filtered(
lambda r: not r.module_name or r.module_name in accessible_modules
)

View File

@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
"""
Tour Plan Model (Simplified)
=============================
Training plan without complex sections.
Topics are linked directly to plans.
"""
from odoo import models, fields, api
class GeniusPlan(models.Model):
"""Training Plan - simplified without sections."""
_name = 'genius.plan'
_description = 'Training Plan'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc'
name = fields.Char(string='Plan Name', required=True, tracking=True, translate=True)
description = fields.Html(string='Description', translate=True)
cover_image = fields.Binary(string='Cover Image', attachment=True)
# Status
state = fields.Selection([
('draft', 'Draft'),
('published', 'Published'),
('archived', 'Archived'),
], string='Status', default='draft', tracking=True)
# Schedule
date_start = fields.Date(string='Start Date')
date_end = fields.Date(string='End Date')
estimated_hours = fields.Float(string='Estimated Hours')
# Topics (direct link - no sections!)
topic_ids = fields.One2many('genius.topic', 'plan_id', string='Topics')
topic_count = fields.Integer(string='Topic Count', compute='_compute_topic_count', store=True)
# Target Modules (simplified - uses names not registry)
target_modules = fields.Char(
string='Covered Modules',
help='Comma-separated list of module names (e.g., sale, purchase, account)'
)
# Participants
attendee_ids = fields.Many2many('res.users', 'genius_plan_attendee_rel',
'plan_id', 'user_id', string='Trainees')
attendee_count = fields.Integer(string='Trainee Count', compute='_compute_attendee_count')
instructor_ids = fields.Many2many('res.users', 'genius_plan_instructor_rel',
'plan_id', 'user_id', string='Instructors')
# Library Mode
is_template = fields.Boolean(string='Is Template', default=False)
is_public = fields.Boolean(string='Public Access', default=False)
# Multi-company
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
# Progress
progress_ids = fields.One2many('genius.progress', 'plan_id', string='Progress Records')
overall_progress = fields.Float(string='Overall Progress (%)', compute='_compute_overall_progress')
active = fields.Boolean(string='Active', default=True)
# -------------------------------------------------------------------------
# Compute Methods
# -------------------------------------------------------------------------
@api.depends('topic_ids')
def _compute_topic_count(self):
for plan in self:
plan.topic_count = len(plan.topic_ids)
@api.depends('attendee_ids')
def _compute_attendee_count(self):
for plan in self:
plan.attendee_count = len(plan.attendee_ids)
@api.depends('progress_ids', 'progress_ids.state')
def _compute_overall_progress(self):
for plan in self:
if not plan.topic_count or not plan.attendee_count:
plan.overall_progress = 0.0
continue
total_expected = plan.topic_count * plan.attendee_count
if total_expected == 0:
plan.overall_progress = 0.0
continue
completed = len(plan.progress_ids.filtered(lambda p: p.state in ('done', 'verified')))
plan.overall_progress = (completed / total_expected) * 100
# -------------------------------------------------------------------------
# Action Methods
# -------------------------------------------------------------------------
def action_publish(self):
"""Publish and initialize progress for attendees"""
for plan in self:
plan.write({'state': 'published'})
plan._initialize_progress_for_attendees()
return True
def action_archive(self):
return self.write({'state': 'archived'})
def action_draft(self):
return self.write({'state': 'draft'})
def _initialize_progress_for_attendees(self):
"""Create progress records for all attendees"""
for plan in self:
for user in plan.attendee_ids:
for topic in plan.topic_ids:
existing = self.env['genius.progress'].search([
('user_id', '=', user.id),
('topic_id', '=', topic.id),
], limit=1)
if not existing:
self.env['genius.progress'].create({
'user_id': user.id,
'topic_id': topic.id,
'plan_id': plan.id,
'state': 'pending',
})
def action_view_topics(self):
"""Open topics for this plan"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Topics - {self.name}',
'res_model': 'genius.topic',
'view_mode': 'tree,form',
'domain': [('plan_id', '=', self.id)],
'context': {'default_plan_id': self.id},
}
def action_view_attendees(self):
"""Open attendee list"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': f'Trainees - {self.name}',
'res_model': 'res.users',
'view_mode': 'tree,form',
'domain': [('id', 'in', self.attendee_ids.ids)],
}
def action_start_first_topic(self):
"""Start the first topic for the current user"""
self.ensure_one()
first_topic = self.topic_ids.sorted('sequence')[:1]
if first_topic:
return first_topic.action_open_topic()
return True

View File

@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class GeniusProgress(models.Model):
"""
Tracks user progress on topics.
One record per user per topic.
"""
_name = 'genius.progress'
_description = 'User Progress'
_order = 'date_started desc'
_sql_constraints = [
('user_topic_uniq', 'unique(user_id, topic_id)', 'Progress record already exists for this tour!')
]
user_id = fields.Many2one(
'res.users',
string='User',
required=True,
ondelete='cascade',
index=True
)
topic_id = fields.Many2one(
'genius.topic',
string='Topic',
required=True,
ondelete='restrict',
index=True
)
plan_id = fields.Many2one(
'genius.plan',
string='Plan',
ondelete='cascade',
help='Training plan (for faster queries)'
)
# Progress State
state = fields.Selection(
selection=[
('pending', 'Pending'),
('in_progress', 'In Progress'),
('skipped', 'Skipped'),
('done', 'Completed'),
('verified', 'Certified'), # Certified = Completed + Passed Quiz
],
string='Status',
default='pending',
index=True
)
# Timestamps
date_started = fields.Datetime(
string='Started At'
)
date_completed = fields.Datetime(
string='Completed At'
)
date_verified = fields.Datetime(
string='Verified At'
)
date_skipped = fields.Datetime(
string='Last Skipped At'
)
# Completion Tracking
completion_count = fields.Integer(
string='Times Completed',
default=0,
help='Number of times user has completed this tour'
)
# Quiz Score (if applicable)
quiz_score = fields.Float(
string='Quiz Score (%)'
)
quiz_attempt_id = fields.Many2one(
'genius.quiz.attempt',
string='Quiz Attempt'
)
# Time spent
time_spent_seconds = fields.Float(
string='Time (Seconds)',
compute='_compute_time_spent',
store=True,
digits=(10, 2)
)
time_spent_minutes = fields.Float(
string='Time (Minutes)',
compute='_compute_time_spent',
store=True,
digits=(10, 2)
)
duration_display = fields.Char(
string='Duration',
compute='_compute_time_spent',
store=True
)
# Gamification (Stored for Performance)
points_earned = fields.Integer(string='Points', compute='_compute_gamification', store=True)
is_quiz_passed = fields.Boolean(string='Quiz Passed', compute='_compute_gamification', store=True)
is_quiz_perfect = fields.Boolean(string='Quiz Perfect', compute='_compute_gamification', store=True)
@api.depends('state', 'quiz_attempt_id', 'quiz_attempt_id.is_passed', 'quiz_score')
def _compute_gamification(self):
for r in self:
points = 0
is_passed = False
is_perfect = False
# Topic Completion
if r.state in ['done', 'verified']:
points += 10
# Quiz Completion
if r.quiz_attempt_id and r.quiz_attempt_id.is_passed:
is_passed = True
points += 25
# Perfect Score (Bonus)
# Check robust float comparison or just use integer points?
# attempt.score is float. attempt.points_earned == points_possible is better.
if r.quiz_attempt_id.points_possible > 0 and r.quiz_attempt_id.points_earned == r.quiz_attempt_id.points_possible:
is_perfect = True
points += 50
r.points_earned = points
r.is_quiz_passed = is_passed
r.is_quiz_perfect = is_perfect
# Notes
notes = fields.Text(
string='Notes',
help='Trainer notes about this progress'
)
@api.depends('date_started', 'date_completed')
def _compute_time_spent(self):
for progress in self:
if progress.date_started and progress.date_completed:
delta = progress.date_completed - progress.date_started
seconds = delta.total_seconds()
progress.time_spent_seconds = seconds
progress.time_spent_minutes = seconds / 60.0
# Format duration (e.g. "2m 15s")
m, s = divmod(int(seconds), 60)
h, m = divmod(m, 60)
if h > 0:
progress.duration_display = f"{h}h {m}m {s}s"
elif m > 0:
progress.duration_display = f"{m}m {s}s"
else:
progress.duration_display = f"{s}s"
else:
progress.time_spent_seconds = 0.0
progress.time_spent_minutes = 0.0
progress.duration_display = "0s"
def action_verify(self):
"""Mark progress as verified by trainer"""
return self.write({
'state': 'verified',
'date_verified': fields.Datetime.now(),
})
def action_reset(self):
"""Reset progress to pending"""
return self.write({
'state': 'pending',
'date_started': False,
'date_completed': False,
'date_verified': False,
'quiz_score': 0,
'quiz_attempt_id': False,
})

View File

@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class GeniusTopicStep(models.Model):
"""
Individual step within a topic tour.
Simplified model focusing on CSS selector and basic configuration.
"""
_name = 'genius.topic.step'
_description = 'Topic Step'
_order = 'sequence, id'
topic_id = fields.Many2one(
'genius.topic',
string='Topic',
required=True,
ondelete='cascade',
index=True
)
sequence = fields.Integer(
string='Order',
default=10
)
title = fields.Char(
string='Step Title',
required=True,
translate=True,
help='Short title for this step (displayed in tooltip)'
)
instruction = fields.Html(
string='Instruction',
translate=True,
sanitize=True,
help='Detailed instruction for the user'
)
# CSS Selector - THE MAIN FIELD
css_selector = fields.Char(
string='CSS Selector',
required=True,
help='CSS selector for the target element. Use the Recorder to capture this automatically.'
)
# Step Type (simplified - only used types)
step_type = fields.Selection(
selection=[
('click', 'Click'),
('input', 'Input Text'),
],
string='Step Type',
default='click',
required=True,
help='Click: Wait for user to click element. Input: Type specific text.'
)
# Tooltip Position
position = fields.Selection(
selection=[
('top', 'Top'),
('bottom', 'Bottom'),
('left', 'Left'),
('right', 'Right'),
],
string='Tip Position',
default='bottom',
help='Position of the tooltip relative to the element'
)
# For input steps
input_value = fields.Char(
string='Input Value',
help='Value to type (only for Input Text steps)'
)
# Extra trigger for context validation (Odoo standard pattern)
extra_trigger = fields.Char(
string='Extra Trigger',
help='Additional CSS selector that must be visible for this step to activate. '
'Used to ensure the correct page/context.'
)
active = fields.Boolean(
string='Active',
default=True
)
# Related fields for display
topic_name = fields.Char(
related='topic_id.name',
string='Tour Name',
store=True
)
# -------------------------------------------------------------------------
# Helper Methods
# -------------------------------------------------------------------------
def to_tour_step_dict(self):
"""
Convert to Odoo web_tour step format.
Returns a dict compatible with web_tour.
Note: We include both Odoo-standard fields (trigger, content, position)
and Genius-specific fields (title, geniusTitle) for GeniusTip widget.
"""
self.ensure_one()
# Combine Title and Instruction for content
content_html = f"<strong>{self.title}</strong>"
if self.instruction:
# Simple clean up of P tags if necessary, or just append
# web_tour supports HTML in content
content_html += f"<br/>{self.instruction}"
step_dict = {
'trigger': self.css_selector,
'content': content_html,
'position': self.position,
# GENIUS: Add title separately for custom GeniusTip widget
'title': self.title,
'geniusTitle': self.title,
}
# Add Extra Trigger if present
if self.extra_trigger:
step_dict['extra_trigger'] = self.extra_trigger
# Generate run command based on step type
if self.step_type == 'click':
step_dict['run'] = 'click'
elif self.step_type == 'input' and self.input_value:
escaped_value = (self.input_value or '').replace("'", "\\'")
step_dict['run'] = f"text {escaped_value}"
return step_dict

View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class GeniusTourTag(models.Model):
"""
Tags for additional topic classification.
Flexible tagging system for filtering.
"""
_name = 'genius.tour.tag'
_description = 'Tour Tag'
_order = 'name'
name = fields.Char(
string='Tag Name',
required=True,
translate=True
)
color = fields.Integer(
string='Color Index'
)
# Usage Stats
topic_count = fields.Integer(
string='Topic Count',
compute='_compute_topic_count'
)
_sql_constraints = [
('unique_name', 'UNIQUE(name)', 'Tag name must be unique!')
]
def _compute_topic_count(self):
for tag in self:
tag.topic_count = self.env['genius.topic'].search_count([
('tag_ids', 'in', [tag.id])
])

View File

@ -0,0 +1,26 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_plan_user,genius.plan.user,model_genius_plan,group_genius_user,1,0,0,0
access_plan_instructor,genius.plan.instructor,model_genius_plan,group_genius_instructor,1,1,1,0
access_plan_admin,genius.plan.admin,model_genius_plan,group_genius_admin,1,1,1,1
access_topic_user,genius.topic.user,model_genius_topic,group_genius_user,1,0,0,0
access_topic_instructor,genius.topic.instructor,model_genius_topic,group_genius_instructor,1,1,1,1
access_step_user,genius.topic.step.user,model_genius_topic_step,group_genius_user,1,0,0,0
access_step_instructor,genius.topic.step.instructor,model_genius_topic_step,group_genius_instructor,1,1,1,1
access_progress_user,genius.progress.user,model_genius_progress,group_genius_user,1,1,1,0
access_progress_instructor,genius.progress.instructor,model_genius_progress,group_genius_instructor,1,1,1,1
access_tag_user,genius.tour.tag.user,model_genius_tour_tag,group_genius_user,1,0,0,0
access_tag_instructor,genius.tour.tag.instructor,model_genius_tour_tag,group_genius_instructor,1,1,1,1
access_quiz_user,genius.quiz.user,model_genius_quiz,group_genius_user,1,0,0,0
access_quiz_instructor,genius.quiz.instructor,model_genius_quiz,group_genius_instructor,1,1,1,1
access_question_user,genius.quiz.question.user,model_genius_quiz_question,group_genius_user,1,0,0,0
access_question_instructor,genius.quiz.question.instructor,model_genius_quiz_question,group_genius_instructor,1,1,1,1
access_answer_user,genius.quiz.answer.user,model_genius_quiz_answer,group_genius_user,1,0,0,0
access_answer_instructor,genius.quiz.answer.instructor,model_genius_quiz_answer,group_genius_instructor,1,1,1,1
access_attempt_user,genius.quiz.attempt.user,model_genius_quiz_attempt,group_genius_user,1,1,1,0
access_attempt_instructor,genius.quiz.attempt.instructor,model_genius_quiz_attempt,group_genius_instructor,1,1,1,1
access_response_user,genius.quiz.response.user,model_genius_quiz_response,group_genius_user,1,1,1,0
access_response_instructor,genius.quiz.response.instructor,model_genius_quiz_response,group_genius_instructor,1,1,1,1
access_leaderboard_user,genius.leaderboard.user,model_genius_leaderboard,group_genius_user,1,0,0,0
access_leaderboard_admin,genius.leaderboard.admin,model_genius_leaderboard,group_genius_admin,1,1,1,1
access_reminder_user,genius.reminder.user,model_genius_reminder,group_genius_user,1,0,0,0
access_reminder_instructor,genius.reminder.instructor,model_genius_reminder,group_genius_instructor,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_plan_user genius.plan.user model_genius_plan group_genius_user 1 0 0 0
3 access_plan_instructor genius.plan.instructor model_genius_plan group_genius_instructor 1 1 1 0
4 access_plan_admin genius.plan.admin model_genius_plan group_genius_admin 1 1 1 1
5 access_topic_user genius.topic.user model_genius_topic group_genius_user 1 0 0 0
6 access_topic_instructor genius.topic.instructor model_genius_topic group_genius_instructor 1 1 1 1
7 access_step_user genius.topic.step.user model_genius_topic_step group_genius_user 1 0 0 0
8 access_step_instructor genius.topic.step.instructor model_genius_topic_step group_genius_instructor 1 1 1 1
9 access_progress_user genius.progress.user model_genius_progress group_genius_user 1 1 1 0
10 access_progress_instructor genius.progress.instructor model_genius_progress group_genius_instructor 1 1 1 1
11 access_tag_user genius.tour.tag.user model_genius_tour_tag group_genius_user 1 0 0 0
12 access_tag_instructor genius.tour.tag.instructor model_genius_tour_tag group_genius_instructor 1 1 1 1
13 access_quiz_user genius.quiz.user model_genius_quiz group_genius_user 1 0 0 0
14 access_quiz_instructor genius.quiz.instructor model_genius_quiz group_genius_instructor 1 1 1 1
15 access_question_user genius.quiz.question.user model_genius_quiz_question group_genius_user 1 0 0 0
16 access_question_instructor genius.quiz.question.instructor model_genius_quiz_question group_genius_instructor 1 1 1 1
17 access_answer_user genius.quiz.answer.user model_genius_quiz_answer group_genius_user 1 0 0 0
18 access_answer_instructor genius.quiz.answer.instructor model_genius_quiz_answer group_genius_instructor 1 1 1 1
19 access_attempt_user genius.quiz.attempt.user model_genius_quiz_attempt group_genius_user 1 1 1 0
20 access_attempt_instructor genius.quiz.attempt.instructor model_genius_quiz_attempt group_genius_instructor 1 1 1 1
21 access_response_user genius.quiz.response.user model_genius_quiz_response group_genius_user 1 1 1 0
22 access_response_instructor genius.quiz.response.instructor model_genius_quiz_response group_genius_instructor 1 1 1 1
23 access_leaderboard_user genius.leaderboard.user model_genius_leaderboard group_genius_user 1 0 0 0
24 access_leaderboard_admin genius.leaderboard.admin model_genius_leaderboard group_genius_admin 1 1 1 1
25 access_reminder_user genius.reminder.user model_genius_reminder group_genius_user 1 0 0 0
26 access_reminder_instructor genius.reminder.instructor model_genius_reminder group_genius_instructor 1 1 1 1

View File

@ -0,0 +1,155 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ============================================================ -->
<!-- Security Groups -->
<!-- ============================================================ -->
<!-- Base Category for Module -->
<record id="module_category_tour_genius" model="ir.module.category">
<field name="name">Tour Genius</field>
<field name="description">Interactive Training Platform</field>
<field name="sequence">50</field>
</record>
<!-- User Group: Can view and complete assigned training -->
<record id="group_genius_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="module_category_tour_genius"/>
<field name="comment">Can view and complete training assigned to them. Read-only access to training content.</field>
</record>
<!-- Instructor Group: Can create and manage training content -->
<record id="group_genius_instructor" model="res.groups">
<field name="name">Instructor</field>
<field name="category_id" ref="module_category_tour_genius"/>
<field name="implied_ids" eval="[(4, ref('group_genius_user'))]"/>
<field name="comment">Can create and manage training plans, topics, and quizzes. Can view all trainees' progress.</field>
</record>
<!-- Admin Group: Full access to everything -->
<record id="group_genius_admin" model="res.groups">
<field name="name">Administrator</field>
<field name="category_id" ref="module_category_tour_genius"/>
<field name="implied_ids" eval="[(4, ref('group_genius_instructor'))]"/>
<field name="comment">Full access to all training content, configuration, and registry management.</field>
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
</record>
<!-- ============================================================ -->
<!-- Record Rules -->
<!-- ============================================================ -->
<!-- Plan: Users see only public plans or assigned plans -->
<record id="rule_plan_user" model="ir.rule">
<field name="name">Training Plan: User Access</field>
<field name="model_id" ref="model_genius_plan"/>
<field name="groups" eval="[(4, ref('group_genius_user'))]"/>
<field name="domain_force">[
'|',
('is_public', '=', True),
('attendee_ids', 'in', [user.id])
]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Plan: Instructors can manage their own plans -->
<record id="rule_plan_instructor" model="ir.rule">
<field name="name">Training Plan: Instructor Access</field>
<field name="model_id" ref="model_genius_plan"/>
<field name="groups" eval="[(4, ref('group_genius_instructor'))]"/>
<field name="domain_force">[
'|',
('instructor_ids', 'in', [user.id]),
('create_uid', '=', user.id)
]</field>
</record>
<!-- Plan: Admin sees everything -->
<record id="rule_plan_admin" model="ir.rule">
<field name="name">Training Plan: Admin Full Access</field>
<field name="model_id" ref="model_genius_plan"/>
<field name="groups" eval="[(4, ref('group_genius_admin'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<!-- Progress: Users see only their own progress -->
<record id="rule_progress_user" model="ir.rule">
<field name="name">Progress: User Own Records</field>
<field name="model_id" ref="model_genius_progress"/>
<field name="groups" eval="[(4, ref('group_genius_user'))]"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Progress: Instructors see all progress in their plans -->
<record id="rule_progress_instructor" model="ir.rule">
<field name="name">Progress: Instructor Access</field>
<field name="model_id" ref="model_genius_progress"/>
<field name="groups" eval="[(4, ref('group_genius_instructor'))]"/>
<field name="domain_force">[
'|',
('plan_id.instructor_ids', 'in', [user.id]),
('plan_id.create_uid', '=', user.id)
]</field>
</record>
<!-- Quiz Attempt: Users see only their own attempts -->
<record id="rule_quiz_attempt_user" model="ir.rule">
<field name="name">Quiz Attempt: User Own Records</field>
<field name="model_id" ref="model_genius_quiz_attempt"/>
<field name="groups" eval="[(4, ref('group_genius_user'))]"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
</record>
<!-- Admin Rules: Full Access -->
<record id="rule_topic_admin" model="ir.rule">
<field name="name">Training Topic: Admin Full Access</field>
<field name="model_id" ref="model_genius_topic"/>
<field name="groups" eval="[(4, ref('group_genius_admin'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<record id="rule_step_admin" model="ir.rule">
<field name="name">Training Step: Admin Full Access</field>
<field name="model_id" ref="model_genius_topic_step"/>
<field name="groups" eval="[(4, ref('group_genius_admin'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<record id="rule_progress_admin" model="ir.rule">
<field name="name">Training Progress: Admin Full Access</field>
<field name="model_id" ref="model_genius_progress"/>
<field name="groups" eval="[(4, ref('group_genius_admin'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<!-- Multi-company rules -->
<record id="rule_plan_company" model="ir.rule">
<field name="name">Training Plan: Multi-Company</field>
<field name="model_id" ref="model_genius_plan"/>
<field name="domain_force">[
'|',
('company_id', '=', False),
('company_id', 'in', company_ids)
]</field>
</record>
<record id="rule_topic_company" model="ir.rule">
<field name="name">Training Topic: Multi-Company</field>
<field name="model_id" ref="model_genius_topic"/>
<field name="domain_force">[
'|',
('company_id', '=', False),
('company_id', 'in', company_ids)
]</field>
</record>
</data>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,453 @@
odoo.define('tour_genius.Dashboard', function (require) {
"use strict";
var AbstractAction = require('web.AbstractAction');
var core = require('web.core');
var ajax = require('web.ajax');
var session = require('web.session');
var Dialog = require('web.Dialog');
var tour = require('web_tour.tour'); // Add tour require
var QWeb = core.qweb;
var _t = core._t;
var GeniusDashboard = AbstractAction.extend({
template: 'tour_genius.Dashboard',
cssLibs: [],
jsLibs: [],
events: {
'click .btn-new-tour': '_onNewTour',
'click .btn-run-tour': '_onRunTour',
'click .btn-edit-tour': '_onEditTour',
'click .btn-delete-tour': '_onDeleteTour',
'click .tour-card': '_onTourCardClick',
'keyup .search-input': '_onSearchTours',
// Quiz buttons
'click .btn-quiz-start': '_onQuizStart',
'click .btn-quiz-retry': '_onQuizStart',
'click .btn-quiz-improve': '_onQuizStart', // Improve uses same handler as start
'click .btn-quiz-certificate': '_onViewCertificate',
'click .stat-card': '_onFilterTours',
},
init: function (parent, action) {
this._super.apply(this, arguments);
this.action = action;
this.tours = [];
this.isAdmin = false;
this.userProgress = 0;
},
willStart: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
return self._loadDashboardData();
});
},
start: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self._renderDashboard();
});
},
/**
* Load dashboard data from server
*/
_loadDashboardData: function () {
var self = this;
return this._rpc({
model: 'genius.topic',
method: 'get_dashboard_data',
args: [],
}).then(function (result) {
self.tours = result.tours || [];
self.isAdmin = result.is_admin || false;
self.userProgress = result.progress || 0;
self.stats = result.stats || {};
});
},
/**
* Called when the client action is re-attached to the DOM (e.g. back button)
* We reload the data to ensure it's fresh after changes in the form view.
*/
on_attach_callback: function () {
var self = this;
this._loadDashboardData().then(function() {
self._renderDashboard();
});
},
/**
* Render the dashboard
*/
_renderDashboard: function () {
var self = this;
this.$('.o_genius_dashboard_content').html(
QWeb.render('tour_genius.DashboardContent', {
tours: self.tours,
isAdmin: self.isAdmin,
userProgress: self.userProgress,
stats: self.stats,
})
);
},
/**
* Create new tour - Opens Recorder directly (no dialog)
* Tour Name and Module are entered in the Recorder Panel itself
*/
_onNewTour: function (ev) {
ev.preventDefault();
ev.stopPropagation();
// Redirect to Recorder in "New Tour" mode
// The Recorder will handle tour creation on first step save
window.location.href = '/web?genius_recorder=new';
},
/**
* Run a tour - Comprehensive cleanup and robust activation
* Handles all scenarios including incomplete tours and URL matching
*/
_onRunTour: function (ev) {
ev.preventDefault();
ev.stopPropagation();
var self = this;
var tourId = $(ev.currentTarget).data('tour-id');
var tourName = 'genius_tour_' + tourId;
console.log('[Tour Genius] Running tour:', tourName);
// ============================================================
// PHASE 1: Complete cleanup of ALL tour state
// ============================================================
// 1a. Destroy all active tooltips
if (tour.active_tooltips) {
Object.keys(tour.active_tooltips).forEach(function(key) {
try {
var tip = tour.active_tooltips[key];
if (tip) {
if (tip.widget && tip.widget.destroy) {
tip.widget.destroy();
}
if (tip.$anchor) {
tip.$anchor.off('.tour_tip');
}
}
} catch (e) {
console.warn('[Tour Genius] Error cleaning tooltip:', e);
}
});
tour.active_tooltips = {};
}
// 1b. Clear running tour state
tour.running_tour = null;
tour.paused = false;
// 1c. Clear ALL localStorage items related to tours
// Use reverse loop to avoid skipping when removing items
var keysToRemove = [];
for (var i = 0; i < window.localStorage.length; i++) {
var key = window.localStorage.key(i);
if (key && (
key.startsWith('debugging_tour_') ||
key.startsWith('tour_manager_') ||
key.startsWith('tour_step_') ||
key.endsWith('_was_active')
)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(function(key) {
window.localStorage.removeItem(key);
});
// 1d. Reset current_step for ALL registered tours (including Odoo standard)
// This ensures any running Odoo tour is stopped when we start a Genius tour
if (tour.tours) {
Object.keys(tour.tours).forEach(function(name) {
var t = tour.tours[name];
if (t) {
t.current_step = 0;
// Only reset ready for genius tours (to force re-registration)
if (name.startsWith('genius_tour_')) {
t.ready = false;
}
}
});
}
console.log('[Tour Genius] Cleanup complete, removed', keysToRemove.length, 'localStorage items');
// ============================================================
// PHASE 2: Activate the requested tour with forced reload
// ============================================================
function activateTour() {
var targetTour = tour.tours && tour.tours[tourName];
if (!targetTour) {
Dialog.alert(self, _t("Tour not found. Make sure the tour has at least one step. Try refreshing the page."));
return;
}
if (!targetTour.steps || targetTour.steps.length === 0) {
Dialog.alert(self, _t("This tour has no steps. Please add steps before running."));
return;
}
var tourUrl = targetTour.url || '/web';
// Set the debugging flag (tells Odoo to activate this tour on load)
// NOTE: Do NOT set 'running_tour' - that triggers AUTOMATED test mode!
// Only 'debugging_tour_X' is needed for interactive mode
window.localStorage.setItem('debugging_tour_' + tourName, 'true');
// Force page reload by appending timestamp
// This ensures the tour_loader.js picks up the debugging flag
var targetUrl = window.location.origin + tourUrl;
// Parse and rebuild URL with timestamp to force reload
try {
var url = new URL(targetUrl);
url.searchParams.set('genius_tour_run', Date.now());
window.location.href = url.href;
} catch (e) {
// Fallback: append timestamp manually
var separator = tourUrl.indexOf('?') >= 0 ? '&' : '?';
window.location.href = targetUrl + separator + 'genius_tour_run=' + Date.now();
}
}
// Check if tour exists, if not try to reload
if (tour.tours && tour.tours[tourName]) {
activateTour();
} else {
// Tour not found - try reloading tour definitions
console.log('[Tour Genius] Tour not found, reloading definitions...');
var tourLoader = require('tour_genius.tour_loader');
if (tourLoader && tourLoader.registerAllTours) {
tourLoader.registerAllTours();
// Wait for registration, then try again
setTimeout(function() {
if (tour.tours && tour.tours[tourName]) {
activateTour();
} else {
Dialog.alert(self, _t("Tour not found or not loaded. Please refresh the page and try again."));
}
}, 1500);
} else {
Dialog.alert(self, _t("Tour not found. Please refresh the page and try again."));
}
}
},
/**
* Edit a tour (Admin only)
*/
_onEditTour: function (ev) {
ev.preventDefault();
ev.stopPropagation();
var self = this;
var tourId = $(ev.currentTarget).data('tour-id');
this.do_action({
type: 'ir.actions.act_window',
name: _t('Edit Tour'),
res_model: 'genius.topic',
res_id: tourId,
view_mode: 'form',
views: [[false, 'form']],
target: 'current',
context: {
'from_genius_dashboard': true,
},
}, {
// Return to dashboard after closing
on_close: function () {
self._loadDashboardData().then(function() {
self._renderDashboard();
});
}
});
},
/**
* Delete a tour (Admin only)
*/
_onDeleteTour: function (ev) {
ev.preventDefault();
ev.stopPropagation();
var tourId = $(ev.currentTarget).data('tour-id');
var self = this;
Dialog.confirm(this, _t("Are you sure you want to delete this tour?"), {
confirm_callback: function () {
self._rpc({
model: 'genius.topic',
method: 'unlink',
args: [[parseInt(tourId)]],
}).then(function () {
self._loadDashboardData().then(function() {
self._renderDashboard();
});
}).catch(function (error) {
// Handle deletion blocked by unlink() constraints
var message = error.message && error.message.data && error.message.data.message
? error.message.data.message
: _t('Cannot delete this tour. It may have user progress or completions.');
Dialog.alert(self, message, {
title: _t('Deletion Blocked'),
});
});
}
});
},
/**
* Click on tour card (view details)
*/
_onTourCardClick: function (ev) {
var tourId = $(ev.currentTarget).data('tour-id');
// If clicking buttons, don't trigger card click
if (!$(ev.target).closest('.btn').length) {
// Genius Logic: Card Click -> Edit/View Details (Form View)
this._onEditTour(ev);
}
},
/**
* Filter tours by status (Stat Cards)
*/
_onFilterTours: function (ev) {
ev.preventDefault();
var $card = $(ev.currentTarget);
var filter = $card.data('filter');
// Ignore progress card or undefined filters
if (!filter || filter === 'progress') return;
// Visual Feedback
this.$('.stat-card').removeClass('active');
$card.addClass('active');
// Reset search input
this.$('.search-input').val('');
// Filter Logic
this.$('.tour-card').each(function () {
var $tour = $(this);
var status = $tour.data('status');
// Match logic:
// 1. 'all' shows everything
// 2. Exact match
// 3. 'completed' should also include 'verified' (since verified means completed + passed quiz)
if (filter === 'all' ||
status === filter ||
(filter === 'completed' && status === 'verified')) {
$tour.fadeIn(200);
} else {
$tour.hide();
}
});
},
/**
* Search tours
*/
_onSearchTours: function (ev) {
var query = $(ev.currentTarget).val().toLowerCase();
var self = this;
this.$('.tour-card').each(function () {
var name = $(this).find('.tour-name').text().toLowerCase();
var module = $(this).find('.tour-module').text().toLowerCase();
if (name.indexOf(query) >= 0 || module.indexOf(query) >= 0) {
$(this).show();
} else {
$(this).hide();
}
});
},
/**
* Start/Retry Quiz from dashboard
*/
_onQuizStart: function (ev) {
ev.preventDefault();
ev.stopPropagation();
var self = this;
var tourId = $(ev.currentTarget).data('tour-id');
// Find tour data
var tour = this.tours.find(function(t) { return t.id === tourId; });
if (!tour || !tour.quiz_id) {
console.warn('[Tour Genius] No quiz found for tour:', tourId);
return;
}
// Import and open GeniusQuizPopup
var QuizModule = require('tour_genius.GeniusQuizPopup');
var GeniusQuizPopup = QuizModule.GeniusQuizPopup;
var popup = new GeniusQuizPopup(self, {
quizId: tour.quiz_id,
topicId: tourId,
quizName: tour.name + ' Quiz',
showCertificate: false,
});
popup.appendTo(document.body).then(function() {
console.log('[Tour Genius] Quiz popup opened for tour:', tour.name);
});
},
/**
* View Certificate from dashboard
*/
_onViewCertificate: function (ev) {
ev.preventDefault();
ev.stopPropagation();
var self = this;
var tourId = $(ev.currentTarget).data('tour-id');
// Find tour data
var tour = this.tours.find(function(t) { return t.id === tourId; });
if (!tour || !tour.quiz_id) {
console.warn('[Tour Genius] No quiz found for tour:', tourId);
return;
}
// Open GeniusQuizPopup in certificate mode
var GeniusQuizPopup = require('tour_genius.GeniusQuizPopup');
var popup = new GeniusQuizPopup({
quizId: tour.quiz_id,
topicId: tourId,
quizName: tour.name,
showCertificate: true,
existingScore: tour.quiz_score || 0,
});
popup.appendTo(document.body).then(function() {
console.log('[Tour Genius] Certificate view opened for tour:', tour.name);
});
},
});
core.action_registry.add('genius_dashboard', GeniusDashboard);
return GeniusDashboard;
});

View File

@ -0,0 +1,156 @@
/**
* Tour Genius - Celebration Widget
* ================================
* A "genius" themed celebration widget for Tour Genius completion.
* Features lightbulb "eureka" moment with particle effects.
*/
odoo.define('tour_genius.GeniusCelebration', function (require) {
"use strict";
var Widget = require('web.Widget');
var core = require('web.core');
var _t = core._t;
var GeniusCelebration = Widget.extend({
template: 'tour_genius.genius_celebration_v2',
xmlDependencies: ['/tour_genius/static/src/xml/genius_celebration.xml'],
/**
* @override
* @constructor
* @param {Object} [options]
* @param {string} [options.message] Message to display
* @param {string} [options.fadeout] Delay: 'fast', 'medium', 'slow', 'no'
*/
init: function (options) {
this._super.apply(this, arguments);
var celebrationDelay = {slow: 5500, medium: 4500, fast: 3000, no: false};
this.options = _.defaults(options || {}, {
fadeout: 'medium',
message: _t('<strong>Well Done!</strong> You completed the tour!'),
quiz: null, // Default quiz to null
});
this.delay = celebrationDelay[this.options.fadeout];
},
events: {
'click .o_take_quiz_btn': '_onTakeQuiz',
'click .o_view_certificate_btn': '_onViewCertificate',
'click .o_genius_close_btn': '_onClose',
},
/**
* @override
*/
start: function () {
var self = this;
// Clean up any persistent hand from tour
if (typeof window.cleanupGeniusHand === 'function') {
window.cleanupGeniusHand();
}
// Destroy on click outside
setTimeout(function () {
core.bus.on('click', self, function (ev) {
// Safety check: className might be SVGAnimatedString for SVG elements
var className = ev.target.className;
var classStr = (typeof className === 'string') ? className : (className.baseVal || '');
if (ev.originalEvent &&
classStr.indexOf('o_genius') === -1 &&
classStr.indexOf('o_take_quiz_btn') === -1 &&
classStr.indexOf('o_view_certificate_btn') === -1 &&
$(ev.target).parents('.o_genius_reward').length === 0) {
this.destroy(); // Fix: Bind to proper context or use self
}
});
});
// Auto fadeout after delay
if (this.delay) {
setTimeout(function () {
self.$el.addClass('o_genius_fading');
setTimeout(function () {
self.destroy();
}, 800);
}, this.delay);
}
// Set the message content
this.$('.o_genius_msg_content').html(this.options.message);
return this._super.apply(this, arguments);
},
/**
* @override
* Clean up bus listener to prevent memory leaks
*/
destroy: function () {
core.bus.off('click', this);
this._super.apply(this, arguments);
},
_onClose: function (ev) {
ev.preventDefault();
ev.stopPropagation();
this.destroy();
},
_onTakeQuiz: function (ev) {
ev.preventDefault();
ev.stopPropagation();
var self = this;
var quiz = this.options.quiz;
// Open the Genius Quiz Popup
// FIX: GeniusQuizPopup is returned as a property of the module object
var GeniusQuizPopupModule = require('tour_genius.GeniusQuizPopup');
var GeniusQuizPopup = GeniusQuizPopupModule.GeniusQuizPopup;
var popup = new GeniusQuizPopup(null, {
quizId: quiz.id,
topicId: quiz.source_tour_id,
quizName: quiz.name,
});
popup.appendTo($('body'));
// Close the celebration
self.destroy();
},
_onViewCertificate: function (ev) {
ev.preventDefault();
ev.stopPropagation();
var self = this;
var quiz = this.options.quiz;
// Get attempt_id via RPC then open PDF directly
var rpc = require('web.rpc');
rpc.query({
model: 'genius.topic',
method: 'get_certificate_data',
args: [[quiz.source_tour_id]],
}).then(function (data) {
if (data && data.attempt_id) {
// Open PDF in new tab
window.open('/tour_genius/certificate/view/' + data.attempt_id, '_blank');
} else {
console.warn('[GeniusCelebration] No certificate found');
}
// Close the celebration
self.destroy();
}).catch(function (err) {
console.error('[GeniusCelebration] Error getting certificate:', err);
self.destroy();
});
},
});
return GeniusCelebration;
});

View File

@ -0,0 +1,866 @@
/**
* Tour Genius - Genius Quiz Popup Widget
* ========================================
* Beautiful popup quiz experience with glassmorphism design.
* Displays one question at a time with smooth animations.
*/
odoo.define('tour_genius.GeniusQuizPopup', function (require) {
"use strict";
var Widget = require('web.Widget');
var AbstractField = require('web.AbstractField');
var rpc = require('web.rpc');
var core = require('web.core');
var _t = core._t;
var QWeb = core.qweb;
var GeniusQuizPopup = Widget.extend({
template: 'tour_genius.GeniusQuizPopup_v2',
xmlDependencies: ['/tour_genius/static/src/xml/genius_quiz_popup.xml'],
events: {
'click .o_answer_option': '_onAnswerSelect',
'click .o_quiz_next': '_onNext',
'click .o_quiz_prev': '_onPrev',
'click .o_quiz_submit': '_onSubmit',
'click .o_quiz_close': '_onClose',
'click .o_quiz_retry': '_onRetry',
'change .o_review_answers_toggle': '_onReviewToggle',
'keyup .o_short_answer_input': '_onShortAnswerChange',
'click .o_confirm_yes': '_onConfirmYes',
'click .o_confirm_no': '_onConfirmNo',
// Ordering drag-and-drop
'dragstart .o_ordering_item': '_onOrderingDragStart',
'dragover .o_ordering_item': '_onOrderingDragOver',
'drop .o_ordering_item': '_onOrderingDrop',
'dragend .o_ordering_item': '_onOrderingDragEnd',
},
/**
* @param {Object} options
* @param {number} options.quizId - Quiz ID
* @param {number} options.topicId - Source Topic ID
* @param {string} options.quizName - Quiz Name
* @param {boolean} options.showCertificate - Show certificate directly (for passed quizzes)
* @param {number} options.existingScore - Existing score (when showing certificate)
*/
init: function (parent, options) {
this._super(parent);
options = options || {};
this.quizId = options.quizId;
this.topicId = options.topicId;
this.quizName = options.quizName || 'Quiz';
this.showCertificate = options.showCertificate || false;
this.existingScore = options.existingScore || 0;
this.isPreview = options.isPreview || false;
this.attemptId = null;
this.questions = [];
this.currentIndex = 0;
this.responses = {};
this.timeLimit = 0;
this.timerInterval = null;
this.timeRemaining = 0;
this.isSubmitted = false;
this.results = null;
},
willStart: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
// If showing certificate, skip loading quiz data
if (self.showCertificate) {
return self._loadCertificateData();
}
return self._loadQuizData();
});
},
start: function () {
var self = this;
console.log('[GeniusQuizPopup] Start called. Template:', this.template);
console.log('[GeniusQuizPopup] $el:', this.$el);
return this._super.apply(this, arguments).then(function () {
console.log('🚀 ANTIGRAVITY: GeniusQuizPopup Loaded v2 - Ghost Code Exorcised');
if (self.showCertificate) {
// console.log('[GeniusQuizPopup] Showing certificate');
// self._renderCertificate(); // REMOVED: Ghost code causing legacy UI
// Fallback to results if certificate requested but method missing
self._renderResults();
} else {
console.log('[GeniusQuizPopup] Rendering Question');
self._renderQuestion();
self._startTimer();
}
});
},
destroy: function () {
if (this.timerInterval) {
clearInterval(this.timerInterval);
}
this._super.apply(this, arguments);
},
// =====================================================================
// Data Loading
// =====================================================================
_loadQuizData: function () {
var self = this;
var def;
if (this.isPreview && this.quizId) {
// Preview Mode: Load directly from Quiz model
def = rpc.query({
model: 'genius.quiz',
method: 'action_start_quiz_preview',
args: [[this.quizId]],
});
} else {
// Normal Mode: Load via Topic (Tour Context)
def = rpc.query({
model: 'genius.topic',
method: 'action_start_quiz_popup',
args: [[this.topicId]],
});
}
return def.then(function (data) {
if (!data || data.error) {
self.loadError = data ? data.error : _t('Failed to load quiz');
return;
}
self.attemptId = data.attempt_id;
self.questions = data.questions || [];
self.timeLimit = data.time_limit_minutes || 0;
self.timeRemaining = self.timeLimit * 60; // Convert to seconds
self.passingScore = data.passing_score || 70;
// Initialize responses
self.questions.forEach(function (q) {
self.responses[q.id] = {
selectedIds: [],
textAnswer: '',
orderSequence: q.type === 'ordering' ? q.answers.map(function(a) { return a.id; }) : []
};
});
}).catch(function (err) {
console.error('[GeniusQuizPopup] Error loading quiz:', err);
// Handle concurrent access error (row locked)
if (err.message && err.message.includes('could not obtain lock')) {
self.loadError = _t('Quiz is being opened in another tab. Please close other tabs and try again.');
} else {
self.loadError = _t('Failed to load quiz. Please try again.');
}
});
},
_loadCertificateData: function () {
var self = this;
return rpc.query({
model: 'genius.topic',
method: 'get_certificate_data',
args: [[this.topicId]],
}).then(function (data) {
if (!data || data.error) {
self.loadError = data ? data.error : 'Failed to load certificate';
return;
}
// Set results for certificate display
self.results = {
attempt_id: data.attempt_id, // For PDF download
score: data.score || self.existingScore,
is_passed: true,
points_earned: data.points_earned || 0,
points_possible: data.points_possible || 0,
time_taken: data.time_taken || 0,
user_name: data.user_name,
date: data.date,
can_retry: false,
};
self.passingScore = data.passing_score || 70;
}).catch(function (err) {
console.error('[GeniusQuizPopup] Error loading certificate:', err);
// Fallback: create minimal results from existing data
self.results = {
score: self.existingScore,
is_passed: true,
points_earned: 0,
points_possible: 0,
time_taken: 0,
user_name: 'User',
date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
can_retry: false,
};
self.passingScore = 70;
});
},
// =====================================================================
// Rendering
// =====================================================================
_renderQuestion: function () {
if (this.loadError) {
this.$('.o_quiz_question_container').html(
'<div class="o_quiz_error">' +
'<i class="fa fa-exclamation-circle"></i> ' + this.loadError +
'</div>'
);
return;
}
if (this.questions.length === 0) {
this.$('.o_quiz_question_container').html(
'<div class="o_quiz_error">' +
'<i class="fa fa-info-circle"></i> No questions in this quiz' +
'</div>'
);
return;
}
var question = this.questions[this.currentIndex];
var response = this.responses[question.id];
// Render question content
this.$('.o_quiz_question_container').html(
QWeb.render('tour_genius.QuizQuestion', {
question: question,
index: this.currentIndex,
total: this.questions.length,
response: response,
})
);
// Update progress
var progress = ((this.currentIndex + 1) / this.questions.length) * 100;
this.$('.o_quiz_progress_fill').css('width', progress + '%');
this.$('.o_quiz_progress_text').text((this.currentIndex + 1) + ' / ' + this.questions.length);
// Update navigation buttons
this.$('.o_quiz_prev').prop('disabled', this.currentIndex === 0);
var isLast = this.currentIndex === this.questions.length - 1;
this.$('.o_quiz_next').toggle(!isLast);
this.$('.o_quiz_submit').toggle(isLast);
// Animation
var self = this;
this.$('.o_quiz_question_container').addClass('o_quiz_fade_in');
setTimeout(function () {
self.$('.o_quiz_question_container').removeClass('o_quiz_fade_in');
}, 300);
},
_renderResults: function () {
// Render V2 template to bypass cache
this.$('.o_quiz_body').html(
QWeb.render('tour_genius.QuizResults_v2', {
results: this.results,
quizName: this.quizName,
passingScore: this.passingScore,
})
);
// Update header
this.$('.o_quiz_timer').hide();
this.$('.o_quiz_title').text(this.results.is_passed ? '🎉 Congratulations!' : '📝 Quiz Complete');
},
// =====================================================================
// Timer
// =====================================================================
_startTimer: function () {
if (this.timeLimit <= 0) {
this.$('.o_quiz_timer').hide();
return;
}
var self = this;
this._updateTimerDisplay();
this.timerInterval = setInterval(function () {
self.timeRemaining--;
self._updateTimerDisplay();
if (self.timeRemaining <= 0) {
clearInterval(self.timerInterval);
self._autoSubmit();
}
}, 1000);
},
_updateTimerDisplay: function () {
var minutes = Math.floor(this.timeRemaining / 60);
var seconds = this.timeRemaining % 60;
var display = String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0');
this.$('.o_quiz_timer_value').text(display);
// Warning colors
if (this.timeRemaining <= 60) {
this.$('.o_quiz_timer').addClass('o_quiz_timer_critical');
} else if (this.timeRemaining <= 120) {
this.$('.o_quiz_timer').addClass('o_quiz_timer_warning');
}
},
// =====================================================================
// Event Handlers
// =====================================================================
_onAnswerSelect: function (ev) {
var $option = $(ev.currentTarget);
var answerId = parseInt($option.data('answer-id'));
var question = this.questions[this.currentIndex];
var response = this.responses[question.id];
var $icon = $option.find('.o_answer_indicator i');
if (question.type === 'multiple') {
// Toggle selection for multiple choice (checkbox style)
var idx = response.selectedIds.indexOf(answerId);
if (idx >= 0) {
// Deselect
response.selectedIds.splice(idx, 1);
$option.removeClass('selected');
$icon.removeClass('fa-check-square').addClass('fa-square-o');
} else {
// Select
response.selectedIds.push(answerId);
$option.addClass('selected');
$icon.removeClass('fa-square-o').addClass('fa-check-square');
}
} else {
// Single selection (radio style - including true/false)
response.selectedIds = [answerId];
// Reset all options first
this.$('.o_answer_option').removeClass('selected');
this.$('.o_answer_option .o_answer_indicator i')
.removeClass('fa-dot-circle-o')
.addClass('fa-circle-o');
// Select current option
$option.addClass('selected');
$icon.removeClass('fa-circle-o').addClass('fa-dot-circle-o');
}
},
_onShortAnswerChange: function (ev) {
var question = this.questions[this.currentIndex];
this.responses[question.id].textAnswer = $(ev.currentTarget).val();
},
// =====================================================================
// Ordering Drag-and-Drop Handlers
// =====================================================================
_onOrderingDragStart: function (ev) {
var $item = $(ev.currentTarget);
this._draggedOrderItem = $item;
$item.addClass('o_ordering_dragging');
ev.originalEvent.dataTransfer.effectAllowed = 'move';
ev.originalEvent.dataTransfer.setData('text/plain', $item.data('answer-id'));
},
_onOrderingDragOver: function (ev) {
ev.preventDefault();
ev.originalEvent.dataTransfer.dropEffect = 'move';
var $target = $(ev.currentTarget);
if (!this._draggedOrderItem || $target[0] === this._draggedOrderItem[0]) {
return;
}
// Visual feedback
this.$('.o_ordering_item').removeClass('o_ordering_drop_target');
$target.addClass('o_ordering_drop_target');
},
_onOrderingDrop: function (ev) {
ev.preventDefault();
var $target = $(ev.currentTarget);
if (!this._draggedOrderItem || $target[0] === this._draggedOrderItem[0]) {
return;
}
// Reorder in DOM
var $container = this.$('.o_ordering_items');
var items = $container.children('.o_ordering_item').toArray();
var draggedIndex = items.indexOf(this._draggedOrderItem[0]);
var targetIndex = items.indexOf($target[0]);
if (draggedIndex < targetIndex) {
$target.after(this._draggedOrderItem);
} else {
$target.before(this._draggedOrderItem);
}
// Update response
this._updateOrderingSequence();
// Update visual numbers
this._updateOrderingNumbers();
},
_onOrderingDragEnd: function (ev) {
this.$('.o_ordering_item').removeClass('o_ordering_dragging o_ordering_drop_target');
this._draggedOrderItem = null;
},
_updateOrderingSequence: function () {
var question = this.questions[this.currentIndex];
var response = this.responses[question.id];
// Get current order from DOM
var newSequence = [];
this.$('.o_ordering_item').each(function () {
newSequence.push(parseInt($(this).data('answer-id')));
});
response.orderSequence = newSequence;
},
_updateOrderingNumbers: function () {
this.$('.o_ordering_item').each(function (index) {
$(this).find('.o_ordering_number').text(index + 1);
});
},
_onNext: function () {
if (this.currentIndex < this.questions.length - 1) {
this.currentIndex++;
this._renderQuestion();
}
},
_onPrev: function () {
if (this.currentIndex > 0) {
this.currentIndex--;
this._renderQuestion();
}
},
_onSubmit: function () {
// Check for unanswered questions
var unansweredCount = this._countUnansweredQuestions();
if (unansweredCount > 0) {
// Show custom confirmation overlay instead of native confirm()
this._showConfirmationOverlay(unansweredCount);
return;
}
this._submitQuiz();
},
/**
* Show custom confirmation overlay for unanswered questions
*/
_showConfirmationOverlay: function (unansweredCount) {
var overlayHtml = QWeb.render('tour_genius.QuizConfirmSubmit', {
unansweredCount: unansweredCount,
});
this.$('.o_quiz_modal').append(overlayHtml);
},
/**
* Hide the confirmation overlay
*/
_hideConfirmationOverlay: function () {
this.$('.o_quiz_confirm_overlay').remove();
},
/**
* User confirmed to submit anyway
*/
_onConfirmYes: function () {
this._hideConfirmationOverlay();
this._submitQuiz();
},
/**
* User cancelled submission
*/
_onConfirmNo: function () {
this._hideConfirmationOverlay();
},
/**
* Count how many questions have no response
*/
_countUnansweredQuestions: function () {
var self = this;
var count = 0;
this.questions.forEach(function (q) {
var response = self.responses[q.id];
if (!response) {
count++;
return;
}
if (q.type === 'short_answer' || q.type === 'fill_blank') {
// Text-based answers: check if text is empty
if (!response.textAnswer || response.textAnswer.trim() === '') {
count++;
}
} else if (q.type === 'ordering') {
// Ordering: considered answered if user has interacted (sequence differs from initial)
// For simplicity, ordering is always considered "answered" since initial state is valid
// The user's current order is their answer
} else {
// Single/Multiple: check if any selection made
if (!response.selectedIds || response.selectedIds.length === 0) {
count++;
}
}
});
return count;
},
_autoSubmit: function () {
// Time ran out
this._submitQuiz();
},
_submitQuiz: function () {
var self = this;
if (this.isSubmitted) return;
this.isSubmitted = true;
// Show loading
this.$('.o_quiz_submit').prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Submitting...');
// Prepare responses data
var responsesData = [];
for (var qId in this.responses) {
var resp = this.responses[qId];
var data = {
question_id: parseInt(qId),
selected_answer_ids: resp.selectedIds,
text_answer: resp.textAnswer,
};
// For ordering questions, send the sequence as selected_answer_ids
// The backend will compare this order with the correct order
if (resp.orderSequence && resp.orderSequence.length > 0) {
data.selected_answer_ids = resp.orderSequence;
}
responsesData.push(data);
}
rpc.query({
model: 'genius.quiz.attempt',
method: 'action_submit_from_popup',
args: [[this.attemptId], responsesData],
}).then(function (results) {
self.results = results;
self._renderResults();
if (self.timerInterval) {
clearInterval(self.timerInterval);
}
}).catch(function (err) {
console.error('[GeniusQuizPopup] Submit error:', err);
self.$('.o_quiz_submit').prop('disabled', false).html('<i class="fa fa-check"></i> Submit');
self.isSubmitted = false;
});
},
_onClose: function () {
// Confirm before closing if quiz is in progress
if (!this.showCertificate && !this.isSubmitted && this.questions.length > 0) {
var confirmed = confirm(_t('Are you sure you want to exit? Your progress will be lost.'));
if (!confirmed) {
return;
}
// Genius Logic: Delete the attempt if user aborts (Normal Mode)
if (this.attemptId && !this.isPreview) {
rpc.query({
model: 'genius.quiz.attempt',
method: 'action_cancel_attempt',
args: [[this.attemptId]],
});
}
}
// Genius Logic: For Test Mode, delete the attempt when closing ONLY if not submitted
// (If submitted, user saw results - we keep for debugging/history, or delete on next open)
if (this.isPreview && this.attemptId && !this.isSubmitted) {
rpc.query({
model: 'genius.quiz.attempt',
method: 'action_cancel_attempt',
args: [[this.attemptId]],
});
}
this.destroy();
},
_onRetry: function () {
// Reset state
this.isSubmitted = false;
this.results = null;
this.currentIndex = 0;
this.responses = {};
this.timeRemaining = this.timeLimit * 60;
// Reset progress bar immediately for visual feedback
this.$('.o_quiz_progress_fill').css('width', '0%');
this.$('.o_quiz_progress_text').text('1 / ?');
// Reset timer display
this.$('.o_quiz_timer').removeClass('o_quiz_timer_warning o_quiz_timer_critical');
var self = this;
this._loadQuizData().then(function () {
// Re-render the body using QWeb template for consistency
var bodyHtml = QWeb.render('tour_genius.QuizBody', {});
self.$('.o_quiz_body').html(bodyHtml);
self._renderQuestion();
self._startTimer();
});
},
/**
* Toggle Answer Review section when switch is changed
*/
_onReviewToggle: function (ev) {
var self = this;
var isChecked = $(ev.target).is(':checked');
var $reviewSection = this.$('.o_answer_review_section');
if (!isChecked) {
// Hide the section
$reviewSection.slideUp(300);
return;
}
// Show the section - build content if empty
if ($reviewSection.children().length === 0) {
var correctAnswers = this.results.correct_answers || {};
var html = '<h5 style="color: #667eea; margin-bottom: 15px; text-align: center; font-weight: 600;"><i class="fa fa-list"></i> Answer Review</h5>';
this.questions.forEach(function (q, idx) {
var review = correctAnswers[q.id] || {};
var isCorrect = review.is_correct;
var statusClass = isCorrect ? 'text-success' : 'text-danger';
var statusIcon = isCorrect ? 'fa-check-circle' : 'fa-times-circle';
var borderColor = isCorrect ? '#48bb78' : '#e53e3e';
var bgColor = isCorrect ? '#f0fff4' : '#fff5f5';
html += '<div class="o_review_question" style="padding: 15px; margin-bottom: 12px; background: ' + bgColor + '; border-radius: 10px; border-left: 4px solid ' + borderColor + ';">';
// Question header
html += '<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px;">';
html += '<strong style="color: #2d3748; flex: 1;">Q' + (idx + 1) + ': ' + self._escapeHtml(q.text || '') + '</strong>';
html += '<span class="' + statusClass + '" style="margin-left: 10px; font-size: 18px;"><i class="fa ' + statusIcon + '"></i></span>';
html += '</div>';
// Answer details
html += '<div style="font-size: 13px; color: #4a5568;">';
if (isCorrect) {
// User is correct -> Clean display (Your answer is the correct one)
var displayAnswer = '';
if (q.type === 'short_answer' || q.type === 'fill_blank') {
displayAnswer = review.user_answer || '(empty)';
} else if (q.type === 'ordering') {
displayAnswer = (review.user_order || []).join(' → ') || '(not ordered)';
} else {
var uAnswers = review.user_answers || [];
displayAnswer = uAnswers.length > 0 ? uAnswers.join(', ') : '(no answer)';
}
html += '<div style="margin-bottom: 6px;"><span style="color: #718096;">Your answer:</span> <span style="font-weight: 500;">' + self._escapeHtml(displayAnswer) + '</span> ✅</div>';
// Correct answer line HIDDEN for clean look
} else {
// User is incorrect -> Show both
if (q.type === 'short_answer' || q.type === 'fill_blank') {
html += '<div style="margin-bottom: 6px;"><span style="color: #718096;">Your answer:</span> <span style="font-weight: 500;">' + self._escapeHtml(review.user_answer || '(empty)') + '</span> ❌</div>';
html += '<div><span style="color: #718096;">Correct answer:</span> <span style="color: #38a169; font-weight: 500;">' + self._escapeHtml(review.correct_answer || '-') + '</span> ✅</div>';
} else if (q.type === 'ordering') {
var userOrder = (review.user_order || []).join(' → ') || '(not ordered)';
var correctOrder = (review.correct_order || []).join(' → ') || '-';
html += '<div style="margin-bottom: 6px;"><span style="color: #718096;">Your order:</span> <span style="font-weight: 500;">' + self._escapeHtml(userOrder) + '</span> ❌</div>';
html += '<div><span style="color: #718096;">Correct order:</span> <span style="color: #38a169; font-weight: 500;">' + self._escapeHtml(correctOrder) + '</span> ✅</div>';
} else {
var userAnswers = review.user_answers || [];
var correctAnsArray = review.correct_answers || [];
var uStr = userAnswers.length > 0 ? userAnswers.map(function(a) { return self._escapeHtml(a); }).join(', ') : '(no answer)';
var cStr = correctAnsArray.length > 0 ? correctAnsArray.map(function(a) { return self._escapeHtml(a); }).join(', ') : '-';
html += '<div style="margin-bottom: 6px;"><span style="color: #718096;">Your answer:</span> <span style="font-weight: 500;">' + uStr + '</span> ❌</div>';
html += '<div><span style="color: #718096;">Correct answer:</span> <span style="color: #38a169; font-weight: 500;">' + cStr + '</span> ✅</div>';
}
}
html += '</div>';
// Explanation
if (review.explanation) {
html += '<div style="margin-top: 10px; font-size: 12px; color: #5a67d8; padding: 10px; background: rgba(102, 126, 234, 0.1); border-radius: 6px;">';
html += '<i class="fa fa-lightbulb-o" style="color: #f6ad55; margin-right: 5px;"></i>' + review.explanation;
html += '</div>';
}
html += '</div>';
});
$reviewSection.html(html);
}
// Show with animation
$reviewSection.slideDown(300);
},
/**
* Helper to escape HTML entities
*/
_escapeHtml: function (text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
});
/**
* Quiz Preview Client Action
* Wrapper that opens the widget in a Dialog
*/
var AbstractAction = require('web.AbstractAction');
var Dialog = require('web.Dialog');
var GeniusQuizPreviewAction = AbstractAction.extend({
init: function (parent, action) {
this._super.apply(this, arguments);
this.action = action;
this.params = action.params || {};
},
start: function () {
var self = this;
var params = this.params || {};
if (!params.quiz_id) {
this.do_warn('Error', 'No Quiz ID provided for preview');
return Promise.resolve();
}
var dialog = new Dialog(this, {
title: _t('Test Mode: ') + (params.quiz_name || 'Quiz'),
size: 'medium',
$content: $('<div>'), // Placeholder
buttons: [], // No default buttons, widget handles navigation
renderHeader: true,
renderFooter: false,
});
var widget = new GeniusQuizPopup(dialog, {
quizId: params.quiz_id,
quizName: params.quiz_name,
isPreview: true
});
return widget.appendTo(dialog.$content).then(function() {
dialog.open();
// Close the client action wrapper (return to previous screen)
// BUT wait for dialog close?
// Actually, if we are a client action, we replaced the previous screen.
// We need to handle "closing" by going back.
dialog.on('closed', self, function () {
self.do_action({type: 'ir.actions.client', tag: 'history_back'});
});
// Listen for widget close request
widget.on('close_dialog', self, function() {
dialog.close();
});
});
},
});
/**
* Genius Quiz Test Button Widget
* A standalone widget to trigger the quiz popup directly from the form view
*/
var GeniusQuizTestButton = AbstractField.extend({
template: null,
tagName: 'div',
className: 'genius-test-btn-wrapper',
events: _.extend({}, AbstractField.prototype.events, {
'click .btn-genius-test': '_onTestQuiz',
}),
_render: function () {
this.$el.html(
'<button class="btn btn-genius-test">' +
'<i class="fa fa-gamepad"></i> ' +
'<span>Test Quiz</span>' +
'</button>'
);
},
_onTestQuiz: function (ev) {
ev.preventDefault();
ev.stopPropagation();
var recordData = this.record && this.record.data;
if (!recordData) {
// Fallback if structure is different
recordData = self.getParent().state.data;
}
var quizId = recordData.id;
var quizName = recordData.name;
console.log('[GeniusButton] Starting Test Mode for Quiz:', quizId);
// Instantiate Popup directly
var popup = new GeniusQuizPopup(self, {
quizId: quizId,
quizName: quizName,
isPreview: true
});
popup.appendTo(document.body).then(function() {
console.log('[GeniusButton] Popup opened successfully');
});
},
});
core.action_registry.add('genius_quiz_popup', GeniusQuizPopup);
core.action_registry.add('genius_quiz_preview', GeniusQuizPreviewAction);
// AbstractField is better basic class for form widgets but Widget works for simple button behavior
// We register it as a widget for fields
var FieldRegistry = require('web.field_registry');
FieldRegistry.add('genius_quiz_test_button', GeniusQuizTestButton);
return {
GeniusQuizPopup: GeniusQuizPopup,
GeniusQuizPreviewAction: GeniusQuizPreviewAction,
GeniusQuizTestButton: GeniusQuizTestButton,
};
});

View File

@ -0,0 +1,358 @@
/**
* Genius Tip Widget - Premium Tooltip with SVG Spotlight
* =======================================================
*
* "Got it" button only hides UI - does NOT advance tour
* Tour advances only when user performs actual action on target
*/
odoo.define('tour_genius.GeniusTip', function (require) {
"use strict";
var core = require('web.core');
var Tip = require('web_tour.Tip');
var _t = core._t;
// Global cleanup function
window.cleanupGeniusHand = function() {
if (window._geniusPersistentHand) {
var hand = window._geniusPersistentHand;
if (hand && hand.parentNode) {
hand.parentNode.removeChild(hand);
}
window._geniusPersistentHand = null;
}
};
var GeniusTip = Tip.extend({
template: "GeniusTip",
xmlDependencies: (Tip.prototype.xmlDependencies || []).concat([
'/tour_genius/static/src/xml/genius_tip.xml'
]),
events: _.extend({}, Tip.prototype.events || {}, {
'click .genius-tip-skip': '_onSkipTour',
'click .genius-tip-next': '_onGotItClick',
}),
init: function (parent, info) {
this._super.apply(this, arguments);
this.geniusStepIndex = info.geniusStepIndex || 0;
this.geniusTotalSteps = info.geniusTotalSteps || 1;
this.geniusTitle = info.geniusTitle || _t("Step");
this.geniusTourName = info.geniusTourName || '';
this.spotlightPadding = 6;
this.spotlightRadius = 6;
this.overlayColor = 'rgba(15, 23, 42, 0.75)';
this._spotlightCreated = false;
this._handCreated = false;
this._uiHidden = false;
},
start: function () {
var self = this;
this.el.style.cssText = 'opacity:0 !important; visibility:hidden !important;';
// Clean up previous persistent hand
window.cleanupGeniusHand();
return this._super.apply(this, arguments).then(function () {
requestAnimationFrame(function() {
if (self.isDestroyed()) return;
self._moveToBodyFixed();
self._createSpotlightSvg();
self._updateSpotlightPosition();
self._positionNearAnchor();
self._createPointingHand();
requestAnimationFrame(function() {
if (!self.isDestroyed()) {
self._showTooltip();
}
});
});
self._onWindowResize = _.debounce(function() {
if (!self.isDestroyed() && self._spotlightCreated && !self._uiHidden) {
self._updateSpotlightPosition();
self._positionNearAnchor();
self._updateHandPosition();
}
}, 200);
$(window).on('resize.geniusTip', self._onWindowResize);
});
},
destroy: function () {
$(window).off('.geniusTip');
// Always remove spotlight
if (this._spotlightSvg) {
$(this._spotlightSvg).remove();
}
// Remove hand unless it's in "hidden UI" mode (just got it clicked)
if (!this._uiHidden) {
if (this._handElement) {
$(this._handElement).remove();
}
} else {
// Store hand for persistence
window._geniusPersistentHand = this._handElement;
}
return this._super.apply(this, arguments);
},
update: function ($anchor, $altAnchor) {
this._super.apply(this, arguments);
if (this._uiHidden) return;
var self = this;
requestAnimationFrame(function() {
if (!self.isDestroyed()) {
self._updateSpotlightPosition();
self._positionNearAnchor();
self._updateHandPosition();
}
});
},
_updatePosition: function () {},
_reposition: function () {},
_to_bubble_mode: function () {},
_to_info_mode: function () {},
_build_bubble_mode: function () {},
_build_info_mode: function () {},
_moveToBodyFixed: function () {
this.$el.detach();
this.$el.appendTo(document.body);
this.el.style.cssText = 'position:fixed !important; z-index:99999 !important; opacity:0 !important; visibility:hidden !important; background:transparent !important; border:none !important;';
this.tip_opened = true;
this.$el.addClass('active genius-tip-always-open o_tooltip_visible genius-tip');
},
_showTooltip: function () {
this.el.style.setProperty('opacity', '1', 'important');
this.el.style.setProperty('visibility', 'visible', 'important');
},
_positionNearAnchor: function () {
if (!this.$anchor || !this.$anchor.length) return;
var anchorRect = this.$anchor[0].getBoundingClientRect();
var tipCard = this.$el.find('.genius-tip-card')[0];
var tipWidth = tipCard ? tipCard.offsetWidth : 300;
var tipHeight = tipCard ? tipCard.offsetHeight : 150;
var spacing = 25;
var position = this.info.position || 'bottom';
var top, left;
switch (position) {
case 'top':
top = anchorRect.top - tipHeight - spacing;
left = anchorRect.left + (anchorRect.width / 2) - (tipWidth / 2);
break;
case 'bottom':
top = anchorRect.bottom + spacing;
left = anchorRect.left + (anchorRect.width / 2) - (tipWidth / 2);
break;
case 'left':
top = anchorRect.top + (anchorRect.height / 2) - (tipHeight / 2);
left = anchorRect.left - tipWidth - spacing;
break;
case 'right':
top = anchorRect.top + (anchorRect.height / 2) - (tipHeight / 2);
left = anchorRect.right + spacing;
break;
}
var viewportWidth = window.innerWidth;
var viewportHeight = window.innerHeight;
if (left < 10) left = 10;
if (left + tipWidth > viewportWidth - 10) left = viewportWidth - tipWidth - 10;
if (top < 10) top = 10;
if (top + tipHeight > viewportHeight - 10) top = viewportHeight - tipHeight - 10;
this.el.style.setProperty('top', top + 'px', 'important');
this.el.style.setProperty('left', left + 'px', 'important');
this._currentPosition = position;
if (position === 'top' || position === 'bottom') {
var anchorCenterX = anchorRect.left + anchorRect.width / 2;
var arrowLeft = anchorCenterX - left;
arrowLeft = Math.max(25, Math.min(arrowLeft, tipWidth - 25));
if (tipCard) tipCard.style.setProperty('--arrow-left', arrowLeft + 'px');
} else {
var anchorCenterY = anchorRect.top + anchorRect.height / 2;
var arrowTop = anchorCenterY - top;
arrowTop = Math.max(25, Math.min(arrowTop, tipHeight - 25));
if (tipCard) tipCard.style.setProperty('--arrow-top', arrowTop + 'px');
}
this.$el.removeClass('top bottom left right').addClass(position);
},
_createSpotlightSvg: function () {
if (this._spotlightSvg) return;
var windowWidth = window.innerWidth;
var windowHeight = window.innerHeight;
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'genius-spotlight-svg');
svg.setAttribute('viewBox', '0 0 ' + windowWidth + ' ' + windowHeight);
svg.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:99990;pointer-events:none;';
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.style.cssText = 'fill:' + this.overlayColor + ';fill-rule:evenodd;';
svg.appendChild(path);
document.body.appendChild(svg);
this._spotlightSvg = svg;
this._spotlightPath = path;
this._spotlightCreated = true;
},
_updateSpotlightPosition: function () {
if (!this._spotlightPath || !this.$anchor || !this.$anchor.length) return;
var rect = this.$anchor[0].getBoundingClientRect();
var windowX = window.innerWidth;
var windowY = window.innerHeight;
var padding = this.spotlightPadding;
var radius = this.spotlightRadius;
var x = Math.max(0, rect.left - padding);
var y = Math.max(0, rect.top - padding);
var w = Math.min(rect.width + padding * 2, windowX - x);
var h = Math.min(rect.height + padding * 2, windowY - y);
radius = Math.min(radius, w / 2, h / 2);
this._spotlightSvg.setAttribute('viewBox', '0 0 ' + windowX + ' ' + windowY);
this._anchorRect = rect;
var pathD =
'M0,0 L' + windowX + ',0 L' + windowX + ',' + windowY + ' L0,' + windowY + ' Z ' +
'M' + (x + radius) + ',' + y +
' h' + (w - radius * 2) +
' a' + radius + ',' + radius + ' 0 0 1 ' + radius + ',' + radius +
' v' + (h - radius * 2) +
' a' + radius + ',' + radius + ' 0 0 1 -' + radius + ',' + radius +
' h-' + (w - radius * 2) +
' a' + radius + ',' + radius + ' 0 0 1 -' + radius + ',-' + radius +
' v-' + (h - radius * 2) +
' a' + radius + ',' + radius + ' 0 0 1 ' + radius + ',-' + radius +
' z';
this._spotlightPath.setAttribute('d', pathD);
},
_createPointingHand: function () {
if (this._handElement) return;
var hand = document.createElement('div');
hand.className = 'genius-pointing-hand';
document.body.appendChild(hand);
this._handElement = hand;
this._handCreated = true;
this._updateHandPosition();
},
_updateHandPosition: function () {
if (!this._handElement || !this._anchorRect) return;
var rect = this._anchorRect;
var targetCenterX = rect.left + rect.width / 2;
var targetCenterY = rect.top + rect.height / 2;
var position = this._currentPosition || 'bottom';
var gap = 8;
var handX, handY, handEmoji, animClass;
switch (position) {
case 'bottom':
case 'top':
handX = rect.right + gap;
handY = targetCenterY - 14;
handEmoji = '👈';
animClass = 'hand-horizontal';
break;
case 'left':
case 'right':
handX = targetCenterX - 14;
handY = rect.top - 28 - gap;
handEmoji = '👇';
animClass = 'hand-vertical';
break;
}
this._handElement.innerHTML = handEmoji;
this._handElement.className = 'genius-pointing-hand ' + animClass;
this._handElement.style.cssText =
'position:fixed;' +
'z-index:99998;' +
'font-size:26px;' +
'pointer-events:none;' +
'left:' + handX + 'px;' +
'top:' + handY + 'px;' +
'filter:drop-shadow(0 2px 6px rgba(0,0,0,0.3)) saturate(1.2);';
},
/**
* Hide tooltip and spotlight, keep hand visible
* Does NOT trigger tip_consumed - tour advances via natural trigger
*/
_hideTooltipKeepHand: function () {
this._uiHidden = true;
// Hide the tooltip card
this.$el.find('.genius-tip-card').hide();
// Remove spotlight
if (this._spotlightSvg) {
$(this._spotlightSvg).remove();
this._spotlightSvg = null;
this._spotlightPath = null;
}
// Hand remains visible - point stored for cleanup by next step
console.log('[GeniusTip] UI hidden, hand remains. Waiting for user action on target.');
},
_onSkipTour: function (ev) {
ev.preventDefault();
ev.stopPropagation();
this._uiHidden = false;
this.trigger('genius_skip_tour', { tourName: this.geniusTourName });
},
/**
* "Got it" button - ONLY hides tooltip/spotlight
* Does NOT advance the tour!
*/
_onGotItClick: function (ev) {
ev.preventDefault();
ev.stopPropagation();
// Just hide the UI, don't trigger tip_consumed
this._hideTooltipKeepHand();
},
});
return GeniusTip;
});

View File

@ -0,0 +1,900 @@
odoo.define('tour_genius.RecorderPanel', function (require) {
"use strict";
var Widget = require('web.Widget');
var core = require('web.core');
var rpc = require('web.rpc');
var ajax = require('web.ajax');
var session = require('web.session');
var Dialog = require('web.Dialog');
var _t = core._t;
/**
* Draggable Recorder Panel Widget
* Provides Track On/Stop functionality for capturing CSS selectors
*/
var RecorderPanel = Widget.extend({
template: 'tour_genius.RecorderPanel',
events: {
'mousedown .recorder-header': '_onStartDrag',
'click .recorder-close': '_onClose',
'click .btn-track-on': '_onToggleTracking',
'click .btn-save-step': '_onSaveStep',
'click .btn-finish': '_onFinish',
'change .position-select': '_onPositionChange',
'change .step-type-select': '_onTypeChange',
},
init: function (parent, options) {
this._super.apply(this, arguments);
this.options = options || {};
// Detect "New Tour" mode
var rawTopicId = options.topicId;
if (rawTopicId === 'new' || !rawTopicId) {
this.isNewTour = true;
this.topicId = null; // Will be set after first save
this.topicName = 'New Tour';
} else {
this.isNewTour = false;
this.topicId = rawTopicId;
this.topicName = options.topicName || 'Tour';
}
this.isTracking = false;
this.steps = [];
this.currentStep = {
css_selector: '',
title: '',
instruction: '',
position: 'bottom',
step_type: 'click',
};
// Drag state
this.isDragging = false;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
console.log('[Recorder] Init - isNewTour:', this.isNewTour, 'topicId:', this.topicId);
},
start: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self._positionPanel();
self._setupGlobalEvents();
// If New Tour mode, load modules for dropdown
if (self.isNewTour) {
self._loadModulesDropdown();
}
console.log('[Recorder] Started - isNewTour:', self.isNewTour);
});
},
/**
* Load modules into the Tour Module dropdown (for New Tour mode)
*/
_loadModulesDropdown: function () {
var self = this;
rpc.query({
model: 'ir.module.module',
method: 'search_read',
domain: [['state', '=', 'installed']],
fields: ['id', 'name', 'shortdesc'],
orderBy: [{name: 'shortdesc', asc: true}],
limit: 0,
}).then(function (modules) {
var $datalist = self.$('#module-datalist');
var $input = self.$('.tour-module-input');
var $hiddenSelect = self.$('.tour-module-select');
// Store modules for lookup
self._moduleMap = {};
// Sort modules alphabetically
modules.sort(function(a, b) {
var nameA = (a.shortdesc || a.name || '').toLowerCase();
var nameB = (b.shortdesc || b.name || '').toLowerCase();
return nameA.localeCompare(nameB);
});
// Populate datalist with options
modules.forEach(function (mod) {
var displayName = mod.shortdesc || mod.name;
self._moduleMap[displayName] = mod.id;
$datalist.append($('<option>').attr('value', displayName));
});
// When user selects from datalist, store ID in hidden field
$input.on('input change', function () {
var selectedText = $(this).val();
var moduleId = self._moduleMap[selectedText] || '';
$hiddenSelect.val(moduleId);
console.log('[Recorder] Module input:', selectedText, '-> ID:', moduleId);
});
console.log('[Recorder] Loaded', modules.length, 'modules for search');
}).catch(function (err) {
console.error('[Recorder] Module load error:', err);
});
},
destroy: function () {
this._cleanup();
this._super.apply(this, arguments);
},
// =====================================================================
// Panel Positioning & Dragging
// =====================================================================
_positionPanel: function () {
this.$el.css({
position: 'fixed',
top: '100px',
right: '20px',
zIndex: 9999,
});
},
_onStartDrag: function (ev) {
if ($(ev.target).hasClass('recorder-close')) return;
this.isDragging = true;
var offset = this.$el.offset();
this.dragOffsetX = ev.pageX - offset.left;
this.dragOffsetY = ev.pageY - offset.top;
$(document).on('mousemove.recorder', this._onDrag.bind(this));
$(document).on('mouseup.recorder', this._onStopDrag.bind(this));
ev.preventDefault();
},
_onDrag: function (ev) {
if (!this.isDragging) return;
var newX = ev.pageX - this.dragOffsetX;
var newY = ev.pageY - this.dragOffsetY;
// Keep within viewport
newX = Math.max(0, Math.min(newX, window.innerWidth - this.$el.width()));
newY = Math.max(0, Math.min(newY, window.innerHeight - this.$el.height()));
this.$el.css({
left: newX + 'px',
top: newY + 'px',
right: 'auto',
});
},
_onStopDrag: function () {
this.isDragging = false;
$(document).off('mousemove.recorder');
$(document).off('mouseup.recorder');
},
// =====================================================================
// Track On/Off - Element Selection
// =====================================================================
_onToggleTracking: function () {
if (this.isTracking) {
this._stopTracking();
} else {
this._startTracking();
}
},
_startTracking: function () {
console.log('[Recorder] Start Tracking');
this.isTracking = true;
this.$('.btn-track-on').addClass('active').html('<i class="fa fa-stop"></i> Stop Tracking');
// Add overlay (pointer-events: none to allow hover on elements)
if ($('.genius-tracking-overlay').length === 0) {
$('body').append('<div class="genius-tracking-overlay"></div>');
}
$('body').addClass('genius-tracking-mode');
// Use native capture listeners to intercept events
this._boundHover = this._onElementHover.bind(this);
this._boundClick = this._onElementClick.bind(this);
this._boundMouseDown = this._preventDefault.bind(this);
this._boundMouseUp = this._preventDefault.bind(this);
document.addEventListener('mouseover', this._boundHover, true);
document.addEventListener('click', this._boundClick, true);
document.addEventListener('mousedown', this._boundMouseDown, true);
document.addEventListener('mouseup', this._boundMouseUp, true);
},
_stopTracking: function () {
console.log('[Recorder] Stop Tracking');
this.isTracking = false;
this.$('.btn-track-on').removeClass('active').html('<i class="fa fa-crosshairs"></i> Track On');
// Remove overlay and classes
$('.genius-tracking-overlay').remove();
$('body').removeClass('genius-tracking-mode');
$('.genius-tracking-highlight').removeClass('genius-tracking-highlight');
// Remove native listeners
if (this._boundHover) {
document.removeEventListener('mouseover', this._boundHover, true);
document.removeEventListener('click', this._boundClick, true);
document.removeEventListener('mousedown', this._boundMouseDown, true);
document.removeEventListener('mouseup', this._boundMouseUp, true);
}
},
_preventDefault: function(ev) {
// checking if target is inside recorder panel
if ($(ev.target).closest('.genius-recorder-panel').length) return;
ev.preventDefault();
ev.stopPropagation();
},
_onElementHover: function (ev) {
if (!this.isTracking) return;
var target = ev.target;
var $target = $(target);
// Ignore our panel and overlay
if ($target.closest('.genius-recorder-panel').length ||
$target.hasClass('genius-tracking-overlay')) {
return;
}
$('.genius-tracking-highlight').removeClass('genius-tracking-highlight');
$target.addClass('genius-tracking-highlight');
},
_onElementClick: function (ev) {
if (!this.isTracking) return;
var target = ev.target;
var $target = $(target);
// Allow clicks on our panel
if ($target.closest('.genius-recorder-panel').length) {
return;
}
console.log('[Recorder] Element Clicked:', target);
// Stop event!
ev.preventDefault();
ev.stopPropagation();
// Generate CSS selector
var selector = this._generateCSSSelector(target);
console.log('[Recorder] Generated Selector:', selector);
// Update current step
this.currentStep.css_selector = selector;
// Force update UI
var $input = this.$('.css-selector-input');
$input.val(selector);
// Auto-fill title from element
var title = this._generateStepTitle($target);
this.$('.step-title-input').val(title);
this.currentStep.title = title;
// Stop tracking
this._stopTracking();
},
// =====================================================================
// CSS Selector Generation - ODOO STANDARD PATTERNS
// Based on analysis of crm.js, mass_mailing_tour.js, tour_step_utils.js
// =====================================================================
_generateCSSSelector: function (element) {
try {
if (!(element instanceof Element)) return '';
var $el = $(element);
// ================ PRIORITY 1: Odoo Semantic Patterns ================
// Check for app icon with data-menu-xmlid (highest priority)
var menuXmlId = $el.attr('data-menu-xmlid') ||
$el.closest('[data-menu-xmlid]').attr('data-menu-xmlid');
if (menuXmlId) {
// CRITICAL FIX: Distinguish between App Tiles (home screen) and Menu Items (navbar)
// Use Global State Check: Are we in the Home Menu?
// In Odoo, the Home Menu has class .o_home_menu and is visible only on the dashboard
var isHomeMenu = $('.o_home_menu').length > 0 && $('.o_home_menu').is(':visible');
// Only use .o_app if we are effectively in the Home Menu AND clicking an app icon
if (isHomeMenu && ($el.hasClass('o_app') || $el.closest('.o_apps').length || $el.hasClass('o_app_icon'))) {
return '.o_app[data-menu-xmlid="' + menuXmlId + '"]';
}
// Otherwise, we are likely in a Top Menu (NavBar) or inner menu
else {
return '[data-menu-xmlid="' + menuXmlId + '"]';
}
}
// CRITICAL FIX: Group By vs Filter (Dropdowns)
// Use text content to distinguish between generic dropdown buttons
// Search for button with class o_dropdown_toggler_btn
var $btn = $el.hasClass('o_dropdown_toggler_btn') ? $el : $el.closest('.o_dropdown_toggler_btn');
if ($btn.length) {
var text = $btn.text().trim();
// Clean text (remove caret/icon text if any)
text = text.replace(/\s+/g, ' ').trim();
if (text && ['Filter', 'Group By', 'Favorites', 'Measures'].some(t => text.includes(t))) {
// Use contains for partial match (safe for buttons)
return 'button.o_dropdown_toggler_btn:contains("' + text + '")';
}
}
// ================ PRIORITY 1.5: Smart Field Detection ================
// CRITICAL: Odoo fields need container-level selectors, NOT input-level
// This matches Odoo's native tour patterns (e.g., Sales tour, CRM tour)
// Check if we're inside an Odoo field widget
var $fieldWidget = $el.closest('.o_field_widget');
if ($fieldWidget.length) {
var fieldName = $fieldWidget.attr('name');
if (fieldName) {
// Determine field type by checking widget classes
var isMany2one = $fieldWidget.hasClass('o_field_many2one');
var isMany2many = $fieldWidget.hasClass('o_field_many2many') ||
$fieldWidget.hasClass('o_field_many2manytags'); // No underscore before 'tags'!
var isDate = $fieldWidget.hasClass('o_field_date') ||
$fieldWidget.hasClass('o_field_datetime');
var isSelection = $fieldWidget.hasClass('o_field_selection');
var isRadio = $fieldWidget.hasClass('o_field_radio');
var isBoolean = $fieldWidget.hasClass('o_field_boolean');
var isX2many = $fieldWidget.hasClass('o_field_one2many') ||
$fieldWidget.hasClass('o_field_x2many');
// Relational fields: Use CONTAINER selector (no input suffix!)
// This is the Odoo standard pattern as seen in Sales tour
if (isMany2one) {
return ".o_form_editable .o_field_many2one[name='" + fieldName + "']";
}
if (isMany2many) {
return ".o_form_editable .o_field_many2many[name='" + fieldName + "']";
}
// Date fields: Use container (date picker handles the rest)
if (isDate) {
return ".o_field_widget[name='" + fieldName + "']";
}
// Selection fields: Use container
if (isSelection) {
return ".o_field_widget.o_field_selection[name='" + fieldName + "']";
}
// Radio buttons: Use container
if (isRadio) {
return ".o_field_widget.o_field_radio[name='" + fieldName + "']";
}
// Boolean checkbox: Use container
if (isBoolean) {
return ".o_field_widget.o_field_boolean[name='" + fieldName + "']";
}
// X2Many (One2many list): Use "Add a line" pattern
if (isX2many) {
// Check if clicking on Add a line button
if ($el.closest('.o_field_x2many_list_row_add').length) {
return ".o_field_x2many_list_row_add > a";
}
return ".o_field_widget[name='" + fieldName + "']";
}
// Simple text/char/integer fields: Target the input
if ($el.is('input, textarea')) {
return ".o_field_widget[name='" + fieldName + "'] " + element.tagName.toLowerCase();
}
// Fallback: Just the widget container
return ".o_field_widget[name='" + fieldName + "']";
}
}
// Check for direct name attribute on input/button
var elName = $el.attr('name');
if (elName) {
if (element.tagName === 'BUTTON') {
return 'button[name="' + elName + '"]';
}
if (element.tagName === 'INPUT') {
return 'input[name="' + elName + '"]';
}
if (element.tagName === 'SELECT') {
return 'select[name="' + elName + '"]';
}
}
// Check for button/a with data-name (common in form views)
var dataName = $el.attr('data-name') || $el.closest('[data-name]').attr('data-name');
if (dataName && ($el.is('button, a') || $el.closest('button, a').length)) {
return '[data-name="' + dataName + '"]';
}
// ================ PRIORITY 2: Odoo Semantic Classes ================
// Common Odoo action classes (from tour_step_utils.js and real tours)
var priorityClasses = [
'o-kanban-button-new', // Kanban create button
'o_kanban_add', // Kanban add button
'o_list_button_add', // List create button
'o_form_button_create', // Form create button
'o_form_button_save', // Form save button
'o_form_button_cancel', // Form cancel button
'o_back_button', // Back/breadcrumb button
'o_statusbar_buttons', // Status bar buttons
'o_menu_apps', // Apps menu
'o_menu_toggle', // Menu toggle (enterprise)
'o_schedule_activity', // Schedule activity button
'o_kanban_record', // Kanban record
'o_list_record_selector', // List record checkbox
'dropdown-toggle', // Bootstrap dropdown
];
for (var i = 0; i < priorityClasses.length; i++) {
if ($el.hasClass(priorityClasses[i])) {
return '.' + priorityClasses[i];
}
}
// ================ PRIORITY 3: Contextual Selectors ================
// Check for modal button (common pattern)
if ($el.closest('.modal-footer').length) {
var btnText = $el.text().trim();
if (btnText.length > 0 && btnText.length < 30) {
return '.modal-footer button:contains("' + btnText + '")';
}
}
// Check for breadcrumb
if ($el.closest('.breadcrumb').length) {
return '.breadcrumb-item:not(.active)';
}
// ================ PRIORITY 4: Class-based Path (no dynamic IDs) ================
// Build a class-based path, avoiding dynamic IDs
var path = [];
var current = element;
var maxDepth = 5; // Keep selector short
while (current && current.nodeType === Node.ELEMENT_NODE && maxDepth > 0) {
var selector = current.nodeName.toLowerCase();
// Get meaningful classes (skip dynamic/internal ones)
if (current.className && typeof current.className === 'string') {
var classes = current.className.trim().split(/\s+/).filter(function(cls) {
// Include semantic Odoo classes
if (cls.startsWith('o_') && !cls.match(/\d{3,}/)) return true;
// Include some Bootstrap/semantic classes
if (['btn', 'btn-primary', 'btn-secondary', 'form-control',
'dropdown', 'dropdown-menu', 'nav-link', 'card'].includes(cls)) return true;
// Exclude dynamic/internal classes
if (cls.match(/\d+/) || cls.startsWith('genius-') ||
cls.includes('ui-') || cls.includes('--')) return false;
return false;
});
if (classes.length > 0) {
// Use first meaningful class
selector += '.' + classes[0];
}
}
path.unshift(selector);
// Stop at meaningful container
if ($el.hasClass('o_form_view') || $el.hasClass('o_kanban_view') ||
$el.hasClass('o_list_view') || $el.hasClass('modal-content') ||
current.nodeName.toLowerCase() === 'body') {
break;
}
current = current.parentNode;
maxDepth--;
}
return path.join(' > ');
} catch (e) {
console.error('[Recorder] Selector Gen Error:', e);
return element.nodeName.toLowerCase(); // Fallback
}
},
_generateStepTitle: function ($target) {
var text = $target.text().trim().substring(0, 30);
if (text) return 'Click "' + text + '"';
var ph = $target.attr('placeholder');
if (ph) return 'Enter ' + ph;
var aria = $target.attr('aria-label');
if (aria) return aria;
return 'Click ' + $target.prop('tagName').toLowerCase();
},
// =====================================================================
// Step Management
// =====================================================================
_onPositionChange: function (ev) {
this.currentStep.position = $(ev.target).val();
},
_onTypeChange: function (ev) {
var type = $(ev.target).val();
this.currentStep.step_type = type;
if (type === 'input') {
this.$('.input-value-field').removeClass('d-none');
} else {
this.$('.input-value-field').addClass('d-none');
}
},
_onSaveStep: function () {
var self = this;
console.log('[Recorder] Save Step Clicked');
// Validate via UI values, NOT stored values (to allow manual edits)
var selector = this.$('.css-selector-input').val() || '';
var title = this.$('.step-title-input').val() || '';
selector = selector.trim();
title = title.trim();
if (!selector) {
this.do_warn(_t('Error'), _t('Please select an element'));
return;
}
if (!title) {
this.do_warn(_t('Error'), _t('Please enter a title'));
return;
}
// For existing tours, topicId must be present
// For new tours, topicId will be created on first save
if (!this.topicId && !this.isNewTour) {
this.do_warn(_t('Error'), _t('Topic ID is missing.'));
return;
}
// ============================================================
// NEW TOUR MODE: Create tour first if it doesn't exist
// ============================================================
var createTourPromise = Promise.resolve();
if (this.isNewTour && !this.topicId) {
var tourName = this.$('.tour-name-input').val();
// Get module ID from hidden field (set by input handler)
var moduleId = this.$('.tour-module-select').val();
// DEBUG: Log module selection details
console.log('[Recorder] Tour Name:', tourName);
console.log('[Recorder] Module ID retrieved:', moduleId);
// Validate Tour Name
if (!tourName || tourName.trim() === '') {
this.$('.tour-name-input').addClass('is-invalid');
var $alert = this.$('.recorder-alert');
$alert.removeClass('d-none alert-success').addClass('alert-danger').text(_t('Please enter a Tour Name.'));
return;
}
this.$('.tour-name-input').removeClass('is-invalid');
var tourVals = {
'name': tourName.trim(),
};
// Only add module_id if it's a valid number
if (moduleId && moduleId !== '' && !isNaN(parseInt(moduleId))) {
tourVals.module_id = parseInt(moduleId);
console.log('[Recorder] Module ID added to vals:', tourVals.module_id);
} else {
console.log('[Recorder] No valid module_id to add');
}
console.log('[Recorder] Creating Tour with vals:', JSON.stringify(tourVals));
createTourPromise = ajax.jsonRpc('/web/dataset/call_kw/genius.topic/create', 'call', {
model: 'genius.topic',
method: 'create',
args: [tourVals],
kwargs: { context: session.user_context }
}).then(function (newTopicId) {
console.log('[Recorder] Tour Created:', newTopicId);
self.topicId = newTopicId;
self.topicName = tourName.trim();
self.isNewTour = false; // No longer new
// Update header title
self.$('.recorder-header h4').html('<i class="fa fa-video-camera"></i> Recording: ' + self.topicName);
// Hide the Tour Info section
self.$('.tour-info-section').slideUp();
self.$('.tour-info-section').next('hr').slideUp();
return newTopicId;
});
}
// ============================================================
// SAVE STEP (after tour is guaranteed to exist)
// ============================================================
createTourPromise.then(function () {
var stepData = {
topic_id: self.topicId,
css_selector: selector,
title: title,
instruction: self.$('.step-instruction-input').val() || '',
position: self.$('.position-select').val() || 'bottom',
step_type: self.$('.step-type-select').val() || 'click',
input_value: self.$('.input-value-input').val() || '',
sequence: (self.steps.length + 1) * 10,
};
console.log('[Recorder] Saving Step:', stepData);
return ajax.jsonRpc('/web/dataset/call_kw/genius.topic.step/create', 'call', {
model: 'genius.topic.step',
method: 'create',
args: [stepData],
kwargs: { context: session.user_context }
}).then(function (stepId) {
console.log('[Recorder] Step Saved ID:', stepId);
// Update local steps array
stepData.id = stepId;
self.steps.push(stepData);
// CRITICAL FIX: Only update starting_url for NEW tours, not existing ones!
// Previously: self.steps.length === 1 (wrong - triggered for any first step in session)
// Now: Check if this is a NEW tour AND it's the first step we're adding
if (self.isNewTour && self.steps.length === 1) {
// CRITICAL: Capture URL where FIRST STEP was recorded
// This should be the page the user navigated to BEFORE selecting element
// NOT window.location.hash (which just gives the hash)
// Format should be like: /web#action=445&model=stock.picking
var fullUrl = window.location.href;
var origin = window.location.origin;
// Remove origin to get path + query + hash
var relativeUrl = fullUrl.replace(origin, '');
// Clean out our recorder params
try {
var url = new URL(fullUrl);
url.searchParams.delete('genius_recorder');
url.searchParams.delete('genius_tour');
url.searchParams.delete('genius_preview_tour');
url.searchParams.delete('genius_tour_run');
relativeUrl = url.pathname + url.search + url.hash;
// Ensure starts with /web if just a hash
if (relativeUrl.startsWith('#') || relativeUrl === '') {
relativeUrl = '/web' + relativeUrl;
}
} catch(e) {
// Fallback for older browsers
if (relativeUrl.startsWith('#')) {
relativeUrl = '/web' + relativeUrl;
}
}
console.log('[Recorder] Captured starting_url:', relativeUrl);
var modelName = '';
// Smart Module Detection from URL hash
try {
var hashParams = new URLSearchParams(window.location.hash.substring(1));
modelName = hashParams.get('model');
// Fallback to search params
if (!modelName) {
var searchParams = new URLSearchParams(window.location.search);
modelName = searchParams.get('model');
}
// Async Action Lookup if model missing but action exists
if (!modelName) {
var actionId = hashParams.get('action');
if (actionId) {
rpc.query({
model: 'ir.actions.act_window',
method: 'read',
args: [[parseInt(actionId)], ['res_model']],
}).then(function(res) {
if (res && res[0] && res[0].res_model) {
var m = res[0].res_model;
ajax.jsonRpc('/web/dataset/call_kw/genius.topic/write', 'call', {
model: 'genius.topic',
method: 'write',
args: [[parseInt(self.topicId)], {
'model_name': m,
'module_name': m.split('.')[0]
}],
kwargs: { context: session.user_context }
});
}
});
}
}
} catch(e) { console.warn('[Recorder] Context detection failed', e); }
var updateVals = { 'starting_url': relativeUrl };
if (modelName) {
updateVals.model_name = modelName;
updateVals.module_name = modelName.split('.')[0];
}
// Write to topic
ajax.jsonRpc('/web/dataset/call_kw/genius.topic/write', 'call', {
model: 'genius.topic',
method: 'write',
args: [[parseInt(self.topicId)], updateVals],
kwargs: { context: session.user_context }
}).then(function() {
console.log('[Recorder] Topic updated with starting_url:', relativeUrl);
});
}
self._updateStepCounter();
self._clearForm();
// Inline Feedback (Button)
var $btn = self.$('.btn-save-step');
var originalHtml = $btn.html();
$btn.html('<i class="fa fa-check"></i> ' + _t("Saved!"));
$btn.removeClass('btn-secondary').addClass('btn-success');
setTimeout(function() {
$btn.html(originalHtml);
$btn.removeClass('btn-success').addClass('btn-secondary');
}, 2000);
// Hide alert if visible from previous error
self.$('.recorder-alert').addClass('d-none');
}).catch(function (error) {
console.error('[Recorder] Save Error:', error);
// Inline Feedback Error
var $alert = self.$('.recorder-alert');
$alert.removeClass('d-none alert-success').addClass('alert-danger').text(_t("Error: ") + (error.message || 'Unknown')).show();
});
}).catch(function (error) {
// Handle tour creation failure
console.error('[Recorder] Tour/Step Error:', error);
var $alert = self.$('.recorder-alert');
$alert.removeClass('d-none alert-success').addClass('alert-danger').text(_t("Error creating tour: ") + (error.message || 'Unknown')).show();
});
},
_clearForm: function () {
this.$('.css-selector-input').val('');
this.$('.step-title-input').val('');
this.$('.step-instruction-input').val('');
this.$('.input-value-input').val('');
// Reset type and ensure input field is hidden
this.$('.step-type-select').val('click');
this.$('.input-value-field').addClass('d-none');
this.currentStep = {
css_selector: '',
title: '',
instruction: '',
position: 'bottom',
step_type: 'click',
};
},
_updateStepCounter: function () {
this.$('.step-counter').text(this.steps.length + ' steps recorded');
},
_onFinish: function () {
var self = this;
if (this.steps.length === 0) {
this.do_warn(_t('Warning'), _t('No steps recorded'));
return;
}
// Clean URL (remove genius_recorder param)
try {
var url = new URL(window.location.href);
url.searchParams.delete('genius_recorder');
window.history.replaceState({}, '', url);
} catch(e) { console.error(e); }
// Redirect to the created tour's form view (NOT hardcoded action)
var timestamp = new Date().getTime();
if (this.topicId) {
// Go to the created tour's form
window.location.href = '/web?t=' + timestamp + '#model=genius.topic&id=' + this.topicId + '&view_type=form';
} else {
// Fallback: go to tours list
window.location.href = '/web?t=' + timestamp + '#model=genius.topic&view_type=list';
}
this.destroy();
},
_onClose: function () {
if (this.steps.length > 0) {
if (!confirm(_t('Unsaved steps will be lost. Close?'))) return;
}
// Clean URL (remove genius_recorder param)
try {
var url = new URL(window.location.href);
if (url.searchParams.get('genius_recorder')) {
url.searchParams.delete('genius_recorder');
window.history.replaceState({}, '', url);
}
} catch(e) { console.error(e); }
this.destroy();
},
_cleanup: function () {
this._stopTracking();
$(document).off('.recorder');
},
_setupGlobalEvents: function () {
$(document).on('keydown.recorder', function (ev) {
if (ev.which === 27) this._onClose(); // Esc
}.bind(this));
},
});
core.action_registry.add('genius_recorder_panel', RecorderPanel);
function openRecorder(topicId, topicName) {
var recorder = new RecorderPanel(null, {
topicId: topicId,
topicName: topicName,
});
recorder.appendTo($('body'));
return recorder;
}
$(document).ready(function () {
var urlParams = new URLSearchParams(window.location.search);
var recorderTopicId = urlParams.get('genius_recorder');
if (recorderTopicId) {
setTimeout(function() {
// CLEANUP: Remove parameter immediately so refresh doesn't re-trigger
try {
var url = new URL(window.location.href);
url.searchParams.delete('genius_recorder');
window.history.replaceState({}, '', url);
} catch(e) { console.error('Failed to clean URL', e); }
var rpc = require('web.rpc');
rpc.query({
model: 'genius.topic',
method: 'read',
args: [[parseInt(recorderTopicId)], ['name']],
}).then(function (result) {
var topicName = result && result[0] ? result[0].name : 'New Tour';
openRecorder(parseInt(recorderTopicId), topicName);
});
}, 1000); // Increased delay to ensure Odoo is ready
}
});
return { RecorderPanel: RecorderPanel, openRecorder: openRecorder };
});

View File

@ -0,0 +1,263 @@
odoo.define('tour_genius.smart_systray', function (require) {
"use strict";
var SystrayMenu = require('web.SystrayMenu');
var Widget = require('web.Widget');
var rpc = require('web.rpc');
var core = require('web.core');
var tour = require('web_tour.tour'); // Add tour require
var QWeb = core.qweb;
/**
* Smart Systray - Context-aware training icon with dropdown
* Shows available training based on current screen
* Following Odoo official pattern (mail.systray.ActivityMenu)
*/
var GeniusSystray = Widget.extend({
template: 'GeniusSystrayIcon',
// Lower sequence = more to the LEFT in systray (default 50)
sequence: 10,
events: {
'show.bs.dropdown': '_onDropdownShow',
'hide.bs.dropdown': '_onDropdownHide',
'click .genius-run-tour': '_onRunTour',
'click .genius-view-all': '_onViewAll',
},
init: function (parent) {
this._super.apply(this, arguments);
this.badge_count = 0;
this.tours = [];
},
start: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self._detectContext();
// Listen for navigation changes
core.bus.on('change_title', self, self._detectContext);
core.bus.on('action_started', self, self._detectContext);
// CRITICAL: Listen to hash changes for immediate updates
$(window).on('hashchange', function() {
self._detectContext();
});
});
},
_detectContext: function () {
var self = this;
// Debounce to prevent rapid firing during redirects
if (this.detectTimeout) clearTimeout(this.detectTimeout);
this.detectTimeout = setTimeout(function() {
// Parse URL hash for context
var hash = window.location.hash;
var params = {};
if (hash) {
hash.substring(1).split('&').forEach(function(part) {
var kv = part.split('=');
if (kv.length === 2) {
params[kv[0]] = decodeURIComponent(kv[1]);
}
});
}
var context = {
action_id: params.action ? parseInt(params.action) : null,
model: params.model || null,
menu_id: params.menu_id ? parseInt(params.menu_id) : null, // NEW: For reliable app context
};
// Fetch relevant training
rpc.query({
model: 'genius.topic',
method: 'get_contextual_training',
args: [context],
}).then(function (result) {
self.tours = result.topics || [];
// CRITICAL: Only show badge for NEW items to avoid annoyance
self.badge_count = result.new_count || 0;
self._updateBadge();
}).catch(function (err) {
// console.warn('[Tour Genius] Failed to fetch contextual training', err);
self.tours = [];
self.badge_count = 0;
self._updateBadge();
});
}, 300); // Reduced debounce to 300ms for snappier feel
},
_updateBadge: function () {
var $badge = this.$('.genius-systray-badge');
if (this.badge_count > 0) {
$badge.text(this.badge_count).show();
this.$('.genius-systray-btn').addClass('has-training');
} else {
$badge.hide();
this.$('.genius-systray-btn').removeClass('has-training');
}
},
/**
* Bootstrap dropdown show event - populate tours
*/
_onDropdownShow: function () {
var self = this;
var $list = this.$('.genius-tours-list');
var $empty = this.$('.genius-empty');
$list.empty();
if (this.tours.length > 0) {
$empty.hide();
this.tours.forEach(function(tour) {
var $item = $(QWeb.render('GeniusSystrayItem', {tour: tour}));
$list.append($item);
});
} else {
$empty.show();
}
},
/**
* Bootstrap dropdown hide event
*/
_onDropdownHide: function () {
// Nothing needed - Bootstrap handles the close
},
_onRunTour: function (ev) {
ev.preventDefault();
ev.stopPropagation();
var tourId = $(ev.currentTarget).data('tour-id');
var tourName = 'genius_tour_' + tourId;
this.$('.dropdown-toggle').dropdown('toggle');
// ============================================================
// COMPREHENSIVE CLEANUP (same as dashboard)
// ============================================================
// 1. Destroy all active tooltips
if (tour.active_tooltips) {
Object.keys(tour.active_tooltips).forEach(function(key) {
try {
var tip = tour.active_tooltips[key];
if (tip && tip.widget && tip.widget.destroy) {
tip.widget.destroy();
}
} catch (e) {}
});
tour.active_tooltips = {};
}
// 2. Clear running tour state
tour.running_tour = null;
tour.paused = false;
// 3. Clear ALL tour-related localStorage items
var keysToRemove = [];
for (var i = 0; i < window.localStorage.length; i++) {
var key = window.localStorage.key(i);
if (key && (
key.startsWith('debugging_tour_') ||
key.startsWith('tour_manager_') ||
key.startsWith('tour_step_') ||
key.endsWith('_was_active')
)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(function(key) {
window.localStorage.removeItem(key);
});
// 4. Reset all tours
if (tour.tours) {
Object.keys(tour.tours).forEach(function(name) {
var t = tour.tours[name];
if (t) {
t.current_step = 0;
if (name.startsWith('genius_tour_')) {
t.ready = false;
}
}
});
}
// ============================================================
// ACTIVATE TOUR
// ============================================================
var targetTour = tour.tours && tour.tours[tourName];
if (targetTour && targetTour.steps && targetTour.steps.length > 0) {
var tourUrl = targetTour.url || '/web';
// Set debugging flag only (NOT running_tour - that triggers automated mode!)
window.localStorage.setItem('debugging_tour_' + tourName, 'true');
// Force reload with timestamp
var targetUrl = window.location.origin + tourUrl;
try {
var url = new URL(targetUrl);
url.searchParams.set('genius_tour_run', Date.now());
window.location.href = url.href;
} catch (e) {
var separator = tourUrl.indexOf('?') >= 0 ? '&' : '?';
window.location.href = targetUrl + separator + 'genius_tour_run=' + Date.now();
}
} else {
console.error('[Tour Genius] Tour not found or has no steps:', tourName);
}
},
_onViewAll: function (ev) {
ev.preventDefault();
this.$('.dropdown-toggle').dropdown('toggle');
var self = this;
// GENIUS NAVIGATION:
// To ensure the user "feels" they left the current module, we must:
// 1. Switch the Action (Content)
// 2. Switch the Menu (Navbar/Context)
// We fetch the IDs dynamically to ensure robustness.
rpc.query({
model: 'ir.model.data',
method: 'xmlid_to_res_model_res_id',
args: ['tour_genius.action_genius_dashboard', true], // Raise if not found
}).then(function (actionResult) {
var actionId = actionResult[1];
rpc.query({
model: 'ir.model.data',
method: 'xmlid_to_res_model_res_id',
args: ['tour_genius.menu_tour_genius_root', true],
}).then(function (menuResult) {
var menuId = menuResult[1];
// Construct a clean state
// We use window.location to force the ActionManager to process the full context switch
// inclusive of the Menu ID, which updates the Top Navbar.
var url = '/web#action=' + actionId + '&menu_id=' + menuId;
// Use pushState to change URL without full reload if possible,
// but Odoo 14 sometimes needs a help to update the menu.
// The most reliable way for "feeling like a new app" is to let the router handle it.
window.location.href = url;
// Fallback: If hash didn't trigger immediate update (e.g. same hash), reload
// window.location.reload();
});
});
},
});
SystrayMenu.Items.push(GeniusSystray);
return GeniusSystray;
});

View File

@ -0,0 +1,144 @@
/**
* Tour Genius - Client Action
* ============================
* Client action to run a tour with comprehensive cleanup
*/
odoo.define('tour_genius.client_action', function (require) {
"use strict";
var core = require('web.core');
var tour = require('web_tour.tour');
/**
* Client action to run a genius tour
* Called from Python action: {'type': 'ir.actions.client', 'tag': 'tour_genius_run_tour', ...}
*/
function runTourAction(parent, action) {
var tourName = action.params && action.params.tour_name;
if (!tourName) {
console.error('[Tour Genius] No tour_name provided in action params');
return Promise.reject('No tour_name provided');
}
console.log('[Tour Genius] Running tour via client action:', tourName);
// ============================================================
// COMPREHENSIVE CLEANUP
// ============================================================
// 1. Destroy all active tooltips
if (tour.active_tooltips) {
Object.keys(tour.active_tooltips).forEach(function(key) {
try {
var tip = tour.active_tooltips[key];
if (tip && tip.widget && tip.widget.destroy) {
tip.widget.destroy();
}
} catch (e) {}
});
tour.active_tooltips = {};
}
// 2. Clear running tour state
tour.running_tour = null;
tour.paused = false;
// 3. Clear ALL tour-related localStorage items
var keysToRemove = [];
for (var i = 0; i < window.localStorage.length; i++) {
var key = window.localStorage.key(i);
if (key && (
key.startsWith('debugging_tour_') ||
key.startsWith('tour_manager_') ||
key.startsWith('tour_step_') ||
key.endsWith('_was_active')
)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(function(key) {
window.localStorage.removeItem(key);
});
// 4. Reset all tours
if (tour.tours) {
Object.keys(tour.tours).forEach(function(name) {
var t = tour.tours[name];
if (t) {
t.current_step = 0;
if (name.startsWith('genius_tour_')) {
t.ready = false;
}
}
});
}
// ============================================================
// ACTIVATE TOUR
// ============================================================
// Check if we need to dynamically register (test mode for draft tours)
var testMode = action.params && action.params.test_mode;
var tourData = action.params && action.params.tour_data;
if (testMode && tourData && tourData.steps) {
// Dynamic registration for test mode (draft tours)
console.log('[Tour Genius] Test Mode: Storing tour data for post-redirect registration:', tourName);
// Store tour data in localStorage for post-redirect registration
// This is needed because draft tours aren't in get_tours_for_registration()
window.localStorage.setItem('genius_test_tour_data_' + tourName, JSON.stringify(tourData));
// Also register now in case we don't redirect (unlikely but safe)
if (!tour.tours) {
tour.tours = {};
}
// Register the tour dynamically
tour.register(tourName, {
url: tourData.url || '/web',
wait_for: Promise.resolve(),
}, tourData.steps);
console.log('[Tour Genius] Tour registered dynamically:', tourName);
}
var targetTour = tour.tours && tour.tours[tourName];
if (targetTour && targetTour.steps && targetTour.steps.length > 0) {
var tourUrl = targetTour.url || '/web';
// Set debugging flag only (NOT running_tour - that triggers automated mode!)
window.localStorage.setItem('debugging_tour_' + tourName, 'true');
// Mark as test mode in localStorage if applicable
if (testMode) {
window.localStorage.setItem('genius_test_mode_' + tourName, 'true');
}
// Force reload with timestamp
var targetUrl = window.location.origin + tourUrl;
try {
var url = new URL(targetUrl);
url.searchParams.set('genius_tour_run', Date.now());
window.location.href = url.href;
} catch (e) {
var separator = tourUrl.indexOf('?') >= 0 ? '&' : '?';
window.location.href = targetUrl + separator + 'genius_tour_run=' + Date.now();
}
return Promise.resolve();
} else {
console.error('[Tour Genius] Tour not found or has no steps:', tourName);
console.log('[Tour Genius] Available tours:', Object.keys(tour.tours || {}));
return Promise.reject('Tour not found: ' + tourName);
}
}
// Register the client action
core.action_registry.add('tour_genius_run_tour', runTourAction);
return {
runTourAction: runTourAction,
};
});

View File

@ -0,0 +1,631 @@
/**
* Tour Genius - Static Tour Registration
* =======================================
* Registers all genius tours at page load, then manually triggers
* the activation check for each tour.
*
* FOLLOWS ODOO PATTERN (from tour_manager.js):
* - Tours registered via tour.register()
* - After registration, tour._register() is called to check localStorage
* - If debugging_tour_X flag exists, tooltip is activated
* - tour.reset() sets this flag and redirects to tour URL
*/
odoo.define('tour_genius.tour_loader', function (require) {
"use strict";
var rpc = require('web.rpc');
var tour = require('web_tour.tour');
var core = require('web.core');
var _t = core._t;
/**
* Register all genius tours and trigger activation check
* Called once on page load
*/
// Force tooltips to be on top of everything (especially navbars/dropdowns)
var style = document.createElement('style');
style.innerHTML = '.o_tooltip { z-index: 10000 !important; }';
document.head.appendChild(style);
// CRITICAL: Store quiz data locally to prevent Odoo from stripping it during tour processing
var geniusToursData = {};
function registerAllTours() {
console.log('[Tour Genius] Starting tour registration...');
// GENIUS FIX: Pre-emptive check for pending genius tours to prevent overlap
// This prevents standard tours from flashing while we fetch our tours
var antiFlashInterval;
try {
var isGeniusPending = false;
// Use native localStorage for iteration as Odoo's wrapper might not support key iteration easily
if (window.localStorage) {
for (var i = 0; i < window.localStorage.length; i++) {
var key = window.localStorage.key(i);
if (key && key.indexOf('debugging_tour_genius_') > -1) {
isGeniusPending = true;
break;
}
}
}
if (isGeniusPending) {
console.log('[Tour Genius] Pending genius tour detected. Aggressively clearing others.');
deactivateNonGeniusTours();
// Repeat deactivation every 100ms until RPC returns to catch any stragglers
antiFlashInterval = setInterval(deactivateNonGeniusTours, 100);
}
} catch(e) {
console.warn('[Tour Genius] Pre-emptive check failed:', e);
}
rpc.query({
model: 'genius.topic',
method: 'get_tours_for_registration',
args: [],
}).catch(function(err) {
console.warn('[Tour Genius] Failed to fetch tours (likely public page/access denied):', err);
return []; // Return empty array to allow chain to continue (e.g. for test tours)
}).then(function(topics) {
// Stop the aggressive clearing once we have data
if (antiFlashInterval) clearInterval(antiFlashInterval);
// Initialize registeredTours array FIRST (before test tour registration)
var registeredTours = [];
// GENIUS: Check for stored test tour data (for testing draft tours)
try {
for (var i = 0; i < window.localStorage.length; i++) {
var key = window.localStorage.key(i);
if (key && key.startsWith('genius_test_tour_data_')) {
var tourName = key.replace('genius_test_tour_data_', '');
var storedData = window.localStorage.getItem(key);
if (storedData) {
var testTourData = JSON.parse(storedData);
console.log('[Tour Genius] Found stored test tour data:', tourName);
// Register the test tour dynamically
if (testTourData.steps && testTourData.steps.length > 0) {
console.log('[Tour Genius] Registering test tour with steps:', testTourData.steps);
console.log('[Tour Genius] Step 0 Content CHECK:', testTourData.steps[0].content);
tour.register(tourName, {
url: testTourData.url || '/web',
wait_for: Promise.resolve(),
}, testTourData.steps);
console.log('[Tour Genius] Registered test tour from localStorage:', tourName);
// CRITICAL: Add to registeredTours so it goes through activation!
registeredTours.push(tourName);
} else {
console.error('[Tour Genius] Test tour data found but NO STEPS:', testTourData);
}
// Clean up after registration (will be used once)
window.localStorage.removeItem(key);
}
}
}
} catch (e) {
console.warn('[Tour Genius] Error processing stored test tour data:', e);
}
if (!topics || !topics.length) {
console.log('[Tour Genius] No published tours to register (test tours may still work)');
} else {
console.log('[Tour Genius] Registering', topics.length, 'tours');
}
// Safely iterate over topics (may be empty array or null)
(topics || []).forEach(function(topic) {
var tourName = 'genius_tour_' + topic.id;
// Store quiz data permanently
geniusToursData[tourName] = topic.quiz;
// Skip if already registered (e.g. by test mode loader above)
if (tour.tours && tour.tours[tourName]) {
console.log('[Tour Genius] Tour already registered (likely test mode):', tourName);
// Only add to registeredTours if NOT already there to prevent double activation
if (registeredTours.indexOf(tourName) === -1) {
registeredTours.push(tourName);
}
return;
}
// Skip if no steps
if (!topic.steps || !topic.steps.length) {
console.log('[Tour Genius] Skipping tour with no steps:', tourName);
return;
}
// GENIUS FIX: Auto-inject showAppsMenuItem() when first step targets an app icon
// This ensures the home menu is visible before clicking on app icons
var stepsToRegister = topic.steps.slice(); // Clone array
var firstStep = stepsToRegister[0];
var needsAppsMenu = firstStep && firstStep.trigger &&
(firstStep.trigger.includes('.o_app[data-menu-xmlid') ||
firstStep.trigger.includes('.o_app[data-menu-xmlid='));
if (needsAppsMenu && tour.stepUtils && tour.stepUtils.showAppsMenuItem) {
// Check if showAppsMenuItem is not already the first step (avoid dupes)
var firstStepIsApps = firstStep && firstStep.content === "Home Menu"; // Odoo standard name
if (!firstStepIsApps) {
console.log('[Tour Genius] Injecting showAppsMenuItem for tour:', tourName);
stepsToRegister.unshift(tour.stepUtils.showAppsMenuItem());
}
}
// Register with Odoo's tour manager
tour.register(tourName, {
test: false, // Interactive mode (not test)
url: topic.starting_url || '/web',
rainbowMan: false, // DISABLED: We use our own GeniusCelebration widget
skip_enabled: true,
sequence: 1, // Priority 1: Override standard Odoo tours (which are usually 10/50)
}, stepsToRegister);
registeredTours.push(tourName);
console.log('[Tour Genius] Registered tour:', tourName, 'with', topic.steps.length, 'steps');
});
// CRITICAL FIX: For tours with debugging flags, we need to properly call _register()
// This ensures:
// 1. tour.wait_for promise is awaited
// 2. tour.steps are filtered for edition/mobile
// 3. tour.current_step is initialized from localStorage
// 4. tour.ready is set correctly
// 5. _to_next_step is called to set up active_tooltips
var localStorage = require('web.local_storage');
registeredTours.forEach(function(tourName) {
var registeredTour = tour.tours[tourName];
if (!registeredTour) return;
// Check if this tour has a debugging flag (set by tour.reset())
var debuggingKey = 'debugging_tour_' + tourName;
var isDebugging = localStorage.getItem(debuggingKey);
console.log('[Tour Genius] Checking activation for:', tourName, 'debugging:', !!isDebugging);
if (isDebugging) {
// Tour was started via reset() - call _register to properly initialize it
console.log('[Tour Genius] Activating tour from debugging flag:', tourName);
// ============================================================
// DEEP CLEANUP: Force Fresh Start
// ============================================================
// 1. Remove from Odoo's 'consumed_tours' (Storage & Memory)
// This prevents Odoo from skipping the tour because it thinks it's done
var consumedKey = 'tour_manager_consumed_tours';
try {
var consumedStr = window.localStorage.getItem(consumedKey);
if (consumedStr) {
var consumed = JSON.parse(consumedStr);
var idx = consumed.indexOf(tourName);
if (idx > -1) {
consumed.splice(idx, 1);
window.localStorage.setItem(consumedKey, JSON.stringify(consumed));
console.log('[Tour Genius] Removed from localStorage consumed list:', tourName);
}
}
} catch(e) {}
if (tour.consumed_tours) {
var idx = tour.consumed_tours.indexOf(tourName);
if (idx > -1) {
tour.consumed_tours.splice(idx, 1);
console.log('[Tour Genius] Removed from memory consumed list:', tourName);
}
}
// 1.5 Deep Clean Step Persistence (CRITICAL)
// Odoo stores the current step in localStorage using the format:
// tour_<tourName>_current_step
// We MUST delete this BEFORE calling _register to force Step 0
var stepKey = 'tour_' + tourName + '_current_step';
if (window.localStorage.getItem(stepKey) !== null) {
window.localStorage.removeItem(stepKey);
console.log('[Tour Genius] Wiped step persistence key:', stepKey);
}
// Also clean any other patterns just in case
for (var i = window.localStorage.length - 1; i >= 0; i--) {
var key = window.localStorage.key(i);
if (!key) continue;
// Avoid deleting the debugging flag itself!
if (key === 'debugging_tour_' + tourName) continue;
// Delete if it matches tour name patterns
if (key === tourName ||
key.indexOf(tourName) >= 0 && (key.indexOf('step') >= 0 || key.indexOf('current') >= 0)) {
window.localStorage.removeItem(key);
console.log('[Tour Genius] Wiped additional persistence key:', key);
}
}
// 2. Destroy Zombie Tooltips
// If a tooltip exists from a previous page load, it might be detached. Kill it.
if (tour.active_tooltips[tourName]) {
console.log('[Tour Genius] Destroying zombie tooltip before activation');
try {
if (tour.active_tooltips[tourName].destroy)
tour.active_tooltips[tourName].destroy();
else if (tour.active_tooltips[tourName].widget && tour.active_tooltips[tourName].widget.destroy)
tour.active_tooltips[tourName].widget.destroy();
} catch(e) {}
delete tour.active_tooltips[tourName];
}
// 3. Reset Step State
if (tour.tours[tourName]) {
tour.tours[tourName].current_step = 0;
}
// ============================================================
// Call _register with do_update=true
// This will check the debugging flag and call _to_next_step
tour._register(true, registeredTour, tourName).then(function() {
console.log('[Tour Genius] Tour registered promise resolved:', tourName);
// CRITICAL: Deactivate ALL non-genius tours
deactivateNonGeniusTours();
// Check if this is a test mode run (skip progress tracking)
var isTestMode = window.localStorage.getItem('genius_test_mode_' + tourName) === 'true';
// Track start time ONLY if not in test mode
if (!isTestMode) {
var tourId = parseInt(tourName.replace('genius_tour_', ''));
rpc.query({
model: 'genius.topic',
method: 'action_track_start',
args: [[tourId]],
}).catch(function(e) {
console.warn('[Tour Genius] Failed to track start time:', e);
});
} else {
console.log('[Tour Genius] Test mode - skipping progress tracking start');
}
// FORCE ACTIVATION CHECK
// Sometimes Odoo's _register doesn't trigger _to_next_step if timing is off
if (!tour.active_tooltips[tourName]) {
console.warn('[Tour Genius] Tooltip missing after register. Forcing activation of Step 0.');
if (tour.tours[tourName]) {
tour.tours[tourName].current_step = 0;
// Manually trigger internal method to stage the first tooltip
if (tour._to_next_step) {
tour._to_next_step(tourName, 0);
console.log('[Tour Genius] Forced _to_next_step(0) success.');
}
}
}
// After registration, call update to display the tooltip
setTimeout(function() {
console.log('[Tour Genius] Calling tour.update() for:', tourName);
console.log('[Tour Genius] Active Tooltip State:', tour.active_tooltips[tourName]);
tour.update(tourName);
}, 500);
});
}
// For non-debugging tours, do nothing - they don't need activation
});
console.log('[Tour Genius] All tours registered and activation checks initiated');
// CRITICAL: Override Odoo's _consume_tour to show our GeniusCelebration
patchConsumeTour(registeredTours);
patchActivateTip(); // GENIUS: Enable custom GeniusTip for genius tours
// Monitor for tour completions and handle redirect + mark consumed
setupTourCompletionListener(registeredTours);
}).catch(function(err) {
if (antiFlashInterval) clearInterval(antiFlashInterval);
console.error('[Tour Genius] Error registering tours:', err);
});
}
/**
* Patch Odoo's _consume_tour to show GeniusCelebration for genius tours
* This is the most reliable way to intercept tour completion
*/
function patchConsumeTour(geniusTourNames) {
if (tour._consume_tour_patched) return; // Already patched
var originalConsumeTour = tour._consume_tour.bind(tour);
tour._consume_tour = function(tour_name, error) {
var isGeniusTour = tour_name.startsWith('genius_tour_');
var tourData = this.tours[tour_name];
// Retrieve Genius Quiz Data
var geniusQuiz = geniusToursData[tour_name];
// Determine if tour was COMPLETED vs SKIPPED
var wasCompleted = false;
var wasSkipped = false;
if (isGeniusTour && tourData) {
wasCompleted = !error && tourData.current_step === tourData.steps.length;
wasSkipped = !error && tourData.current_step < tourData.steps.length;
}
// CRITICAL: Check test mode BEFORE calling original consume
// This prevents Odoo's rainbow flag from showing in test mode
var isTestMode = false;
if (isGeniusTour) {
isTestMode = window.localStorage.getItem('genius_test_mode_' + tour_name) === 'true';
if (isTestMode) {
console.log('[Tour Genius] Test mode - performing manual cleanup to skip Odoo effects');
// 1. UNCONDITIONAL DELETE: This is critical.
// _to_next_step sets the value to undefined but keeps the key.
// We must delete the key to prevent Class.update from crashing on tip.hidden.
delete tour.active_tooltips[tour_name];
// 2. Clean localStorage
window.localStorage.removeItem('genius_test_mode_' + tour_name);
window.localStorage.removeItem('debugging_tour_' + tour_name);
// Also clean Odoo's standard keys
var tourKey = tour_name; // tour_name is correct key
// Odoo uses helper functions for keys, but standard is:
// 'tour_current_step' -> not used in registered tours
// 'tour_step_NAME' -> likely key format for some versions, or just rely on reset
// 3. Reset tour state
if (tourData) {
tourData.current_step = 0;
tourData.ready = false;
}
// 4. Clear running tour if applicable
if (tour.running_tour === tour_name) {
tour.running_tour = null;
}
// 5. Show our celebration
try {
var GeniusCelebration = require('tour_genius.GeniusCelebration');
new GeniusCelebration({
message: _t('<div style="text-align: center;"><strong>✨ Test Run Successful! ✨</strong><br/><span style="font-size: 14px; display: block; margin-top: 8px;">The tour functioned perfectly.</span><span style="font-size: 13px; font-weight: normal; opacity: 0.8; display: block; margin-top: 4px;">(Simulation Mode: No progress recorded)</span></div>'),
fadeout: 'slow', // Slow fadeout to give time to read
quiz: null,
}).appendTo($('body'));
} catch (e) {
console.error('[Tour Genius] Error showing test celebration:', e);
}
return; // Exit early! Do NOT call originalConsumeTour
}
}
// Call original only for non-test mode (this resets current_step to 0)
var result = originalConsumeTour(tour_name, error);
// Handle genius tour outcomes (only for non-test mode)
if (isGeniusTour) {
var tourId = parseInt(tour_name.replace('genius_tour_', ''));
if (wasCompleted) {
// Show quiz button if quiz has a valid ID
var hasQuiz = geniusQuiz && geniusQuiz.id && geniusQuiz.id !== false;
console.log('[Tour Genius] Completed tour:', tourId, 'Has Quiz:', hasQuiz);
// Mark as consumed
rpc.query({
model: 'genius.topic',
method: 'action_mark_consumed',
args: [[tourId]],
}).catch(function() {});
try {
var GeniusCelebration = require('tour_genius.GeniusCelebration');
new GeniusCelebration({
message: _t('<strong>Well Done!</strong> You completed the tour!'),
fadeout: hasQuiz ? 'no' : 'medium',
quiz: hasQuiz ? geniusQuiz : null,
}).appendTo($('body'));
} catch (e) {
console.error('[Tour Genius] Error showing celebration:', e);
}
} else if (wasSkipped) {
rpc.query({
model: 'genius.topic',
method: 'action_mark_skipped',
args: [[tourId]],
}).catch(function() {});
}
}
return result;
};
tour._consume_tour_patched = true;
console.log('[Tour Genius] Patched _consume_tour for GeniusCelebration');
}
/**
* Patch Odoo's _activate_tip to use GeniusTip for genius tours
* This enables our custom tooltip with spotlight and always-visible content
*/
function patchActivateTip() {
if (tour._activate_tip_patched) return; // Already patched
var originalActivateTip = tour._activate_tip.bind(tour);
var GeniusTip = null;
// Lazy load GeniusTip to avoid circular dependencies
try {
GeniusTip = require('tour_genius.GeniusTip');
} catch (e) {
console.warn('[Tour Genius] GeniusTip not available, using standard tooltips');
}
tour._activate_tip = function(tip, tour_name, $anchor, $alt_trigger) {
var isGeniusTour = tour_name.startsWith('genius_tour_');
// For non-genius tours, use original behavior
if (!isGeniusTour || !GeniusTip) {
return originalActivateTip(tip, tour_name, $anchor, $alt_trigger);
}
// For genius tours, use GeniusTip widget
var tourData = this.tours[tour_name];
var tip_info = tip;
// Add skip link functionality
if (tourData && tourData.skip_link) {
tip_info = _.extend(_.omit(tip_info, 'content'), {
content: tip.content + tourData.skip_link,
event_handlers: [{
event: 'click',
selector: '.o_skip_tour',
handler: tourData.skip_handler.bind(this, tip),
}],
});
}
// Enhance tip_info with Genius-specific data
var stepIndex = tourData ? tourData.current_step : 0;
var totalSteps = tourData && tourData.steps ? tourData.steps.length : 1;
var stepTitle = tip.geniusTitle || tip.title || _t("Step");
tip_info = _.extend({}, tip_info, {
geniusStepIndex: stepIndex,
geniusTotalSteps: totalSteps,
geniusTitle: stepTitle,
geniusTourName: tour_name,
});
// Create GeniusTip widget instead of standard Tip
tip.widget = new GeniusTip(this, tip_info);
// Handle tip_consumed event (standard behavior)
if (this.running_tour !== tour_name) {
tip.widget.on('tip_consumed', this, this._consume_tip.bind(this, tip, tour_name));
}
// Handle genius_skip_tour event
tip.widget.on('genius_skip_tour', this, function(data) {
console.log('[Tour Genius] Skip tour triggered:', data.tourName);
this._deactivate_tip(tip);
this._consume_tour(data.tourName);
});
// Attach to anchor
tip.widget.attach_to($anchor, $alt_trigger).then(
this._to_next_running_step.bind(this, tip, tour_name)
);
console.log('[Tour Genius] Activated GeniusTip for step', stepIndex + 1, 'of', totalSteps);
};
tour._activate_tip_patched = true;
console.log('[Tour Genius] Patched _activate_tip for GeniusTip');
}
/**
* Deactivate ALL non-genius tours to prevent them from showing
* Called when a genius tour is being activated
*/
function deactivateNonGeniusTours() {
console.log('[Tour Genius] Deactivating non-genius tours...');
if (!tour.active_tooltips) return;
var deactivatedCount = 0;
Object.keys(tour.active_tooltips).forEach(function(tourName) {
// Skip genius tours
if (tourName.startsWith('genius_tour_')) return;
var tip = tour.active_tooltips[tourName];
// Destroy the tooltip widget if it exists
if (tip && tip.widget && tip.widget.destroy) {
try {
tip.widget.destroy();
} catch (e) {
console.warn('[Tour Genius] Error destroying tip widget:', e);
}
}
// Remove from active_tooltips
delete tour.active_tooltips[tourName];
deactivatedCount++;
console.log('[Tour Genius] Deactivated tour:', tourName);
});
console.log('[Tour Genius] Deactivated', deactivatedCount, 'non-genius tours');
}
/**
* Set up listener for tour completion to:
* 1. Mark tour as consumed via RPC
* 2. Redirect to dashboard after a delay
*/
function setupTourCompletionListener(registeredTours) {
var localStorage = require('web.local_storage');
var dashboardAction = 581; // Tour Genius Dashboard action ID
// Check periodically if any genius tour was completed
var checkInterval = setInterval(function() {
registeredTours.forEach(function(tourName) {
if (!tourName.startsWith('genius_tour_')) return;
var registeredTour = tour.tours[tourName];
if (!registeredTour) return;
// Tour is complete when current_step >= steps.length and debugging flag is gone
var debuggingKey = 'debugging_tour_' + tourName;
var wasDebugging = localStorage.getItem(debuggingKey + '_was_active');
var isNowDebugging = localStorage.getItem(debuggingKey);
// Detect when debugging flag gets cleared (tour consumed)
if (wasDebugging && !isNowDebugging) {
localStorage.removeItem(debuggingKey + '_was_active');
// NOTE: Tour completion/skip handling is done in patchConsumeTour
// - Completion shows GeniusCelebration and marks consumed
// - Skip just marks as skipped (no celebration)
console.log('[Tour Genius] Tour consumed (completion listener):', tourName);
}
// Track that debugging is active
if (isNowDebugging) {
localStorage.setItem(debuggingKey + '_was_active', 'true');
}
});
}, 500);
// Clean up after 5 minutes (tours shouldn't take longer)
setTimeout(function() {
clearInterval(checkInterval);
}, 5 * 60 * 1000);
}
// Initialize on DOM ready with delay for Odoo to fully load
$(function() {
setTimeout(function() {
registerAllTours();
}, 1000);
});
// Export for external use
return {
registerAllTours: registerAllTours,
};
});

View File

@ -0,0 +1,235 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Main Dashboard Template -->
<t t-name="tour_genius.Dashboard">
<div class="o_genius_dashboard">
<!-- Header -->
<div class="o_genius_dashboard_header">
<div class="header-left">
<h1 class="dashboard-title">
<span class="title-icon"><i class="fa fa-lightbulb-o"/></span>
Tour Genius
</h1>
<p class="dashboard-subtitle">Interactive Training Platform</p>
</div>
<div class="header-right">
<div class="search-box">
<i class="fa fa-search search-icon"/>
<input type="text" class="search-input" placeholder="Search tours..."/>
</div>
<t t-if="widget.isAdmin">
<button class="btn btn-primary btn-new-tour">
<i class="fa fa-plus"/>
New Tour
</button>
</t>
</div>
</div>
<!-- Dashboard Content -->
<div class="o_genius_dashboard_content">
<!-- Will be rendered by DashboardContent template -->
</div>
</div>
</t>
<!-- Dashboard Content Template -->
<t t-name="tour_genius.DashboardContent">
<!-- Stats Cards -->
<div class="stats-row">
<div class="stat-card" data-filter="all">
<div class="stat-icon"><i class="fa fa-bar-chart"/></div>
<div class="stat-value"><t t-esc="stats.total_tours or 0"/></div>
<div class="stat-label">Total Tours</div>
</div>
<div class="stat-card" data-filter="completed">
<div class="stat-icon"><i class="fa fa-check-circle text-success"/></div>
<div class="stat-value"><t t-esc="stats.completed or 0"/></div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-card" data-filter="verified">
<div class="stat-icon"><i class="fa fa-certificate text-primary"/></div>
<div class="stat-value"><t t-esc="stats.certified or 0"/></div>
<div class="stat-label">Certified</div>
</div>
<div class="stat-card" data-filter="in_progress">
<div class="stat-icon"><i class="fa fa-clock-o text-warning"/></div>
<div class="stat-value"><t t-esc="stats.in_progress or 0"/></div>
<div class="stat-label">In Progress</div>
</div>
<div class="stat-card progress-card" data-filter="progress">
<div class="stat-icon"><i class="fa fa-line-chart"/></div>
<div class="stat-value"><t t-esc="Math.round(userProgress)"/>%</div>
<div class="stat-label">Your Progress</div>
<div class="progress-bar-mini">
<div class="progress-fill" t-attf-style="width: #{userProgress}%"/>
</div>
</div>
</div>
<!-- Tours Grid -->
<div class="section-title">
<h2>Available Tours</h2>
<span class="tour-count">(<t t-esc="tours.length"/> tours)</span>
</div>
<div class="tours-grid">
<t t-foreach="tours" t-as="tour">
<div class="tour-card" t-att-data-tour-id="tour.id" t-att-data-status="tour.user_status || 'new'" t-att-data-module="tour.module_name">
<!-- Tour Header (Icon + Info) -->
<div class="tour-card-header">
<!-- Tour Icon -->
<div class="tour-icon">
<i t-attf-class="fa #{tour.icon or 'fa-graduation-cap'}"/>
</div>
<!-- Tour Info -->
<div class="tour-info">
<h3 class="tour-name"><t t-esc="tour.name"/></h3>
<div class="tour-meta">
<span class="tour-module">
<i class="fa fa-cube"/> <t t-esc="tour.module_name or 'General'"/>
</span>
<span class="tour-steps">
<i class="fa fa-list-ol"/> <t t-esc="tour.step_count"/> steps
</span>
</div>
<!-- ALL USERS: Personal status badge below module name -->
<t t-if="tour.user_status and tour.user_status != 'new'">
<div t-attf-class="tour-status-inline status-#{tour.user_status}">
<t t-if="tour.user_status == 'verified'"><i class="fa fa-certificate"/> Certified</t>
<t t-if="tour.user_status == 'completed'"><i class="fa fa-check"/> Completed</t>
<t t-if="tour.user_status == 'in_progress'"><i class="fa fa-clock-o"/> In Progress</t>
</div>
</t>
<!-- Tags -->
<t t-if="tour.tags and tour.tags.length">
<div class="tour-tags">
<t t-foreach="tour.tags" t-as="tag">
<span class="tour-tag" t-att-style="'background-color: ' + (tag.color || '#6c757d')">
<t t-esc="tag.name"/>
</span>
</t>
</div>
</t>
</div>
</div>
<!-- ADMIN ONLY: Shows tour state (Draft) + certified count in CORNER -->
<t t-if="isAdmin">
<t t-if="tour.state == 'draft'">
<div class="tour-status status-draft">
<i class="fa fa-pencil"/> Draft
</div>
</t>
<!-- Show certified count for published tours (only if > 1 OR admin not personally certified) -->
<t t-if="tour.state == 'published' and tour.verified_count > 0 and (tour.verified_count > 1 or tour.user_status != 'verified')">
<div class="tour-status status-verified-count">
<i class="fa fa-users"/> <t t-esc="tour.verified_count"/> certified
</div>
</t>
</t>
<!-- Actions -->
<div class="tour-actions">
<!-- Run Tour Button (only for published tours) -->
<t t-if="tour.state == 'published'">
<button class="btn btn-success btn-run-tour" t-att-data-tour-id="tour.id" title="Run Tour">
<i class="fa fa-play"/>
</button>
</t>
<!-- Quiz Button (conditional) - ONLY for published tours -->
<t t-if="tour.quiz_id and tour.state == 'published'">
<!-- Perfect Score: Mastered Badge (no button) -->
<t t-if="tour.is_perfect">
<span class="btn btn-quiz btn-quiz-mastered" title="Perfect Score!">
<i class="fa fa-star"/> <span class="quiz-btn-text">100%</span>
</span>
</t>
<!-- Passed but not perfect: Improve button -->
<t t-elif="tour.quiz_status == 'passed' and tour.can_retry">
<button class="btn btn-quiz btn-quiz-improve"
t-att-data-tour-id="tour.id"
t-att-title="'Improve your score (' + tour.quiz_score + '%)'">
<i class="fa fa-arrow-up"/> <span class="quiz-btn-text"><t t-esc="tour.quiz_score"/>%</span>
</button>
</t>
<!-- Passed, no more attempts: View Certificate (PDF) -->
<t t-elif="tour.quiz_status == 'passed' and !tour.can_retry">
<a class="btn btn-quiz btn-quiz-certificate"
t-attf-href="/tour_genius/certificate/view/#{tour.certificate_attempt_id}"
target="_blank"
title="View Certificate">
<i class="fa fa-trophy"/> <span class="quiz-btn-text"><t t-esc="tour.quiz_score"/>%</span>
</a>
</t>
<!-- Failed but can retry -->
<t t-elif="tour.quiz_status == 'failed' and tour.can_retry">
<button class="btn btn-quiz btn-quiz-retry"
t-att-data-tour-id="tour.id"
t-att-title="'Retry Quiz (' + (tour.attempts_remaining == -1 ? '∞' : tour.attempts_remaining) + ' left)'">
<i class="fa fa-refresh"/> <span class="quiz-btn-text">Retry</span>
</button>
</t>
<!-- Failed and max attempts reached -->
<t t-elif="tour.quiz_status == 'failed' and !tour.can_retry">
<button class="btn btn-quiz btn-quiz-disabled" disabled="disabled" title="Max attempts reached">
<i class="fa fa-ban"/>
</button>
</t>
<!-- Not attempted yet -->
<t t-elif="tour.quiz_status == 'not_attempted'">
<button class="btn btn-quiz btn-quiz-start"
t-att-data-tour-id="tour.id"
title="Take Quiz">
<i class="fa fa-graduation-cap"/> <span class="quiz-btn-text">Quiz</span>
</button>
</t>
</t>
<!-- Download Certificate Button (for any passed quiz) - Only for published tours -->
<t t-if="tour.has_certificate and tour.certificate_attempt_id and tour.state == 'published'">
<a class="btn btn-quiz btn-quiz-download"
t-attf-href="/tour_genius/certificate/download/#{tour.certificate_attempt_id}"
target="_blank"
title="Download Certificate">
<i class="fa fa-download"/>
</a>
</t>
<!-- Admin Actions -->
<t t-if="isAdmin">
<button class="btn btn-secondary btn-edit-tour" t-att-data-tour-id="tour.id" title="Edit">
<i class="fa fa-edit"/>
</button>
<button class="btn btn-danger btn-delete-tour" t-att-data-tour-id="tour.id" title="Delete">
<i class="fa fa-trash"/>
</button>
</t>
</div>
</div>
</t>
<!-- Empty State -->
<t t-if="!tours or tours.length === 0">
<div class="empty-state">
<div class="empty-icon"><i class="fa fa-inbox fa-3x text-muted"/></div>
<h3>No Tours Available</h3>
<p t-if="isAdmin">Create your first tour by clicking the "New Tour" button above.</p>
<p t-else="">No training tours are available for your modules yet.</p>
</div>
</t>
</div>
</t>
</templates>

View File

@ -0,0 +1,130 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- Genius Celebration - Unique lightbulb moment effect -->
<t t-name="tour_genius.genius_celebration_v2">
<div class="o_genius_reward">
<!-- Particle explosion background -->
<div class="o_genius_particles">
<span class="particle p1"/>
<span class="particle p2"/>
<span class="particle p3"/>
<span class="particle p4"/>
<span class="particle p5"/>
<span class="particle p6"/>
<span class="particle p7"/>
<span class="particle p8"/>
<span class="particle p9"/>
<span class="particle p10"/>
<span class="particle p11"/>
<span class="particle p12"/>
</div>
<!-- Central celebration container -->
<div class="o_genius_center">
<!-- Glowing lightbulb icon -->
<div class="o_genius_bulb_container">
<div class="o_genius_bulb_rays"/>
<div class="o_genius_bulb">
<i class="fa fa-lightbulb-o"/>
</div>
<div class="o_genius_bulb_pulse"/>
</div>
<!-- Success badge -->
<div class="o_genius_badge">
<i class="fa fa-check"/>
<span>GENIUS!</span>
</div>
<!-- Message card -->
<div class="o_genius_card">
<!-- Close button -->
<button class="o_genius_close_btn" title="Close" style="position: absolute; top: 8px; right: 8px; background: none; border: none; font-size: 18px; color: #999; cursor: pointer; z-index: 10; padding: 5px 10px;">
<i class="fa fa-times"/>
</button>
<div class="o_genius_card_header">
<span class="o_genius_emoji">🎉</span>
<span class="o_genius_title">Tour Completed!</span>
<span class="o_genius_emoji">🎉</span>
</div>
<div class="o_genius_msg_content"/>
<!-- QUIZ ACTIONS -->
<!-- ACTIONS SECTION: Flattened Logic for Reliability -->
<div class="o_genius_actions" t-if="widget.options.quiz">
<!-- 1. PASSED -->
<t t-if="widget.options.quiz.status == 'passed'">
<div class="o_genius_certified" style="margin-top: 15px; text-align: center;">
<div style="color: #28a745; font-weight: bold; font-size: 1.3em; margin-bottom: 8px;">
<i class="fa fa-certificate" style="font-size: 1.5em;"/> Certified Genius!
</div>
<div style="font-size: 0.9em; color: #666; margin-bottom: 10px;">
Score: <strong><t t-esc="Math.round(widget.options.quiz.score || 0)"/>%</strong>
</div>
<button class="btn btn-success o_view_certificate_btn" style="border-radius: 20px; font-weight: bold; padding: 8px 20px;">
<i class="fa fa-trophy"/> View Certificate
</button>
</div>
</t>
<!-- 2. FAILED -->
<t t-if="widget.options.quiz.status == 'failed'">
<div style="text-align: center; margin-top: 15px;">
<div style="margin-bottom: 8px; color: #dc3545; font-weight: 600;">
Quiz Failed (<t t-esc="Math.round(widget.options.quiz.score || 0)"/>%)
</div>
<t t-if="widget.options.quiz.attempts_remaining == -1 or widget.options.quiz.attempts_remaining > 0">
<button class="btn btn-warning o_take_quiz_btn" style="border-radius: 20px; font-weight: bold; padding: 10px 24px;">
<i class="fa fa-refresh"/> Retry Quiz
</button>
<div t-if="widget.options.quiz.max_attempts > 0" style="font-size: 0.85em; color: #666; margin-top: 6px;">
<t t-esc="widget.options.quiz.attempts_remaining"/> attempts remaining
</div>
</t>
<t t-else="">
<button class="btn btn-secondary" disabled="disabled" style="border-radius: 20px; padding: 10px 24px; cursor: not-allowed;">
<i class="fa fa-ban"/> Max Attempts Reached
</button>
</t>
</div>
</t>
<!-- 3. NOT ATTEMPTED (or incomplete) -->
<t t-if="widget.options.quiz.status != 'passed' and widget.options.quiz.status != 'failed'">
<div style="text-align: center; margin-top: 15px;">
<t t-if="widget.options.quiz.attempts_remaining == 0">
<button class="btn btn-secondary" disabled="disabled" style="border-radius: 20px; padding: 10px 24px; cursor: not-allowed;">
<i class="fa fa-ban"/> No Attempts Available
</button>
</t>
<t t-else="">
<button class="btn btn-primary o_take_quiz_btn" style="border-radius: 20px; font-weight: bold; padding: 12px 28px; box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); font-size: 1.1em;">
<i class="fa fa-graduation-cap"/> Take Quiz Now
</button>
<div t-if="widget.options.quiz.max_attempts > 0" style="font-size: 0.85em; color: #666; margin-top: 6px;">
<t t-esc="widget.options.quiz.max_attempts"/> attempts allowed
</div>
</t>
</div>
</t>
</div>
<div class="o_genius_footer">
<span class="o_genius_stars">⭐⭐⭐</span>
</div>
</div>
</div>
<!-- Floating icons -->
<div class="o_genius_floating">
<i class="floating-icon fi1 fa fa-star"/>
<i class="floating-icon fi2 fa fa-trophy"/>
<i class="floating-icon fi3 fa fa-thumbs-up"/>
<i class="floating-icon fi4 fa fa-star"/>
</div>
</div>
</t>
</templates>

View File

@ -0,0 +1,325 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- GENIUS QUIZ POPUP WIDGET -->
<t t-name="tour_genius.GeniusQuizPopup_v2">
<div class="o_genius_quiz_popup">
<div class="o_quiz_overlay"/>
<div class="o_quiz_modal">
<!-- Header -->
<div class="o_quiz_header">
<div class="o_quiz_title_section">
<i class="fa fa-graduation-cap o_quiz_icon"/>
<span class="o_quiz_title"><t t-esc="widget.quizName"/></span>
</div>
<div class="o_quiz_header_right">
<div class="o_quiz_timer">
<i class="fa fa-clock-o"/>
<span class="o_quiz_timer_value">00:00</span>
</div>
<button class="o_quiz_close" title="Close">
<i class="fa fa-times"/>
</button>
</div>
</div>
<!-- Progress Bar -->
<div class="o_quiz_progress">
<div class="o_quiz_progress_bar">
<div class="o_quiz_progress_fill"/>
</div>
<span class="o_quiz_progress_text">1 / ?</span>
</div>
<!-- Body -->
<div class="o_quiz_body">
<t t-call="tour_genius.QuizBody"/>
</div>
</div>
</div>
</t>
<!-- Quiz Body Template (Reusable for Retry) -->
<t t-name="tour_genius.QuizBody">
<div class="o_quiz_content">
<div class="o_quiz_question_container"/>
</div>
<!-- Navigation -->
<div class="o_quiz_navigation">
<button class="btn o_quiz_prev" disabled="disabled">
<i class="fa fa-chevron-left"/> Previous
</button>
<button class="btn btn-primary o_quiz_next">
Next <i class="fa fa-chevron-right"/>
</button>
<button class="btn btn-success o_quiz_submit" style="display: none;">
<i class="fa fa-check"/> Submit
</button>
</div>
</t>
<!-- Individual Question Template -->
<t t-name="tour_genius.QuizQuestion">
<div class="o_quiz_question">
<!-- Question Number Badge -->
<div class="o_question_badge">
Question <t t-esc="index + 1"/> of <t t-esc="total"/>
</div>
<!-- Question Text -->
<div class="o_question_text">
<t t-esc="question.text"/>
</div>
<!-- Question Image (if any) -->
<t t-if="question.image">
<div class="o_question_image">
<img t-att-src="'data:image/png;base64,' + question.image" alt="Question Image"/>
</div>
</t>
<!-- Answer Options Based on Type -->
<!-- Short Answer / Fill in the Blank -->
<t t-if="question.type === 'short_answer' || question.type === 'fill_blank'">
<div class="o_short_answer_container">
<input type="text" class="o_short_answer_input"
t-att-placeholder="question.type === 'fill_blank' ? 'Fill in the blank...' : 'Type your answer here...'"
t-att-value="response.textAnswer"/>
</div>
</t>
<!-- Ordering (Drag and Drop List) -->
<t t-elif="question.type === 'ordering'">
<div class="o_ordering_container" data-question-id="question.id">
<div class="o_ordering_items">
<t t-foreach="question.answers" t-as="answer">
<div class="o_ordering_item"
t-att-data-answer-id="answer.id"
draggable="true">
<span class="o_ordering_handle">
<i class="fa fa-bars"/>
</span>
<span class="o_ordering_number" t-esc="answer_index + 1"/>
<span class="o_ordering_text" t-esc="answer.text"/>
</div>
</t>
</div>
</div>
</t>
<!-- Single Choice / Multiple Choice -->
<t t-else="">
<div class="o_answers_grid">
<t t-foreach="question.answers" t-as="answer">
<div class="o_answer_option"
t-att-data-answer-id="answer.id"
t-att-class="response.selectedIds.indexOf(answer.id) >= 0 ? 'selected' : ''">
<div class="o_answer_indicator">
<t t-if="question.type === 'multiple'">
<i t-att-class="response.selectedIds.indexOf(answer.id) >= 0 ? 'fa fa-check-square' : 'fa fa-square-o'"/>
</t>
<t t-else="">
<i t-att-class="response.selectedIds.indexOf(answer.id) >= 0 ? 'fa fa-dot-circle-o' : 'fa fa-circle-o'"/>
</t>
</div>
<div class="o_answer_text">
<t t-esc="answer.text"/>
</div>
</div>
</t>
</div>
</t>
<!-- Question Type Hint -->
<div class="o_question_hint">
<t t-if="question.type === 'multiple'">
<i class="fa fa-info-circle"/> Select all that apply
</t>
<t t-elif="question.type === 'ordering'">
<i class="fa fa-info-circle"/> Drag items to arrange in correct order
</t>
<t t-elif="question.type === 'fill_blank'">
<i class="fa fa-info-circle"/> Fill in the blank
</t>
</div>
</div>
</t>
<!-- Quiz Results Template -->
<t t-name="tour_genius.QuizResults_v2">
<div class="o_quiz_results">
<!-- Score Display with Integrated Trophy -->
<div class="o_results_score_container">
<div t-att-class="'o_results_circle ' + (results.is_passed ? 'passed' : 'failed')">
<t t-if="results.is_passed">
<i class="fa fa-trophy o_circle_trophy"/>
</t>
<div class="o_results_percentage">
<span class="o_score_value"><t t-esc="Math.round(results.score)"/></span><span class="o_score_percent">%</span>
</div>
</div>
</div>
<!-- Status Message -->
<div class="o_results_status">
<t t-if="results.is_passed">
<div class="o_status_passed">
<!-- Trophy moved to circle -->
<h3>Excellent Work!</h3>
<!-- Custom success message or default -->
<t t-if="results.success_message">
<div class="o_custom_message" t-raw="results.success_message"/>
</t>
<t t-else="">
<p>You passed the <strong><t t-esc="quizName"/></strong></p>
</t>
</div>
</t>
<t t-else="">
<!-- GENIUS UNIFIED FEEDBACK CARD for Failed State -->
<div class="genius-feedback-card">
<!-- Header: Keep Learning + passing score on same line -->
<div class="feedback-header">
<i class="fa fa-refresh feedback-icon"/>
<span class="feedback-title">Keep Learning!</span>
<span class="feedback-separator"></span>
<span class="feedback-requirement">
<t t-if="results.fail_message">
<span t-raw="results.fail_message"/>
</t>
<t t-else="">
Need <strong><t t-esc="passingScore"/>%</strong>
</t>
</span>
</div>
<!-- Attempts Status (only if limited attempts) -->
<t t-if="results.max_attempts > 0">
<div class="feedback-attempts">
<!-- Visual Dots -->
<div class="attempts-dots">
<t t-foreach="results.max_attempts" t-as="i">
<span t-att-class="'attempt-dot ' + (i &lt; (results.max_attempts - results.attempts_remaining) ? 'used' : 'available')"/>
</t>
</div>
<!-- Dynamic Message -->
<t t-if="results.attempts_remaining > 0">
<div t-att-class="'attempts-message ' + (results.attempts_remaining === 1 ? 'last-chance' : '')">
<i class="fa fa-heart"/>
<t t-if="results.attempts_remaining === 1">
<span>Last chance! Make it count.</span>
</t>
<t t-elif="results.attempts_remaining === 2">
<span>2 attempts left</span>
</t>
<t t-else="">
<span><t t-esc="results.attempts_remaining"/> attempts remaining</span>
</t>
</div>
</t>
<t t-else="">
<div class="attempts-message exhausted">
<i class="fa fa-times-circle"/>
<span>No attempts remaining</span>
</div>
</t>
</div>
</t>
<!-- Unlimited attempts: encouraging message -->
<t t-else="">
<div class="feedback-unlimited">
<i class="fa fa-infinity"/> You can try again anytime!
</div>
</t>
</div>
</t>
</div>
<!-- Stats -->
<div class="o_results_stats">
<div class="genius_stat_item">
<span class="genius_stat_value"><t t-esc="results.points_earned"/></span>
<span class="genius_stat_label">Points Earned</span>
</div>
<div class="genius_stat_item">
<span class="genius_stat_value"><t t-esc="results.points_possible"/></span>
<span class="genius_stat_label">Total Points</span>
</div>
<div class="genius_stat_item">
<span class="genius_stat_value"><t t-esc="results.time_formatted"/></span>
<span class="genius_stat_label">Time Taken</span>
</div>
</div>
<!-- Review Answers Toggle - Genius Style -->
<t t-if="results.show_correct_answers and results.correct_answers">
<div class="o_review_toggle_container">
<label class="genius-toggle">
<input type="checkbox" class="genius-toggle-input o_review_answers_toggle"/>
<span class="genius-toggle-slider"></span>
<span class="genius-toggle-label">
<i class="fa fa-list-ol"/> Show Answer Review
</span>
</label>
</div>
<div class="o_answer_review_section" style="display: none;">
<!-- Will be populated by JS -->
</div>
</t>
<!-- Actions - Genius Styled and Centered -->
<div class="o_results_actions genius-results-actions">
<t t-if="results.is_passed">
<!-- Download Certificate -->
<t t-if="results.attempt_id">
<a class="btn genius-btn-download o_quiz_download_pdf"
t-attf-href="/tour_genius/certificate/download/#{results.attempt_id}"
target="_blank">
<i class="fa fa-certificate" style="margin-right: 8px;"></i> View Certificate
</a>
</t>
<button class="btn genius-btn-done o_quiz_close">
<i class="fa fa-check"/> Done
</button>
</t>
<t t-else="">
<button class="btn genius-btn-retry o_quiz_retry" t-if="results.can_retry">
<i class="fa fa-refresh"/> Try Again
</button>
<button class="btn genius-btn-close o_quiz_close">
<i class="fa fa-times"/> Close
</button>
</t>
</div>
</div>
</t>
<!-- Submit Confirmation Overlay -->
<t t-name="tour_genius.QuizConfirmSubmit">
<div class="o_quiz_confirm_overlay">
<div class="o_quiz_confirm_dialog">
<div class="o_confirm_icon">
<i class="fa fa-exclamation-triangle"/>
</div>
<h4 class="o_confirm_title">Unanswered Questions</h4>
<p class="o_confirm_message">
You have <strong><t t-esc="unansweredCount"/></strong> unanswered question<t t-if="unansweredCount > 1">s</t>.
<br/>Submit anyway?
</p>
<div class="o_confirm_actions">
<button class="btn o_confirm_no">
<i class="fa fa-arrow-left"/> Go Back
</button>
<button class="btn o_confirm_yes">
<i class="fa fa-check"/> Submit Anyway
</button>
</div>
</div>
</div>
</t>
</templates>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!--
GeniusTip Template - Premium tooltip with glassmorphism design.
-->
<div t-name="GeniusTip" t-attf-class="o_tooltip genius-tip #{widget.info.position} #{widget.is_anchor_fixed_position ? 'o_tooltip_fixed' : ''}">
<!-- Odoo overlay for mouse detection -->
<div class="o_tooltip_overlay"/>
<!-- Genius Premium Card -->
<div class="genius-tip-card">
<!-- Step Badge - No icon, just text -->
<div class="genius-tip-step-badge">
<span>Step <t t-esc="widget.geniusStepIndex + 1"/> of <t t-esc="widget.geniusTotalSteps"/></span>
</div>
<!-- Title Row with Lightbulb -->
<div class="genius-tip-title-row">
<span class="genius-tip-title-icon" style="font-size: 22px;">💡</span>
<h3 class="genius-tip-title"><t t-esc="widget.geniusTitle"/></h3>
</div>
<!-- Content -->
<div class="genius-tip-content o_tooltip_content">
<t t-raw="widget.info.content"/>
</div>
<!-- Action Buttons -->
<div class="genius-tip-actions">
<button type="button" class="genius-tip-skip">✕ Skip Tour</button>
<button type="button" class="genius-tip-next">GOT IT! →</button>
</div>
</div>
</div>
</templates>

View File

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Recorder Panel Template -->
<t t-name="tour_genius.RecorderPanel">
<div class="genius-recorder-panel">
<!-- Header (Draggable) -->
<div class="recorder-header">
<h4>
<i class="fa fa-video-camera"></i>
Recording: <t t-esc="widget.topicName"/>
</h4>
<button class="recorder-close" title="Close">×</button>
</div>
<!-- Body -->
<div class="recorder-body">
<!-- Inline Feedback Alert -->
<div class="recorder-alert alert d-none mb-2" role="alert"></div>
<!-- Tour Info Section (Visible for New Tours) -->
<t t-if="widget.isNewTour">
<div class="tour-info-section mb-3 p-2" style="background: #f8f9fa; border-radius: 8px; border: 1px solid #e2e8f0;">
<div class="recorder-field mb-2">
<label>Tour Name <span class="text-danger">*</span></label>
<input type="text" class="tour-name-input form-control"
placeholder="e.g., Sales Order Training"/>
</div>
<div class="recorder-field">
<label>Target Module (Optional)</label>
<input type="text" class="tour-module-input form-control"
list="module-datalist"
placeholder="Search module..."/>
<datalist id="module-datalist" class="tour-module-datalist">
</datalist>
<input type="hidden" class="tour-module-select"/>
</div>
</div>
<hr class="my-2"/>
</t>
<!-- Track On Button -->
<div class="recorder-field track-section">
<button class="btn btn-block btn-track-on">
<i class="fa fa-crosshairs"></i>
Track On
</button>
<small class="text-muted mt-1 d-block">
Click to select any element on the page
</small>
</div>
<!-- CSS Selector (read-only) -->
<div class="recorder-field">
<label>CSS Selector</label>
<input type="text" class="css-selector-input css-selector-display"
placeholder="Click Track On, then click an element..."/>
</div>
<!-- Step Title -->
<div class="recorder-field">
<label>Step Title <span class="text-danger">*</span></label>
<input type="text" class="step-title-input"
placeholder="e.g., Click the Save button"/>
</div>
<!-- Instruction -->
<div class="recorder-field">
<label>Instructions (optional)</label>
<textarea class="step-instruction-input" rows="2"
placeholder="Additional help text..."></textarea>
</div>
<!-- Position & Type -->
<div class="row">
<div class="col-6">
<div class="recorder-field">
<label>Tooltip Position</label>
<select class="position-select form-control">
<option value="bottom">Bottom</option>
<option value="top">Top</option>
<option value="left">Left</option>
<option value="right">Right</option>
</select>
</div>
</div>
<div class="col-6">
<div class="recorder-field">
<label>Action Type</label>
<select class="step-type-select form-control">
<option value="click">Click</option>
<option value="input">Input Text</option>
</select>
</div>
</div>
</div>
<!-- Input Value (Hidden by default) -->
<div class="recorder-field input-value-field d-none">
<label>Text to Enter <span class="text-danger">*</span></label>
<input type="text" class="input-value-input"
placeholder="Value to type..."/>
</div>
<!-- Save Step Button -->
<div class="recorder-actions">
<button class="btn btn-save-step">
<i class="fa fa-plus"></i>
Save Step
</button>
</div>
</div>
<!-- Footer -->
<div class="recorder-footer">
<span class="step-counter">0 steps recorded</span>
<button class="btn btn-finish">
<i class="fa fa-check"></i>
Finish
</button>
</div>
</div>
</t>
</templates>

View File

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Systray Icon - Following Odoo official pattern (li as root) -->
<t t-name="GeniusSystrayIcon">
<li class="o_mail_systray_item genius-systray">
<a class="dropdown-toggle o-no-caret" data-toggle="dropdown"
data-display="static" aria-expanded="false"
title="Tour Genius Training" href="#" role="button">
<i class="fa fa-lightbulb-o" role="img" aria-label="Tours"/>
<span class="o_notification_counter badge badge-pill"
t-att-style="badge_count ? '' : 'display:none;'">
<t t-esc="badge_count or 0"/>
</span>
</a>
<div class="genius-dropdown-menu dropdown-menu dropdown-menu-right" role="menu">
<div class="genius-dropdown-header px-3 py-2 border-bottom">
<strong>Tour Genius Training</strong>
</div>
<div class="genius-dropdown-content">
<div class="genius-loading" style="display:none;">
<i class="fa fa-spinner fa-spin"/> Loading...
</div>
<div class="genius-tours-list">
<!-- Tours will be inserted here -->
</div>
<div class="genius-empty p-3 text-center" style="display:none;">
<p class="text-muted mb-0">No training available for this screen</p>
</div>
</div>
<div class="genius-dropdown-footer px-3 py-2 border-top">
<a href="#" class="genius-view-all btn btn-sm btn-primary w-100">
<i class="fa fa-book"/> View All Training
</a>
</div>
</div>
</li>
</t>
<t t-name="GeniusSystrayItem">
<div t-attf-class="genius-tour-item #{tour.is_strict_match ? 'highlight-item' : ''}" t-att-data-tour-id="tour.id">
<div class="d-flex align-items-center w-100">
<!-- Icon -->
<div class="genius-tour-icon mr-3">
<i t-attf-class="fa #{tour.icon} fa-2x text-primary"/>
</div>
<!-- Content -->
<div class="genius-tour-info flex-grow-1" style="min-width: 0;"> <!-- min-width: 0 is crucial for flex child truncation -->
<div class="d-flex align-items-center mb-1">
<span class="genius-tour-name font-weight-bold text-dark" t-esc="tour.name"/>
<!-- Status Badges - Now strictly next to name -->
<span class="d-flex flex-shrink-0 align-items-center">
<t t-if="tour.status == 'new'">
<span class="badge badge-pill badge-info text-uppercase ml-2" style="font-size: 0.6rem; padding: 2px 6px;">New</span>
</t>
<t t-if="tour.status == 'verified'">
<span class="badge badge-pill badge-warning text-uppercase ml-2" style="font-size: 0.6rem; padding: 2px 6px;" title="Certified Genius"><i class="fa fa-star"/> Mastered</span>
</t>
<t t-if="tour.is_strict_match and tour.status == 'new'">
<span class="badge badge-pill badge-danger text-uppercase ml-2" style="font-size: 0.6rem; padding: 2px 6px;"><i class="fa fa-thumbs-up"/> Top Pick</span>
</t>
</span>
</div>
<div class="d-flex align-items-center">
<span class="genius-tour-meta text-muted small">
<i class="fa fa-clock-o"/> <t t-esc="tour.duration_minutes"/>m &#8226; <t t-esc="tour.step_count"/> steps
</span>
</div>
<!-- Progress Bar (Only for In Progress) -->
<t t-if="tour.status == 'in_progress'">
<div class="progress mt-1" style="height: 4px;">
<div class="progress-bar bg-primary" role="progressbar" t-att-style="'width: ' + tour.progress + '%;'" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"/>
</div>
</t>
</div>
<!-- Action Button - Fixed Width & No Shrink -->
<div class="genius-tour-action ml-3 flex-shrink-0">
<button t-attf-class="btn btn-sm btn-icon #{tour.status == 'in_progress' ? 'btn-primary' : 'btn-light'} genius-run-tour"
t-att-data-tour-id="tour.id"
t-att-title="tour.status == 'in_progress' ? 'Resume Tour' : 'Start Tour'">
<t t-if="tour.status == 'in_progress'">
<i class="fa fa-play"/>
</t>
<t t-elif="tour.status == 'verified' or tour.status == 'done'">
<i class="fa fa-refresh text-muted"/>
</t>
<t t-else="">
<i class="fa fa-play text-primary"/>
</t>
</button>
</div>
</div>
</div>
</t>
</templates>

View File

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from . import test_training
from . import test_quiz
from . import test_security
from . import test_advanced

View File

@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
"""
Advanced Features Tests (Simplified)
=====================================
Tests for gamification and reminders.
Removed tests for deleted models (Recorder, DraftStep, StepTiming, Certificate).
"""
from odoo.tests import common, tagged
@tagged('post_install', '-at_install', 'tour_genius')
class TestAdvancedFeatures(common.TransactionCase):
"""Test cases for Advanced Features (Leaderboard, Reminder)"""
def setUp(self):
super(TestAdvancedFeatures, self).setUp()
self.Plan = self.env['genius.plan']
self.Topic = self.env['genius.topic']
self.Leaderboard = self.env['genius.leaderboard']
self.Reminder = self.env['genius.reminder']
# Create test user
self.test_user = self.env['res.users'].create({
'name': 'Advanced Test User',
'login': 'advanced_test_user',
'email': 'advanced@test.com',
})
# Create base data
self.test_plan = self.Plan.create({'name': 'Advanced Test Plan'})
self.test_topic = self.Topic.create({
'name': 'Advanced Test Topic',
'plan_id': self.test_plan.id,
})
# =========================================================================
# Leaderboard Tests
# =========================================================================
def test_leaderboard_creation(self):
"""Test creating leaderboard entry"""
entry = self.Leaderboard.create({
'user_id': self.test_user.id,
'period_type': 'alltime',
'points': 100,
'topics_completed': 5,
})
self.assertTrue(entry.id)
self.assertEqual(entry.points, 100)
def test_leaderboard_get_method(self):
"""Test leaderboard get API method"""
# Create an entry
self.Leaderboard.create({
'user_id': self.test_user.id,
'period_type': 'alltime',
'points': 100,
'topics_completed': 5,
'rank': 1,
})
# Get leaderboard
results = self.Leaderboard.get_leaderboard('alltime', limit=10)
self.assertIsInstance(results, list)
def test_leaderboard_calculate_stats(self):
"""Test leaderboard stats calculation"""
# Create progress for user
self.env['genius.progress'].create({
'user_id': self.test_user.id,
'topic_id': self.test_topic.id,
'state': 'done',
})
# Calculate stats
stats = self.Leaderboard.calculate_user_stats(
self.test_user.id, 'alltime'
)
self.assertEqual(stats['topics_completed'], 1)
self.assertTrue(stats['points'] > 0)
def test_leaderboard_user_rank(self):
"""Test get user rank method"""
result = self.Leaderboard.get_user_rank(self.test_user.id, 'alltime')
self.assertIsInstance(result, dict)
self.assertIn('rank', result)
# =========================================================================
# Reminder Tests
# =========================================================================
def test_reminder_creation(self):
"""Test creating a reminder"""
reminder = self.Reminder.create({
'name': 'Test Reminder',
'user_id': self.test_user.id,
'reminder_type': 'incomplete_topic',
'topic_id': self.test_topic.id,
})
self.assertTrue(reminder.id)
self.assertEqual(reminder.state, 'pending')
self.assertTrue(reminder.message_body)
def test_reminder_cancel(self):
"""Test canceling a reminder"""
reminder = self.Reminder.create({
'name': 'Cancel Test',
'user_id': self.test_user.id,
'reminder_type': 'streak',
})
reminder.action_cancel()
self.assertEqual(reminder.state, 'cancelled')
def test_reminder_types(self):
"""Test different reminder types"""
# Test incomplete topic
r1 = self.Reminder.create({
'user_id': self.test_user.id,
'reminder_type': 'incomplete_topic',
'topic_id': self.test_topic.id,
})
self.assertTrue(r1.message_body)
# Test streak reminder
r2 = self.Reminder.create({
'user_id': self.test_user.id,
'reminder_type': 'streak',
})
self.assertTrue(r2.message_body)
# Test custom reminder
r3 = self.Reminder.create({
'user_id': self.test_user.id,
'reminder_type': 'custom',
'custom_message': '<p>Custom message here</p>',
})
self.assertEqual(r3.message_body, '<p>Custom message here</p>')
def test_reminder_recurring(self):
"""Test recurring reminder setting"""
reminder = self.Reminder.create({
'name': 'Recurring Test',
'user_id': self.test_user.id,
'reminder_type': 'streak',
'is_recurring': True,
'recurrence_interval': 7,
})
self.assertTrue(reminder.is_recurring)
self.assertEqual(reminder.recurrence_interval, 7)

View File

@ -0,0 +1,304 @@
# -*- coding: utf-8 -*-
from odoo.tests import common, tagged
@tagged('post_install', '-at_install', 'tour_genius')
class TestQuizModels(common.TransactionCase):
"""Test cases for Quiz Layer models"""
def setUp(self):
super(TestQuizModels, self).setUp()
self.Quiz = self.env['genius.quiz']
self.Question = self.env['genius.quiz.question']
self.Answer = self.env['genius.quiz.answer']
self.Attempt = self.env['genius.quiz.attempt']
self.Response = self.env['genius.quiz.response']
# Create test user
self.test_user = self.env['res.users'].create({
'name': 'Test Quiz User',
'login': 'test_quiz_user',
'email': 'quizuser@test.com',
})
# =========================================================================
# Quiz Tests
# =========================================================================
def test_quiz_creation(self):
"""Test creating a quiz"""
quiz = self.Quiz.create({
'name': 'Sales Quiz',
'passing_score': 70.0,
})
self.assertTrue(quiz.id)
self.assertEqual(quiz.passing_score, 70.0)
self.assertEqual(quiz.question_count, 0)
def test_quiz_question_count(self):
"""Test question count computation"""
quiz = self.Quiz.create({'name': 'Test Quiz'})
self.Question.create({
'quiz_id': quiz.id,
'question_text': '<p>What is Odoo?</p>',
'question_type': 'single',
})
self.Question.create({
'quiz_id': quiz.id,
'question_text': '<p>How to create a record?</p>',
'question_type': 'single',
})
quiz.invalidate_cache()
self.assertEqual(quiz.question_count, 2)
# =========================================================================
# Question Tests
# =========================================================================
def test_question_creation_single_choice(self):
"""Test creating a single choice question"""
quiz = self.Quiz.create({'name': 'Test Quiz'})
question = self.Question.create({
'quiz_id': quiz.id,
'question_text': '<p>What is 2+2?</p>',
'question_type': 'single',
'points': 5,
})
self.assertTrue(question.id)
self.assertEqual(question.question_type, 'single')
self.assertEqual(question.points, 5)
def test_question_with_answers(self):
"""Test question with answer options"""
quiz = self.Quiz.create({'name': 'Test Quiz'})
question = self.Question.create({
'quiz_id': quiz.id,
'question_text': '<p>What is Odoo?</p>',
'question_type': 'single',
})
# Add answers
correct = self.Answer.create({
'question_id': question.id,
'answer_text': 'An ERP system',
'is_correct': True,
})
wrong = self.Answer.create({
'question_id': question.id,
'answer_text': 'A game',
'is_correct': False,
})
correct_answers = question.get_correct_answers()
self.assertEqual(len(correct_answers), 1)
self.assertEqual(correct_answers[0], correct)
def test_question_short_answer(self):
"""Test short answer question type"""
quiz = self.Quiz.create({'name': 'Test Quiz'})
question = self.Question.create({
'quiz_id': quiz.id,
'question_text': '<p>What is the capital of France?</p>',
'question_type': 'short_answer',
'correct_short_answer': 'Paris',
})
correct_answers = question.get_correct_answers()
self.assertEqual(correct_answers, ['Paris'])
# =========================================================================
# Attempt Tests
# =========================================================================
def test_attempt_creation(self):
"""Test creating a quiz attempt"""
quiz = self.Quiz.create({
'name': 'Test Quiz',
'passing_score': 70.0,
})
attempt = self.Attempt.create({
'quiz_id': quiz.id,
'user_id': self.test_user.id,
})
self.assertTrue(attempt.id)
self.assertEqual(attempt.state, 'in_progress')
def test_attempt_scoring(self):
"""Test attempt scoring calculation"""
quiz = self.Quiz.create({
'name': 'Test Quiz',
'passing_score': 50.0,
})
question1 = self.Question.create({
'quiz_id': quiz.id,
'question_text': '<p>Question 1</p>',
'question_type': 'single',
'points': 10,
})
correct1 = self.Answer.create({
'question_id': question1.id,
'answer_text': 'Correct',
'is_correct': True,
})
question2 = self.Question.create({
'quiz_id': quiz.id,
'question_text': '<p>Question 2</p>',
'question_type': 'single',
'points': 10,
})
correct2 = self.Answer.create({
'question_id': question2.id,
'answer_text': 'Correct',
'is_correct': True,
})
attempt = self.Attempt.create({
'quiz_id': quiz.id,
'user_id': self.test_user.id,
})
# Answer first question correctly
response1 = self.Response.create({
'attempt_id': attempt.id,
'question_id': question1.id,
'selected_answer_ids': [(6, 0, [correct1.id])],
})
response1._score_response()
self.assertTrue(response1.is_correct)
# Answer second question incorrectly (no answer selected)
response2 = self.Response.create({
'attempt_id': attempt.id,
'question_id': question2.id,
'selected_answer_ids': [(6, 0, [])],
})
response2._score_response()
self.assertFalse(response2.is_correct)
# Check score
attempt.invalidate_cache()
self.assertEqual(attempt.points_earned, 10)
self.assertEqual(attempt.points_possible, 20)
self.assertEqual(attempt.score, 50.0)
self.assertTrue(attempt.is_passed)
def test_attempt_submit(self):
"""Test submitting an attempt"""
quiz = self.Quiz.create({'name': 'Test Quiz'})
attempt = self.Attempt.create({
'quiz_id': quiz.id,
'user_id': self.test_user.id,
})
self.assertEqual(attempt.state, 'in_progress')
attempt.action_submit()
self.assertEqual(attempt.state, 'submitted')
self.assertTrue(attempt.submitted_at)
# =========================================================================
# Response Tests
# =========================================================================
def test_response_scoring_single_choice(self):
"""Test response scoring for single choice"""
quiz = self.Quiz.create({'name': 'Test Quiz'})
question = self.Question.create({
'quiz_id': quiz.id,
'question_text': '<p>Test?</p>',
'question_type': 'single',
'points': 5,
})
correct = self.Answer.create({
'question_id': question.id,
'answer_text': 'Correct',
'is_correct': True,
})
wrong = self.Answer.create({
'question_id': question.id,
'answer_text': 'Wrong',
'is_correct': False,
})
attempt = self.Attempt.create({
'quiz_id': quiz.id,
'user_id': self.test_user.id,
})
# Correct response
response = self.Response.create({
'attempt_id': attempt.id,
'question_id': question.id,
'selected_answer_ids': [(6, 0, [correct.id])],
})
response._score_response()
self.assertTrue(response.is_correct)
self.assertEqual(response.points_awarded, 5)
def test_response_scoring_short_answer(self):
"""Test response scoring for short answer"""
quiz = self.Quiz.create({'name': 'Test Quiz'})
question = self.Question.create({
'quiz_id': quiz.id,
'question_text': '<p>Capital of France?</p>',
'question_type': 'short_answer',
'correct_short_answer': 'Paris',
'case_sensitive': False,
'points': 10,
})
attempt = self.Attempt.create({
'quiz_id': quiz.id,
'user_id': self.test_user.id,
})
# Correct response (different case)
response = self.Response.create({
'attempt_id': attempt.id,
'question_id': question.id,
'text_answer': 'paris',
})
response._score_response()
self.assertTrue(response.is_correct)
self.assertEqual(response.points_awarded, 10)
def test_response_scoring_short_answer_case_sensitive(self):
"""Test case-sensitive short answer"""
quiz = self.Quiz.create({'name': 'Test Quiz'})
question = self.Question.create({
'quiz_id': quiz.id,
'question_text': '<p>Password?</p>',
'question_type': 'short_answer',
'correct_short_answer': 'Secret123',
'case_sensitive': True,
'points': 10,
})
attempt = self.Attempt.create({
'quiz_id': quiz.id,
'user_id': self.test_user.id,
})
# Wrong case
response = self.Response.create({
'attempt_id': attempt.id,
'question_id': question.id,
'text_answer': 'secret123',
})
response._score_response()
self.assertFalse(response.is_correct)

View File

@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
"""
Security Tests (Simplified)
============================
Tests for security groups and access control.
"""
from odoo.tests import common, tagged
@tagged('post_install', '-at_install', 'tour_genius')
class TestSecurity(common.TransactionCase):
"""Test cases for Security and Access Control"""
def setUp(self):
super(TestSecurity, self).setUp()
self.Plan = self.env['genius.plan']
self.Topic = self.env['genius.topic']
self.Progress = self.env['genius.progress']
# Get security groups
self.group_user = self.env.ref('tour_genius.group_genius_user')
self.group_instructor = self.env.ref('tour_genius.group_genius_instructor')
self.group_admin = self.env.ref('tour_genius.group_genius_admin')
# Create test users with different roles
self.user_trainee = self.env['res.users'].create({
'name': 'Trainee User',
'login': 'trainee_test',
'email': 'trainee@test.com',
'groups_id': [(6, 0, [self.group_user.id])],
})
self.user_instructor = self.env['res.users'].create({
'name': 'Instructor User',
'login': 'instructor_test',
'email': 'instructor@test.com',
'groups_id': [(6, 0, [self.group_instructor.id])],
})
# Create base data
self.test_plan = self.Plan.create({'name': 'Security Test Plan'})
# =========================================================================
# Group Tests
# =========================================================================
def test_groups_exist(self):
"""Test that security groups are created"""
self.assertTrue(self.group_user)
self.assertTrue(self.group_instructor)
self.assertTrue(self.group_admin)
def test_group_hierarchy(self):
"""Test group inheritance"""
# Instructor implies User
self.assertIn(self.group_user, self.group_instructor.implied_ids)
# Admin implies Instructor
self.assertIn(self.group_instructor, self.group_admin.implied_ids)
# =========================================================================
# Access Rights Tests
# =========================================================================
def test_trainee_can_read_topic(self):
"""Test trainee can read topics"""
topic = self.Topic.create({'name': 'Public Topic'})
# Switch to trainee user and try to read
topic_as_trainee = topic.with_user(self.user_trainee)
self.assertTrue(topic_as_trainee.name)
def test_instructor_can_create_plan(self):
"""Test instructor can create plans"""
Plan = self.Plan.with_user(self.user_instructor)
plan = Plan.create({'name': 'Instructor Plan'})
self.assertTrue(plan.id)
def test_instructor_can_create_topic(self):
"""Test instructor can create topics"""
Plan = self.Plan.with_user(self.user_instructor)
Topic = self.Topic.with_user(self.user_instructor)
plan = Plan.create({'name': 'Instructor Test Plan'})
topic = Topic.create({
'name': 'Instructor Topic',
'plan_id': plan.id,
})
self.assertTrue(topic.id)
# =========================================================================
# Progress Access Tests
# =========================================================================
def test_trainee_can_create_own_progress(self):
"""Test trainee can create their own progress"""
topic = self.Topic.create({'name': 'Test Topic Progress'})
Progress = self.Progress.with_user(self.user_trainee)
progress = Progress.create({
'user_id': self.user_trainee.id,
'topic_id': topic.id,
})
self.assertTrue(progress.id)
# =========================================================================
# Module Access Mixin Tests
# =========================================================================
def test_user_accessible_modules_method(self):
"""Test _get_accessible_modules method exists on users"""
modules = self.user_trainee._get_accessible_modules()
self.assertIsInstance(modules, list)

View File

@ -0,0 +1,198 @@
# -*- coding: utf-8 -*-
"""
Training Models Tests (Simplified)
===================================
Tests for tour genius training layer - updated for flat structure.
"""
from odoo.tests import common, tagged
from odoo.exceptions import ValidationError
@tagged('post_install', '-at_install', 'tour_genius')
class TestTrainingModels(common.TransactionCase):
"""Test cases for Training Layer models"""
def setUp(self):
super(TestTrainingModels, self).setUp()
self.Plan = self.env['genius.plan']
self.Topic = self.env['genius.topic']
self.Step = self.env['genius.topic.step']
self.Progress = self.env['genius.progress']
self.Tag = self.env['genius.tour.tag']
# Create test user
self.test_user = self.env['res.users'].create({
'name': 'Test Trainee',
'login': 'test_trainee',
'email': 'trainee@test.com',
})
# Create base plan
self.test_plan = self.Plan.create({'name': 'Test Plan'})
# =========================================================================
# Plan Tests
# =========================================================================
def test_plan_creation(self):
"""Test creating a training plan"""
plan = self.Plan.create({
'name': 'Test Training Plan',
})
self.assertTrue(plan.id)
self.assertEqual(plan.state, 'draft')
def test_plan_publish_workflow(self):
"""Test plan state workflow"""
plan = self.Plan.create({'name': 'Test Plan Workflow'})
# Initial state
self.assertEqual(plan.state, 'draft')
# Publish
plan.action_publish()
self.assertEqual(plan.state, 'published')
# Archive
plan.action_archive()
self.assertEqual(plan.state, 'archived')
# Back to draft
plan.action_draft()
self.assertEqual(plan.state, 'draft')
def test_plan_topic_count(self):
"""Test topic count computation"""
plan = self.Plan.create({'name': 'Test Plan Topics'})
self.assertEqual(plan.topic_count, 0)
# Add topics
self.Topic.create({'name': 'Topic 1', 'plan_id': plan.id})
self.Topic.create({'name': 'Topic 2', 'plan_id': plan.id})
plan.invalidate_cache()
self.assertEqual(plan.topic_count, 2)
# =========================================================================
# Topic Tests
# =========================================================================
def test_topic_creation(self):
"""Test creating a topic"""
topic = self.Topic.create({
'name': 'Getting Started',
})
self.assertTrue(topic.id)
def test_topic_step_count(self):
"""Test step count computation"""
topic = self.Topic.create({'name': 'Test Topic Steps'})
self.assertEqual(topic.step_count, 0)
# Add steps
self.Step.create({'topic_id': topic.id, 'title': 'Step 1'})
self.Step.create({'topic_id': topic.id, 'title': 'Step 2'})
topic.invalidate_cache()
self.assertEqual(topic.step_count, 2)
def test_topic_consumed_tracking(self):
"""Test consumed by tracking"""
topic = self.Topic.create({'name': 'Test Topic Consumed'})
self.assertEqual(topic.consumed_count, 0)
# Mark as consumed
topic.consumed_user_ids = [(4, self.test_user.id)]
topic.invalidate_cache()
self.assertEqual(topic.consumed_count, 1)
def test_topic_default_values(self):
"""Test topic default values"""
topic = self.Topic.create({'name': 'Defaults Test'})
self.assertEqual(topic.duration_minutes, 15)
self.assertTrue(topic.active)
# =========================================================================
# Topic Step Tests
# =========================================================================
def test_step_creation(self):
"""Test creating a topic step"""
topic = self.Topic.create({'name': 'Test Topic'})
step = self.Step.create({
'topic_id': topic.id,
'title': 'Click Save Button',
'step_type': 'click',
'css_selector': '.o_form_button_save',
})
self.assertTrue(step.id)
self.assertEqual(step.step_type, 'click')
def test_step_css_selector(self):
"""Test CSS selector field"""
topic = self.Topic.create({'name': 'Test Topic'})
step = self.Step.create({
'topic_id': topic.id,
'title': 'Custom Step',
'css_selector': '.my-custom-selector',
})
self.assertEqual(step.css_selector, '.my-custom-selector')
# =========================================================================
# Progress Tests
# =========================================================================
def test_progress_creation(self):
"""Test creating progress record"""
topic = self.Topic.create({'name': 'Test Topic'})
progress = self.Progress.create({
'user_id': self.test_user.id,
'topic_id': topic.id,
'state': 'pending',
})
self.assertTrue(progress.id)
self.assertEqual(progress.state, 'pending')
def test_progress_unique_constraint(self):
"""Test unique constraint on user/topic"""
topic = self.Topic.create({'name': 'Test Topic Unique'})
self.Progress.create({
'user_id': self.test_user.id,
'topic_id': topic.id,
})
# Try to create duplicate
with self.assertRaises(Exception):
self.Progress.create({
'user_id': self.test_user.id,
'topic_id': topic.id,
})
# =========================================================================
# Tag Tests
# =========================================================================
def test_tag_creation(self):
"""Test creating a tag"""
tag = self.Tag.create({'name': 'Beginner Tag Test'})
self.assertTrue(tag.id)
def test_tag_unique_name(self):
"""Test tag unique name constraint"""
self.Tag.create({'name': 'Unique Tag Test 123'})
with self.assertRaises(Exception):
self.Tag.create({'name': 'Unique Tag Test 123'})

View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- Leaderboard Views -->
<!-- ============================================================ -->
<record id="view_genius_leaderboard_tree" model="ir.ui.view">
<field name="name">genius.leaderboard.tree</field>
<field name="model">genius.leaderboard</field>
<field name="arch" type="xml">
<tree string="Leaderboard" decoration-bf="rank &lt;= 3" decoration-success="rank == 1">
<field name="rank"/>
<field name="user_id"/>
<field name="points"/>
<field name="topics_completed"/>
<field name="quizzes_passed"/>
<field name="time_spent_hours"/>
<field name="period_type"/>
</tree>
</field>
</record>
<!-- Leaderboard Action -->
<record id="action_genius_leaderboard" model="ir.actions.act_window">
<field name="name">Leaderboard</field>
<field name="res_model">genius.leaderboard</field>
<field name="view_mode">tree</field>
<field name="domain">[('period_type', '=', 'alltime')]</field>
</record>
<!-- ============================================================ -->
<!-- Reminder Views -->
<!-- ============================================================ -->
<record id="view_genius_reminder_tree" model="ir.ui.view">
<field name="name">genius.reminder.tree</field>
<field name="model">genius.reminder</field>
<field name="arch" type="xml">
<tree string="Reminders">
<field name="name"/>
<field name="user_id"/>
<field name="reminder_type"/>
<field name="scheduled_date"/>
<field name="state" widget="badge" decoration-success="state == 'sent'"
decoration-warning="state == 'pending'" decoration-danger="state == 'failed'"/>
</tree>
</field>
</record>
<record id="view_genius_reminder_form" model="ir.ui.view">
<field name="name">genius.reminder.form</field>
<field name="model">genius.reminder</field>
<field name="arch" type="xml">
<form string="Reminder">
<header>
<button name="action_send" type="object"
string="Send Now" class="btn-primary"
attrs="{'invisible': [('state', '!=', 'pending')]}"/>
<button name="action_cancel" type="object"
string="Cancel"
attrs="{'invisible': [('state', '!=', 'pending')]}"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="name"/>
<field name="user_id"/>
<field name="reminder_type"/>
</group>
<group>
<field name="scheduled_date"/>
<field name="sent_date"/>
<field name="is_recurring"/>
<field name="recurrence_interval" attrs="{'invisible': [('is_recurring', '=', False)]}"/>
</group>
</group>
<group>
<field name="topic_id" attrs="{'invisible': [('reminder_type', 'not in', ['incomplete_topic', 'new_topic'])]}"/>
<field name="plan_id" attrs="{'invisible': [('reminder_type', '!=', 'incomplete_plan')]}"/>
</group>
<field name="custom_message" attrs="{'invisible': [('reminder_type', '!=', 'custom')]}"/>
<field name="message_body" readonly="1"/>
</sheet>
</form>
</field>
</record>
<!-- Reminder Action -->
<record id="action_genius_reminder" model="ir.actions.act_window">
<field name="name">Reminders</field>
<field name="res_model">genius.reminder</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menu Items -->
<menuitem id="menu_analytics_leaderboard"
name="Leaderboard"
parent="menu_analytics"
action="action_genius_leaderboard"
sequence="10"/>
<menuitem id="menu_analytics_reminders"
name="Reminders"
parent="menu_analytics"
action="action_genius_reminder"
sequence="20"/>
</odoo>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Backend Assets -->
<template id="assets_backend_inherit_tour_genius" inherit_id="web.assets_backend" name="Tour Genius backend assets">
<xpath expr="link[last()]" position="after">
<link rel="stylesheet" type="text/css" href="/tour_genius/static/src/css/tour_genius.css"/>
</xpath>
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/tour_genius/static/src/js/genius_celebration.js"/>
<script type="text/javascript" src="/tour_genius/static/src/js/genius_quiz_popup.js"/>
<script type="text/javascript" src="/tour_genius/static/src/js/dashboard.js"/>
<script type="text/javascript" src="/tour_genius/static/src/js/recorder_panel.js"/>
<script type="text/javascript" src="/tour_genius/static/src/js/tour_client_action.js"/>
<script type="text/javascript" src="/tour_genius/static/src/js/genius_tip.js"/>
<script type="text/javascript" src="/tour_genius/static/src/js/tour_loader.js"/>
<script type="text/javascript" src="/tour_genius/static/src/js/smart_systray.js"/>
</xpath>
</template>
<!-- Frontend Assets (Website/Login) -->
<template id="assets_frontend_inherit_tour_genius" inherit_id="web.assets_frontend" name="Tour Genius frontend assets">
<xpath expr="link[last()]" position="after">
<link rel="stylesheet" type="text/css" href="/tour_genius/static/src/css/tour_genius.css"/>
</xpath>
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/tour_genius/static/src/js/genius_tip.js"/>
<script type="text/javascript" src="/tour_genius/static/src/js/tour_loader.js"/>
<!-- Also load celebration and popup widgets for frontend usage -->
<script type="text/javascript" src="/tour_genius/static/src/js/genius_celebration.js"/>
<script type="text/javascript" src="/tour_genius/static/src/js/genius_quiz_popup.js"/>
</xpath>
</template>
</odoo>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Dashboard Client Action -->
<record id="action_genius_dashboard" model="ir.actions.client">
<field name="name">Tour Genius Dashboard</field>
<field name="tag">genius_dashboard</field>
</record>
<!-- Dashboard Menu Item (will be first in menu) -->
<menuitem
id="menu_genius_dashboard"
name="Dashboard"
action="action_genius_dashboard"
parent="menu_tour_genius_root"
sequence="1"/>
</odoo>

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- TREE VIEW -->
<record id="view_genius_leaderboard_tree" model="ir.ui.view">
<field name="name">genius.leaderboard.tree</field>
<field name="model">genius.leaderboard</field>
<field name="arch" type="xml">
<tree string="Leaderboard" decoration-success="rank &lt;= 3" decoration-bf="rank &lt;= 3">
<field name="rank"/>
<field name="user_id" widget="many2one_avatar_user"/>
<field name="points" sum="Total Points"/>
<field name="topics_completed"/>
<field name="quizzes_passed"/>
<field name="badge_count"/>
<field name="streak_days" optional="hide"/>
<field name="period_type" optional="hide"/>
</tree>
</field>
</record>
<!-- SEARCH VIEW -->
<record id="view_genius_leaderboard_search" model="ir.ui.view">
<field name="name">genius.leaderboard.search</field>
<field name="model">genius.leaderboard</field>
<field name="arch" type="xml">
<search string="Search Leaderboard">
<field name="user_id"/>
<filter string="All Time" name="period_alltime" domain="[('period_type', '=', 'alltime')]"/>
<filter string="Monthly" name="period_monthly" domain="[('period_type', '=', 'monthly')]"/>
<filter string="Weekly" name="period_weekly" domain="[('period_type', '=', 'weekly')]"/>
<group expand="0" string="Group By">
<filter string="Period" name="group_period" context="{'group_by': 'period_type'}"/>
</group>
</search>
</field>
</record>
<!-- ACTION -->
<record id="action_genius_leaderboard" model="ir.actions.act_window">
<field name="name">Leaderboard</field>
<field name="res_model">genius.leaderboard</field>
<field name="view_mode">tree</field>
<field name="context">{'search_default_period_alltime': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Join the competition!
</p>
<p>
Complete tours and pass quizzes to earn points and climb the leaderboard.
</p>
</field>
</record>
<!-- MENU -->
<menuitem id="menu_genius_leaderboard"
name="Leaderboard"
parent="menu_training"
action="action_genius_leaderboard"
sequence="30"
groups="tour_genius.group_genius_user"/>
</odoo>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- ROOT MENU (No actions - just structure) -->
<!-- ============================================================ -->
<menuitem id="menu_tour_genius_root"
name="Tour Genius"
sequence="100"
web_icon="tour_genius,static/description/icon.png"/>
<!-- Parent Menu Items (no actions) -->
<menuitem id="menu_training"
name="Training"
parent="menu_tour_genius_root"
sequence="10"/>
<menuitem id="menu_analytics"
name="Analytics"
parent="menu_tour_genius_root"
sequence="30"
groups="tour_genius.group_genius_instructor"/>
<menuitem id="menu_configuration"
name="Configuration"
parent="menu_tour_genius_root"
sequence="90"
groups="tour_genius.group_genius_admin"/>
</odoo>

View File

@ -0,0 +1,191 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- Training Plan Views -->
<!-- ============================================================ -->
<!-- Plan Tree View -->
<record id="view_genius_plan_tree" model="ir.ui.view">
<field name="name">genius.plan.tree</field>
<field name="model">genius.plan</field>
<field name="arch" type="xml">
<tree string="Training Plans" decoration-info="state == 'draft'" decoration-muted="state == 'archived'">
<field name="name"/>
<field name="topic_count"/>
<field name="attendee_count"/>
<field name="state" widget="badge"
decoration-info="state == 'draft'"
decoration-success="state == 'published'"
decoration-muted="state == 'archived'"/>
<field name="overall_progress" widget="progressbar"/>
</tree>
</field>
</record>
<!-- Plan Kanban View -->
<record id="view_genius_plan_kanban" model="ir.ui.view">
<field name="name">genius.plan.kanban</field>
<field name="model">genius.plan</field>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile" default_group_by="state">
<field name="name"/>
<field name="state"/>
<field name="topic_count"/>
<field name="attendee_count"/>
<field name="overall_progress"/>
<field name="cover_image"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_card oe_kanban_global_click">
<div class="o_kanban_image" t-if="record.cover_image.raw_value">
<img t-att-src="kanban_image('genius.plan', 'cover_image', record.id.raw_value)" alt="Cover"/>
</div>
<div class="oe_kanban_details">
<strong class="o_kanban_record_title">
<field name="name"/>
</strong>
<div class="o_kanban_tags_section">
<span class="badge badge-pill">
<field name="topic_count"/> tours
</span>
<span class="badge badge-pill">
<field name="attendee_count"/> trainees
</span>
</div>
<div class="o_kanban_record_bottom">
<field name="overall_progress" widget="progressbar"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- Plan Form View -->
<record id="view_genius_plan_form" model="ir.ui.view">
<field name="name">genius.plan.form</field>
<field name="model">genius.plan</field>
<field name="arch" type="xml">
<form string="Training Plan">
<header>
<button name="action_publish" type="object" string="Publish"
class="btn-primary" states="draft"/>
<button name="action_archive" type="object" string="Archive"
states="published"/>
<button name="action_draft" type="object" string="Reset to Draft"
states="archived"/>
<field name="state" widget="statusbar" statusbar_visible="draft,published,archived"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_topics" type="object"
class="oe_stat_button" icon="fa-list-ol">
<field name="topic_count" widget="statinfo" string="Tours"/>
</button>
<button name="action_view_attendees" type="object"
class="oe_stat_button" icon="fa-users">
<field name="attendee_count" widget="statinfo" string="Trainees"/>
</button>
</div>
<field name="cover_image" widget="image" class="oe_avatar"/>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" placeholder="Training Plan Name"/>
</h1>
</div>
<group>
<group>
<field name="date_start"/>
<field name="date_end"/>
<field name="estimated_hours"/>
</group>
<group>
<field name="is_public"/>
<field name="is_template"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Tours" name="topics">
<field name="topic_ids">
<tree editable="bottom">
<field name="sequence" widget="handle"/>
<field name="icon"/>
<field name="name"/>
<field name="step_count"/>
<field name="duration_minutes"/>
</tree>
</field>
</page>
<page string="Modules Covered" name="modules">
<field name="target_modules" placeholder="sale, purchase, account"/>
</page>
<page string="Participants" name="participants">
<group>
<group string="Instructors">
<field name="instructor_ids" widget="many2many_tags" nolabel="1"/>
</group>
<group string="Trainees">
<field name="attendee_ids" widget="many2many_tags" nolabel="1"/>
</group>
</group>
</page>
<page string="Description" name="description">
<field name="description" placeholder="Describe this training plan..."/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- Plan Search View -->
<record id="view_genius_plan_search" model="ir.ui.view">
<field name="name">genius.plan.search</field>
<field name="model">genius.plan</field>
<field name="arch" type="xml">
<search string="Search Plans">
<field name="name"/>
<filter name="my_plans" string="My Plans"
domain="['|', ('attendee_ids', 'in', [uid]), ('instructor_ids', 'in', [uid])]"/>
<filter name="published" string="Published" domain="[('state', '=', 'published')]"/>
<separator/>
<filter name="archived" string="Archived" domain="[('active', '=', False)]"/>
<group expand="0" string="Group By">
<filter string="State" name="group_by_state" context="{'group_by': 'state'}"/>
<filter string="Company" name="group_by_company" context="{'group_by': 'company_id'}"/>
</group>
</search>
</field>
</record>
<!-- Plan Action -->
<record id="action_genius_plan" model="ir.actions.act_window">
<field name="name">Training Plans</field>
<field name="res_model">genius.plan</field>
<field name="view_mode">kanban,tree,form</field>
<field name="search_view_id" ref="view_genius_plan_search"/>
<field name="context">{'search_default_my_plans': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first training plan!
</p>
<p>Training plans organize tours for structured learning.</p>
</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_training_plans"
name="Training Plans"
parent="menu_training"
action="action_genius_plan"
sequence="10"/>
</odoo>

View File

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- Progress Views (Admin View for Tracking Completions) -->
<!-- ============================================================ -->
<!-- Progress Tree View -->
<record id="view_genius_progress_tree" model="ir.ui.view">
<field name="name">genius.progress.tree</field>
<field name="model">genius.progress</field>
<field name="arch" type="xml">
<tree string="User Progress" decoration-success="state in ('done', 'verified')" decoration-warning="state=='skipped'" decoration-info="state=='in_progress'">
<field name="user_id"/>
<field name="topic_id"/>
<field name="plan_id" optional="hide"/>
<field name="state"/>
<field name="date_started"/>
<field name="date_completed"/>
<field name="duration_display" string="Duration"/>
<field name="completion_count" optional="show"/>
<field name="quiz_score" optional="show"/>
</tree>
</field>
</record>
<!-- Progress Form View -->
<record id="view_genius_progress_form" model="ir.ui.view">
<field name="name">genius.progress.form</field>
<field name="model">genius.progress</field>
<field name="arch" type="xml">
<form string="User Progress">
<header>
<button name="action_verify" type="object" string="Verify Completion"
class="btn-primary" states="done"
groups="tour_genius.group_genius_admin"/>
<button name="action_reset" type="object" string="Reset Progress"
class="btn-secondary"
groups="tour_genius.group_genius_admin"/>
<field name="state" widget="statusbar" statusbar_visible="pending,in_progress,skipped,done,verified"/>
</header>
<sheet>
<group>
<group string="User Information">
<field name="user_id"/>
<field name="topic_id"/>
<field name="plan_id"/>
</group>
<group string="Progress Details">
<field name="date_started"/>
<field name="date_completed"/>
<field name="date_skipped" attrs="{'invisible': [('date_skipped', '=', False)]}"/>
<field name="date_verified"/>
<field name="duration_display"/>
<field name="completion_count"/>
<field name="time_spent_seconds" groups="base.group_no_one"/>
</group>
</group>
<group string="Quiz Results" attrs="{'invisible': [('quiz_score', '=', 0)]}">
<field name="quiz_score"/>
<field name="quiz_attempt_id"/>
</group>
<group string="Trainer Notes">
<field name="notes" placeholder="Add notes about this user's progress..."/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Progress Search View -->
<record id="view_genius_progress_search" model="ir.ui.view">
<field name="name">genius.progress.search</field>
<field name="model">genius.progress</field>
<field name="arch" type="xml">
<search string="Search Progress">
<field name="user_id"/>
<field name="topic_id"/>
<field name="plan_id"/>
<filter name="completed" string="Completed" domain="[('state', '=', 'done')]"/>
<filter name="skipped" string="Skipped" domain="[('state', '=', 'skipped')]"/>
<filter name="in_progress" string="In Progress" domain="[('state', '=', 'in_progress')]"/>
<filter name="verified" string="Certified" domain="[('state', '=', 'verified')]"/>
<separator/>
<group expand="0" string="Group By">
<filter string="User" name="group_by_user" context="{'group_by': 'user_id'}"/>
<filter string="Tour" name="group_by_tour" context="{'group_by': 'topic_id'}"/>
<filter string="Status" name="group_by_state" context="{'group_by': 'state'}"/>
<filter string="Completion Date" name="group_by_completed" context="{'group_by': 'date_completed:month'}"/>
</group>
</search>
</field>
</record>
<!-- Progress Action -->
<record id="action_genius_progress" model="ir.actions.act_window">
<field name="name">User Progress</field>
<field name="res_model">genius.progress</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_genius_progress_search"/>
<field name="context">{'search_default_completed': 0}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No progress records yet
</p>
<p>Progress is recorded automatically when users complete tours.</p>
</field>
</record>
<!-- Menu Item for Progress (Admin only) -->
<menuitem id="menu_training_progress"
name="User Progress"
parent="menu_training"
action="action_genius_progress"
sequence="50"
groups="tour_genius.group_genius_admin"/>
</odoo>

View File

@ -0,0 +1,355 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- Quiz Views -->
<!-- ============================================================ -->
<record id="view_genius_quiz_tree" model="ir.ui.view">
<field name="name">genius.quiz.tree</field>
<field name="model">genius.quiz</field>
<field name="arch" type="xml">
<tree string="Quizzes">
<field name="name"/>
<field name="question_count"/>
<field name="passing_score"/>
<field name="attempt_count"/>
<field name="avg_score"/>
<field name="pass_rate"/>
</tree>
</field>
</record>
<record id="view_genius_quiz_form" model="ir.ui.view">
<field name="name">genius.quiz.form</field>
<field name="model">genius.quiz</field>
<field name="arch" type="xml">
<form string="Quiz">
<header>
<button name="action_analyze_difficulty" string="Analyze" type="object"
icon="fa-bar-chart"
attrs="{'invisible': [('attempt_count', '=', 0)]}"/>
<button name="action_reset_statistics" string="Reset Stats" type="object"
icon="fa-refresh"
confirm="This will delete all attempts and statistics. Continue?"
attrs="{'invisible': [('attempt_count', '=', 0)]}"/>
<button name="action_duplicate_quiz" string="Duplicate" type="object" icon="fa-copy"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" icon="fa-question-circle">
<field name="question_count" widget="statinfo" string="Questions"/>
</button>
<button name="action_view_attempts" class="oe_stat_button" icon="fa-pencil-square" type="object">
<field name="attempt_count" widget="statinfo" string="Attempts"/>
</button>
<button class="oe_stat_button" icon="fa-check-circle">
<field name="pass_rate" widget="statinfo" string="Pass Rate"/>
</button>
</div>
<div class="oe_title d-flex align-items-center">
<div class="flex-grow-1">
<label for="name" class="oe_edit_only"/>
<h1>
<field name="name" placeholder="Quiz Title"/>
</h1>
</div>
<!-- Genius Test Button - Right side of title -->
<div class="genius-test-mode-container ml-auto" attrs="{'invisible': [('question_count', '=', 0)]}">
<field name="test_mode_opener" widget="genius_quiz_test_button"/>
</div>
</div>
<group>
<group string="Configuration">
<field name="passing_score"/>
<field name="time_limit_minutes"/>
<field name="max_attempts"/>
</group>
<group string="Behavior">
<field name="shuffle_questions"/>
<field name="show_correct_answers"/>
<field name="sample_size"/>
</group>
</group>
<notebook>
<page string="Questions" name="questions">
<field name="question_ids" context="{'default_quiz_id': active_id}">
<tree>
<field name="sequence" widget="handle"/>
<field name="question_text"/>
<field name="question_type"/>
<field name="points"/>
</tree>
</field>
</page>
<page string="Feedback &amp; Customization" name="feedback">
<group>
<separator string="Success Feedback" colspan="2"/>
<field name="success_message" nolabel="1" placeholder="Message shown when user passes..."/>
<separator string="Failure Feedback" colspan="2"/>
<field name="fail_message" nolabel="1" placeholder="Message shown when user fails..."/>
</group>
</page>
<page string="Description" name="description">
<field name="description" placeholder="Internal notes or description..."/>
</page>
<page string="Statistics" name="stats">
<group>
<group>
<field name="attempt_count" readonly="1"/>
<field name="avg_score" readonly="1"/>
</group>
<group>
<field name="pass_rate" readonly="1"/>
</group>
</group>
</page>
<page string="Certificate" name="certificate">
<group>
<group string="Branding">
<field name="certificate_logo" widget="image" class="oe_avatar" options="{'size': [90, 90]}"/>
<field name="certificate_secondary_logo" widget="image" class="oe_avatar" options="{'size': [90, 90]}"/>
<field name="certificate_title" placeholder="CERTIFICATE"/>
<field name="certificate_issuer" placeholder="Training Department"/>
</group>
<group string="Signature &amp; Stamp">
<field name="certificate_signature_image" widget="image" class="oe_avatar" options="{'size': [150, 60]}"/>
<field name="certificate_stamp" widget="image" class="oe_avatar" options="{'size': [90, 90]}"/>
<field name="certificate_signature_name" placeholder="Training Director"/>
<field name="certificate_signature_title" placeholder="Director of Training"/>
</group>
</group>
<group string="Achievement Description">
<field name="certificate_body_template" nolabel="1" colspan="2"
placeholder="has successfully completed the training course and demonstrated mastery..."/>
</group>
<div class="alert alert-info">
<i class="fa fa-info-circle"/> <strong>Variables:</strong>
<code>{user_name}</code> <code>{topic_name}</code> <code>{quiz_name}</code>
<br/><small class="text-muted">Note: Score and Date are displayed automatically in the certificate footer.</small>
</div>
<div class="mt-3">
<button name="action_preview_certificate" type="object" class="btn btn-primary" icon="fa-eye">
<i class="fa fa-eye"/> Preview Certificate
</button>
</div>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_genius_quiz" model="ir.actions.act_window">
<field name="name">Quizzes</field>
<field name="res_model">genius.quiz</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_quizzes"
name="Quizzes"
parent="menu_tour_genius_root"
action="action_genius_quiz"
sequence="20"
groups="tour_genius.group_genius_instructor"/>
<record id="view_genius_question_form" model="ir.ui.view">
<field name="name">genius.quiz.question.form</field>
<field name="model">genius.quiz.question</field>
<field name="arch" type="xml">
<form string="Question">
<sheet>
<!-- Question Preview Title -->
<div class="oe_title">
<label for="name" class="oe_edit_only" string="Question Preview"/>
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<!-- Main Configuration -->
<group>
<group string="Configuration">
<field name="question_type" widget="radio"/>
<field name="points"/>
</group>
<group string="Status">
<field name="active" widget="boolean_toggle"/>
<field name="sequence" groups="base.group_no_one"/>
</group>
</group>
<!-- Question Content -->
<separator string="Question Content"/>
<field name="question_text" nolabel="1" placeholder="Enter your question here..."/>
<!-- Dynamic Hint Based on Type -->
<div class="alert alert-info" role="alert" attrs="{'invisible': [('question_type', 'not in', ['fill_blank'])]}">
<i class="fa fa-info-circle"/> <strong>Tip:</strong> Write your question with a blank to fill, e.g., "The capital of France is ___".
</div>
<div class="alert alert-info" role="alert" attrs="{'invisible': [('question_type', '!=', 'ordering')]}">
<i class="fa fa-info-circle"/> <strong>Tip:</strong> Add items below. The sequence order you set here is the <em>correct</em> order.
</div>
<notebook>
<!-- Answer Options (Single/Multiple Choice) -->
<page string="Answer Options" name="answers"
attrs="{'invisible': [('question_type', 'not in', ['single', 'multiple'])]}">
<field name="answer_ids">
<tree editable="bottom">
<field name="sequence" widget="handle"/>
<field name="answer_text" placeholder="Enter answer option..."/>
<field name="is_correct"/>
<field name="feedback" placeholder="Optional feedback..."/>
</tree>
</field>
<div class="text-muted mt-2">
<i class="fa fa-lightbulb-o"/>
<span attrs="{'invisible': [('question_type', '!=', 'single')]}">
Mark exactly <strong>one</strong> answer as correct.
</span>
<span attrs="{'invisible': [('question_type', '!=', 'multiple')]}">
Mark <strong>all</strong> correct answers.
</span>
</div>
</page>
<!-- Ordering Items -->
<page string="Items to Order" name="ordering"
attrs="{'invisible': [('question_type', '!=', 'ordering')]}">
<field name="answer_ids">
<tree editable="bottom">
<field name="sequence" widget="handle"/>
<field name="answer_text" placeholder="Enter item..."/>
</tree>
</field>
<div class="text-muted mt-2">
<i class="fa fa-lightbulb-o"/> Drag items to set the <strong>correct order</strong>. Users will see them shuffled.
</div>
</page>
<!-- Text Answer (Short Answer / Fill in the Blank) -->
<page string="Correct Answer" name="text_answer"
attrs="{'invisible': [('question_type', 'not in', ['short_answer', 'fill_blank'])]}">
<group>
<group>
<field name="correct_short_answer" placeholder="Expected answer..." attrs="{'required': [('question_type', 'in', ['short_answer', 'fill_blank'])]}"/>
<field name="answer_alternatives" placeholder="color, colour, Color"/>
</group>
<group>
<field name="case_sensitive"/>
</group>
</group>
<div class="text-muted">
<i class="fa fa-lightbulb-o"/> Use <strong>Alternative Answers</strong> for synonyms or spelling variations (comma-separated).
</div>
</page>
<!-- Explanation &amp; Media -->
<page string="Explanation &amp; Media" name="explanation">
<group>
<group string="Question Image">
<field name="image" widget="image" class="oe_avatar" options="{'size': [200, 200]}" nolabel="1"/>
</group>
<group string="Explanation">
<field name="explanation" nolabel="1" placeholder="Why is this the correct answer? (Shown after answering)"/>
</group>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Quiz Attempt Views -->
<record id="view_genius_quiz_attempt_tree" model="ir.ui.view">
<field name="name">genius.quiz.attempt.tree</field>
<field name="model">genius.quiz.attempt</field>
<field name="arch" type="xml">
<tree string="Quiz Attempts" create="false" edit="false">
<field name="user_id"/>
<field name="quiz_id"/>
<field name="started_at"/>
<field name="score"/>
<field name="is_passed"/>
<field name="state"/>
</tree>
</field>
</record>
<record id="view_genius_quiz_attempt_form" model="ir.ui.view">
<field name="name">genius.quiz.attempt.form</field>
<field name="model">genius.quiz.attempt</field>
<field name="arch" type="xml">
<form string="Quiz Attempt">
<header>
<button name="action_submit" string="Submit Quiz" type="object" state="in_progress" class="oe_highlight" attrs="{'invisible': [('state', '!=', 'in_progress')]}"/>
<field name="state" widget="statusbar" statusbar_visible="in_progress,submitted"/>
</header>
<sheet>
<group>
<group>
<field name="quiz_id" readonly="1"/>
<field name="user_id" readonly="1"/>
</group>
<group>
<field name="started_at" readonly="1"/>
<field name="score" attrs="{'invisible': [('state', '!=', 'submitted')]}"/>
<field name="is_passed" attrs="{'invisible': [('state', '!=', 'submitted')]}"/>
<field name="show_correct_answers" invisible="1"/>
</group>
</group>
<!-- Success Messages -->
<div class="alert alert-success" role="alert" attrs="{'invisible': ['|', '|', ('state', '!=', 'submitted'), ('is_passed', '=', False), ('success_message', '!=', False)]}">
<strong>Congratulations!</strong> You passed this quiz.
</div>
<field name="success_message" widget="html" class="alert alert-success" attrs="{'invisible': ['|', '|', ('state', '!=', 'submitted'), ('is_passed', '=', False), ('success_message', '=', False)]}"/>
<!-- Failure Messages -->
<div class="alert alert-danger" role="alert" attrs="{'invisible': ['|', '|', ('state', '!=', 'submitted'), ('is_passed', '=', True), ('fail_message', '!=', False)]}">
<strong>Try Again!</strong> Only <field name="points_earned"/> points earned.
</div>
<field name="fail_message" widget="html" class="alert alert-danger" attrs="{'invisible': ['|', '|', ('state', '!=', 'submitted'), ('is_passed', '=', True), ('fail_message', '=', False)]}"/>
<notebook>
<page string="Questions">
<field name="response_ids">
<tree editable="bottom" create="0" delete="0">
<field name="question_id" readonly="1"/>
<field name="text_answer" attrs="{'readonly': [('parent.state', '=', 'submitted')]}"/>
<!-- Assume we need a way to select answers. Standard Odoo tree generic is hard for M2M selection.
Ideally this should be a form view or web client.
For now, just showing structure. -->
<!-- Domain ensures we only see answers for THIS question -->
<field name="selected_answer_ids" widget="many2many_tags"
domain="[('question_id', '=', question_id)]"
attrs="{'readonly': [('parent.state', '=', 'submitted')]}"/>
<field name="is_correct" readonly="1" attrs="{'invisible': [('parent.show_correct_answers', '=', False)]}"/>
<field name="explanation" widget="html" optional="hide" readonly="1" attrs="{'invisible': [('parent.show_correct_answers', '=', False)]}"/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_genius_quiz_attempt" model="ir.actions.act_window">
<field name="name">My Attempts</field>
<field name="res_model">genius.quiz.attempt</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('user_id', '=', uid)]</field>
</record>
<menuitem id="menu_genius_quiz_attempt"
name="My Quizzes"
parent="menu_training"
action="action_genius_quiz_attempt"
sequence="20"
groups="tour_genius.group_genius_user"/>
</odoo>

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- TREE VIEW -->
<record id="view_genius_reminder_tree" model="ir.ui.view">
<field name="name">genius.reminder.tree</field>
<field name="model">genius.reminder</field>
<field name="arch" type="xml">
<tree string="Reminders" decoration-muted="state == 'cancelled'" decoration-danger="state == 'failed'" decoration-info="state == 'sent'">
<field name="name"/>
<field name="user_id"/>
<field name="reminder_type"/>
<field name="scheduled_date"/>
<field name="state"/>
<field name="sent_date"/>
</tree>
</field>
</record>
<!-- FORM VIEW -->
<record id="view_genius_reminder_form" model="ir.ui.view">
<field name="name">genius.reminder.form</field>
<field name="model">genius.reminder</field>
<field name="arch" type="xml">
<form string="Reminder">
<header>
<button name="action_send" string="Send Now" type="object" class="oe_highlight" states="pending"/>
<button name="action_cancel" string="Cancel" type="object" states="pending"/>
<field name="state" widget="statusbar" statusbar_visible="pending,sent,cancelled"/>
</header>
<sheet>
<group>
<group>
<field name="name"/>
<field name="user_id"/>
<field name="reminder_type"/>
</group>
<group>
<field name="scheduled_date"/>
<field name="sent_date" readonly="1"/>
<field name="is_recurring"/>
<field name="recurrence_interval" attrs="{'invisible': [('is_recurring', '=', False)]}"/>
</group>
</group>
<notebook>
<page string="Content">
<field name="message_body" widget="html" readonly="1"/>
<field name="custom_message" attrs="{'invisible': [('reminder_type', '!=', 'custom')]}"/>
</page>
<page string="Context">
<group>
<field name="topic_id"/>
<field name="plan_id"/>
</group>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- ACTION -->
<record id="action_genius_reminder" model="ir.actions.act_window">
<field name="name">Reminders</field>
<field name="res_model">genius.reminder</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_state': 'pending'}</field>
</record>
<!-- MENU (Instructor Only) -->
<menuitem id="menu_genius_reminder"
name="Reminders"
parent="menu_analytics"
action="action_genius_reminder"
sequence="50"
groups="tour_genius.group_genius_instructor"/>
</odoo>

View File

@ -0,0 +1,122 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- STEP TREE VIEW -->
<!-- ============================================================ -->
<record id="view_genius_topic_step_tree" model="ir.ui.view">
<field name="name">genius.topic.step.tree</field>
<field name="model">genius.topic.step</field>
<field name="arch" type="xml">
<tree string="Tour Steps" default_order="topic_id, sequence">
<field name="sequence" widget="handle"/>
<field name="topic_id"/>
<field name="title"/>
<field name="css_selector"/>
<field name="step_type"/>
<field name="position"/>
<field name="active" widget="boolean_toggle"/>
</tree>
</field>
</record>
<!-- ============================================================ -->
<!-- STEP FORM VIEW -->
<!-- ============================================================ -->
<record id="view_genius_topic_step_form" model="ir.ui.view">
<field name="name">genius.topic.step.form</field>
<field name="model">genius.topic.step</field>
<field name="arch" type="xml">
<form string="Tour Step">
<sheet>
<div class="oe_title">
<label for="title"/>
<h1>
<field name="title" placeholder="Step Title"/>
</h1>
</div>
<group>
<group string="Configuration">
<field name="topic_id"/>
<field name="sequence"/>
<field name="step_type"/>
<field name="position"/>
<field name="active"/>
</group>
<group string="Selector">
<field name="css_selector" widget="char"/>
<field name="extra_trigger" widget="char"/>
</group>
</group>
<group attrs="{'invisible': [('step_type', '!=', 'input')]}">
<field name="input_value" placeholder="Value to type..."/>
</group>
<notebook>
<page string="Instructions" name="instructions">
<field name="instruction" placeholder="Detailed instructions for this step..."/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ============================================================ -->
<!-- STEP SEARCH VIEW -->
<!-- ============================================================ -->
<record id="view_genius_topic_step_search" model="ir.ui.view">
<field name="name">genius.topic.step.search</field>
<field name="model">genius.topic.step</field>
<field name="arch" type="xml">
<search string="Search Steps">
<field name="title"/>
<field name="topic_id"/>
<field name="css_selector"/>
<separator/>
<filter string="Active" name="active" domain="[('active', '=', True)]"/>
<filter string="Archived" name="archived" domain="[('active', '=', False)]"/>
<separator/>
<filter string="Click Steps" name="click" domain="[('step_type', '=', 'click')]"/>
<filter string="Input Steps" name="input" domain="[('step_type', '=', 'input')]"/>
<separator/>
<group expand="0" string="Group By">
<filter string="Tour" name="group_topic" context="{'group_by': 'topic_id'}"/>
<filter string="Step Type" name="group_type" context="{'group_by': 'step_type'}"/>
<filter string="Position" name="group_position" context="{'group_by': 'position'}"/>
</group>
</search>
</field>
</record>
<!-- ============================================================ -->
<!-- STEP ACTION -->
<!-- ============================================================ -->
<record id="action_genius_topic_step" model="ir.actions.act_window">
<field name="name">Tour Steps</field>
<field name="res_model">genius.topic.step</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_genius_topic_step_search"/>
<field name="context">{'search_default_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No steps found
</p>
<p>
Steps are usually created from the Tour form or via the Recorder tool.
</p>
</field>
</record>
<!-- ============================================================ -->
<!-- STEP MENU (Under Configuration) -->
<!-- ============================================================ -->
<menuitem id="menu_genius_topic_step"
name="Tour Steps"
parent="menu_configuration"
action="action_genius_topic_step"
sequence="20"/>
</odoo>

View File

@ -0,0 +1,173 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- Topic/Tour Views -->
<!-- ============================================================ -->
<!-- Topic Tree View -->
<record id="view_genius_topic_tree" model="ir.ui.view">
<field name="name">genius.topic.tree</field>
<field name="model">genius.topic</field>
<field name="arch" type="xml">
<tree string="Tours">
<field name="sequence" widget="handle"/>
<field name="icon"/>
<field name="name"/>
<field name="module_id"/>
<field name="step_count"/>
<field name="consumed_count"/>
<field name="state" decoration-info="state == 'draft'" decoration-success="state == 'published'"/>
</tree>
</field>
</record>
<!-- Embedded Step Tree View (for Smart Button) -->
<record id="view_genius_topic_step_tree_embedded" model="ir.ui.view">
<field name="name">genius.topic.step.tree.embedded</field>
<field name="model">genius.topic.step</field>
<field name="arch" type="xml">
<tree string="Steps" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="title"/>
<field name="step_type"/>
<field name="css_selector" string="Trigger (Selector)"/>
<field name="position"/>
</tree>
</field>
</record>
<!-- Topic Form View -->
<record id="view_genius_topic_form" model="ir.ui.view">
<field name="name">genius.topic.form</field>
<field name="model">genius.topic</field>
<field name="arch" type="xml">
<form string="Tour">
<header>
<!-- Main Actions -->
<button name="action_publish" type="object"
string="Publish" class="btn btn-primary"
attrs="{'invisible': [('state', '=', 'published')]}"
groups="tour_genius.group_genius_instructor"/>
<button name="action_start_recording" type="object"
string="Record Steps" class="btn btn-primary" icon="fa-video-camera"
groups="tour_genius.group_genius_admin"/>
<!-- Secondary Actions -->
<button name="action_test_tour" type="object"
string="Test Tour" class="btn btn-secondary" icon="fa-flask"
groups="tour_genius.group_genius_instructor"/>
<button name="action_open_topic" type="object"
string="Run Tour" class="btn btn-secondary" icon="fa-play"
attrs="{'invisible': [('state', '!=', 'published')]}"/>
<button name="action_set_draft" type="object"
string="Set to Draft" class="btn btn-secondary"
attrs="{'invisible': [('state', '=', 'draft')]}"
groups="tour_genius.group_genius_instructor"/>
<field name="state" widget="statusbar" statusbar_visible="draft,published"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_steps" type="object"
class="oe_stat_button" icon="fa-list-ol">
<field name="step_count" widget="statinfo" string="Steps"/>
</button>
<button class="oe_stat_button" icon="fa-users" type="object" name="action_view_completions">
<field name="consumed_count" widget="statinfo" string="Completions"/>
</button>
</div>
<field name="image" widget="image" class="oe_avatar"/>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" placeholder="Tour Title"/>
</h1>
</div>
<group>
<field name="module_xml_id" invisible="1"/>
<group string="Target">
<field name="plan_id"/>
<field name="module_id" options="{'no_create': True}"/>
<field name="starting_url" widget="url" placeholder="/web#action=..."/>
</group>
<group string="Classification">
<field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
<field name="quiz_id" context="{'default_name': name + ' Quiz'}"/>
</group>
</group>
<notebook>
<page string="Tour Steps" name="steps">
<div class="alert alert-info genius-tip-box" role="alert">
<i class="fa fa-lightbulb-o mr-2"/> <strong>Tip:</strong> Use the <em>"Record Steps"</em> button above to visually capture steps.
</div>
<field name="step_ids">
<tree editable="bottom">
<field name="sequence" widget="handle"/>
<field name="title"/>
<field name="instruction"/>
<field name="css_selector"/>
<field name="step_type"/>
<field name="input_value" optional="hide" attrs="{'invisible': [('step_type', '!=', 'input')]}"/>
<field name="position"/>
<field name="extra_trigger" optional="hide"/>
</tree>
</field>
</page>
<page string="Resources" name="resources">
<group>
<field name="video_url" widget="url" placeholder="https://www.youtube.com/..."/>
<field name="document"/>
</group>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Topic Search View -->
<record id="view_genius_topic_search" model="ir.ui.view">
<field name="name">genius.topic.search</field>
<field name="model">genius.topic</field>
<field name="arch" type="xml">
<search string="Search Tours">
<field name="name"/>
<field name="module_id"/>
<filter name="my_completed" string="Completed"
domain="[('consumed_user_ids', 'in', [uid])]"/>
<filter name="has_steps" string="With Steps"
domain="[('step_count', '>', 0)]"/>
<separator/>
<filter name="filter_draft" string="Draft" domain="[('state', '=', 'draft')]"/>
<filter name="filter_published" string="Published" domain="[('state', '=', 'published')]"/>
<separator/>
<filter name="archived" string="Archived" domain="[('active', '=', False)]"/>
<group expand="0" string="Group By">
<filter string="Module" name="group_by_module" context="{'group_by': 'module_id'}"/>
</group>
</search>
</field>
</record>
<!-- Topic Action -->
<record id="action_genius_topic" model="ir.actions.act_window">
<field name="name">Tours</field>
<field name="res_model">genius.topic</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_genius_topic_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first tour!
</p>
<p>Tours guide users through Odoo screens step by step.</p>
</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_training_topics"
name="Tours"
parent="menu_training"
action="action_genius_topic"
sequence="20"
groups="tour_genius.group_genius_instructor"/>
</odoo>