Merge pull request #5850 from expsa/14.0-fix-system_dashboard_classic-auto-20251228_152422

[FIX] system_dashboard_classic tour_genius: update module metadata
This commit is contained in:
Mohamed Eltayar 2025-12-28 15:24:55 +03:00 committed by GitHub
commit 066c348db3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 367 additions and 28 deletions

View File

@ -567,16 +567,22 @@ class DashboardConfigSettings(models.TransientModel):
], 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'),
}
# Periodic Refresh Settings
dashboard_refresh_enabled = fields.Boolean(
string='Enable Auto Refresh',
default=False,
config_parameter='system_dashboard_classic.refresh_enabled',
help='Automatically refresh attendance status and approval count at regular intervals'
)
dashboard_refresh_interval = fields.Integer(
string='Refresh Interval (seconds)',
default=60,
config_parameter='system_dashboard_classic.refresh_interval',
help='How often to refresh data (minimum: 30 seconds, maximum: 3600 seconds / 1 hour)'
)
@api.model
def get_values(self):

View File

@ -638,8 +638,29 @@ class SystemDashboard(models.Model):
except (json.JSONDecodeError, TypeError, AttributeError):
values['card_orders'] = {}
# Load periodic refresh settings
ICP = self.env['ir.config_parameter'].sudo()
values['refresh_settings'] = {
'enabled': ICP.get_param('system_dashboard_classic.refresh_enabled', 'False') == 'True',
'interval': int(ICP.get_param('system_dashboard_classic.refresh_interval', '60'))
}
return values
@api.model
def get_public_dashboard_colors(self):
"""
Secure API method to get dashboard colors for Genius Enhancements JS.
Uses sudo() to bypass access rules for ir.config_parameter.
"""
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'),
}
def _haversine_distance(self, lat1, lon1, lat2, lon2):
"""
Calculate the great-circle distance between two GPS points using Haversine formula.
@ -879,3 +900,124 @@ class SystemDashboard(models.Model):
if order_type:
return all_orders.get(order_type, [])
return all_orders
@api.model
def get_refresh_data(self):
"""
Lightweight method for periodic refresh.
Returns only attendance status and approval count.
Does NOT return full card data for performance.
@return: Dict with attendance status and approval count
"""
result = {
'attendance': [],
'approval_count': 0
}
user = self.env['res.users'].browse(self.env.uid)
employee = self.env['hr.employee'].sudo().search([('user_id', '=', user.id)], limit=1)
if not employee:
return result
# ATTENDANCE LOGIC: Matches get_data exactly
# 1. Use correct model 'attendance.attendance' (Event-based)
# 2. Convert to User's Timezone
# 3. Determine status based on 'action' field
last_attendance = self.env['attendance.attendance'].sudo().search(
[('employee_id', '=', employee.id)],
order='name desc',
limit=1
)
if last_attendance:
is_attendance = (last_attendance.action == 'sign_in')
# Timezone Conversion
user_tz = pytz.timezone(self.env.context.get('tz', 'Asia/Riyadh') or self.env.user.tz or 'UTC')
time_str = ''
if last_attendance.name:
# 'name' is the Datetime field in UTC
time_object = fields.Datetime.from_string(last_attendance.name)
# Convert to user's timezone
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
# Format to string for display (removing timezone offset info for cleanliness)
time_str = time_in_timezone.strftime('%Y-%m-%d %H:%M:%S')
result['attendance'] = [{
'is_attendance': is_attendance,
'time': time_str,
}]
# Calculate approval count
import ast
# Iterate over all dashboard lines (which represent "cards" or sections of cards)
# We process lines directly to be more efficient than iterating boards -> lines
config_lines = self.env['base.dashbord.line'].sudo().search([])
approval_count = 0
for line in config_lines:
# 1. User Access Check using the existing helper method
# This checks if the current user is in the allowed groups for this line
if not self.is_user(line.group_ids, user):
continue
# 2. Get the parent dashboard definition
dashboard = line.board_id
if not dashboard or not dashboard.model_id:
continue
# 3. Construct Domain (Replicating logic from get_data)
domain = []
# Add Action Domain (if configured)
if dashboard.action_domain:
try:
# Parse string domain to list
dom_list = ast.literal_eval(dashboard.action_domain)
# Filter out 'state' from action domain as per get_data logic
# This prevents conflict with the specific state/stage we are checking below
for item in dom_list:
if isinstance(item, (list, tuple)) and len(item) > 0 and item[0] != 'state':
domain.append(item)
except Exception:
# Invalid domain string, ignore action domain
pass
# Special Handling for HR Holidays Workflow
if dashboard.model_name == 'hr.holidays':
hr_holidays_workflow = self.env['ir.module.module'].sudo().search([('name', '=', 'hr_holidays_workflow')], limit=1)
if hr_holidays_workflow and hr_holidays_workflow.state == 'installed':
# Logic from get_data: OR condition for stage name matching state
domain.append('|')
domain.append(('stage_id.name', '=', line.state_id.state))
# Add State/Stage Filter
# Using the exact same logic as get_data to target the specific bucket
if line.state_id:
domain.append(('state', '=', line.state_id.state))
elif line.stage_id:
# stage_id in stage.stage is a Char field holding the ID, so we cast to int
# This matches get_data: ('stage_id', '=', int(line.stage_id.stage_id))
try:
target_stage_id = int(line.stage_id.stage_id)
domain.append(('stage_id', '=', target_stage_id))
except (ValueError, TypeError):
pass
# 4. Execute Count Query
try:
# Ensure the model exists in the registry
if dashboard.model_name in self.env:
line_count = self.env[dashboard.model_name].sudo().search_count(domain)
approval_count += line_count
except Exception:
# Failsafe: If domain is invalid or model doesn't exist, skip this line
pass
result['approval_count'] = approval_count
return result

View File

@ -123,33 +123,40 @@ odoo.define('system_dashboard_classic.genius_enhancements', function(require) {
* Generates light/dark variants automatically for complete theming
*/
function loadCustomColors() {
var ICP = 'ir.config_parameter';
var prefix = 'system_dashboard_classic.';
var DashboardModel = 'system_dashboard_classic.dashboard';
// 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' }
{ param: 'primary', cssVar: '--dash-primary', defaultColor: '#0891b2' },
{ param: 'secondary', cssVar: '--dash-secondary', defaultColor: '#1e293b' },
{ param: 'success', cssVar: '--dash-success', defaultColor: '#10b981' },
{ param: 'warning', cssVar: '--dash-warning', defaultColor: '#f59e0b' }
];
// Load each color
colorParams.forEach(function(item) {
// Fetch valid dashboard colors from the secure public method
ajax.jsonRpc('/web/dataset/call_kw', 'call', {
model: ICP,
method: 'get_param',
args: [prefix + item.param],
model: DashboardModel,
method: 'get_public_dashboard_colors',
args: [],
kwargs: {}
}).then(function(color) {
if (color && color.match(/^#[0-9A-Fa-f]{6}$/)) {
applyColorWithVariants(item.cssVar, color);
}).then(function(colors) {
if (!colors) return;
colorParams.forEach(function(item) {
var colorValue = colors[item.param];
// Use default if not returned or invalid
if (!colorValue) colorValue = item.defaultColor;
if (colorValue && colorValue.match(/^#[0-9A-Fa-f]{6}$/)) {
applyColorWithVariants(item.cssVar, colorValue);
// For primary color, also generate SVG icon filter
if (item.cssVar === '--dash-primary') {
generateIconFilter(color);
generateIconFilter(colorValue);
}
}
}).catch(function() {});
});
}).catch(function(err) {
console.warn('[Genius Enhancements] Failed to load custom colors:', err);
});
}

View File

@ -202,6 +202,11 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
}, []).then(function(result) {
window.resultX = result;
// Load periodic refresh settings
var refreshEnabled = result.refresh_settings && result.refresh_settings.enabled;
var refreshInterval = (result.refresh_settings && result.refresh_settings.interval) || 60;
refreshInterval = Math.max(30, Math.min(3600, refreshInterval)) * 1000; // Clamp 30s-3600s, convert to ms
// IMMEDIATE CSS VARIABLE APPLICATION - Apply colors before any rendering
// This prevents the "flash of green/default" issue by setting CSS vars synchronously
if (result.chart_colors) {
@ -836,6 +841,92 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
});
});
}
// ============================================================
// PERIODIC REFRESH - Updates attendance + approval count
// Uses Page Visibility API to pause when tab hidden
// ============================================================
self.periodicRefreshId = null;
var isRtl = $('body').hasClass('o_rtl');
function updateAttendanceStatus(attendance) {
if (!attendance || attendance.length === 0) return;
var att = attendance[0];
var $imgSection = $('.attendance-img-section');
var $infoSection = $('.last-checkin-info');
// Get localized titles
var checkin_title = isRtl ? 'وقت الدخول' : 'Check-in Time';
var checkout_title = isRtl ? 'وقت الخروج' : 'Check-out Time';
if (att.is_attendance) {
// User is checked IN - show logout icon with checkout title
var image = '<img class="img-logout attendance-icon" src="/system_dashboard_classic/static/src/icons/logout.svg"/>';
$imgSection.html(image).removeClass('state-checkin').addClass('state-checkout');
// Show check-in info
var checkinInfoHtml = '<div class="checkin-label">' + checkout_title + '</div>' +
'<div class="checkin-time">' + att.time + '</div>';
$infoSection.html(checkinInfoHtml).show();
} else {
// User is checked OUT - show login icon with checkin title
var image = '<img class="img-login attendance-icon" src="/system_dashboard_classic/static/src/icons/login.svg"/>';
$imgSection.html(image).removeClass('state-checkout').addClass('state-checkin');
// Show check-out info if time exists
if (att.time) {
var checkoutInfoHtml = '<div class="checkin-label checkout-label">' + checkin_title + '</div>' +
'<div class="checkin-time checkout-time">' + att.time + '</div>';
$infoSection.html(checkoutInfoHtml).show();
} else {
$infoSection.html('').hide();
}
}
}
function updateApprovalCount(count) {
var $badge = $('.pending-count-badge');
if (count > 0) {
$badge.text(count).show();
} else {
$badge.hide();
}
}
function startPeriodicRefresh() {
if (!refreshEnabled || refreshInterval <= 0) {
console.log('[Dashboard] Periodic refresh disabled');
return;
}
console.log('[Dashboard] Starting periodic refresh (interval: ' + (refreshInterval/1000) + 's)');
self.periodicRefreshId = setInterval(function() {
// Skip if page is hidden (browser tab switch)
if (document.hidden) {
console.log('[Dashboard] Skipping refresh - tab hidden');
return;
}
// Fetch lightweight data
self._rpc({
model: 'system_dashboard_classic.dashboard',
method: 'get_refresh_data',
}, []).then(function(data) {
updateAttendanceStatus(data.attendance);
updateApprovalCount(data.approval_count);
console.log('[Dashboard] Periodic refresh completed - Count: ' + data.approval_count);
}).catch(function(err) {
console.warn('[Dashboard] Refresh failed:', err);
});
}, refreshInterval);
}
// Start polling
startPeriodicRefresh();
// Chart Settings
setTimeout(function() {
window.check = false;
@ -1564,6 +1655,12 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
clearInterval(this.pollingIntervalId);
this.pollingIntervalId = null;
}
// Clear periodic refresh interval
if (this.periodicRefreshId) {
clearInterval(this.periodicRefreshId);
this.periodicRefreshId = null;
console.log('[Dashboard] Periodic refresh stopped');
}
// Call parent destroy
this._super.apply(this, arguments);
// Clear attendance clock interval if exists

View File

@ -200,6 +200,39 @@
</div>
</div>
</div>
<!-- Periodic Refresh Settings -->
<h2 class="mt32">Auto-Refresh Settings</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_refresh_enabled" widget="boolean_toggle"/>
</div>
<div class="o_setting_right_pane">
<label for="dashboard_refresh_enabled" string="Enable Auto Refresh"/>
<div class="text-muted">
Automatically refresh attendance status and approval count at regular intervals.
<br/><em>Useful when employees use mobile app for check-in/out.</em>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box"
attrs="{'invisible': [('dashboard_refresh_enabled', '=', False)]}">
<div class="o_setting_left_pane">
<i class="fa fa-clock-o fa-3x"/>
</div>
<div class="o_setting_right_pane">
<label for="dashboard_refresh_interval" string="Refresh Interval"/>
<div class="text-muted">
How often to refresh data (in seconds)
<br/><em>Range: 30 seconds to 3600 seconds (1 hour)</em>
</div>
<div class="content-group mt8">
<field name="dashboard_refresh_interval" class="oe_inline"/> seconds
</div>
</div>
</div>
</div>
</div>
</xpath>
</field>

View File

@ -34,6 +34,7 @@ Author: Expert Development Team
# Security
'security/security.xml',
'security/ir.model.access.csv',
'security/rules.xml',
# Data (Crons)
'data/cron_data.xml',
# Assets (MUST load before views for JS to register)

View File

@ -65,3 +65,5 @@ class ResUsers(models.Model):
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

@ -2,22 +2,31 @@ 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_standard_user,genius.topic.standard.user,model_genius_topic,base.group_user,1,0,0,0
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_standard_user,genius.topic.step.standard.user,model_genius_topic_step,base.group_user,1,0,0,0
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_standard_user,genius.progress.user,model_genius_progress,base.group_user,1,1,1,0
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_standard_user,genius.tour.tag.standard.user,model_genius_tour_tag,base.group_user,1,0,0,0
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_standard_user,genius.quiz.standard.user,model_genius_quiz,base.group_user,1,0,0,0
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_standard_user,genius.quiz.question.standard.user,model_genius_quiz_question,base.group_user,1,0,0,0
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_standard_user,genius.quiz.answer.standard.user,model_genius_quiz_answer,base.group_user,1,0,0,0
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_standard_user,genius.quiz.attempt.standard.user,model_genius_quiz_attempt,base.group_user,1,1,1,0
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_standard_user,genius.quiz.response.user,model_genius_quiz_response,base.group_user,1,1,1,0
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

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_standard_user genius.topic.standard.user model_genius_topic base.group_user 1 0 0 0
6 access_topic_user genius.topic.user model_genius_topic group_genius_user 1 0 0 0
7 access_topic_instructor genius.topic.instructor model_genius_topic group_genius_instructor 1 1 1 1
8 access_step_standard_user genius.topic.step.standard.user model_genius_topic_step base.group_user 1 0 0 0
9 access_step_user genius.topic.step.user model_genius_topic_step group_genius_user 1 0 0 0
10 access_step_instructor genius.topic.step.instructor model_genius_topic_step group_genius_instructor 1 1 1 1
11 access_progress_standard_user genius.progress.user model_genius_progress base.group_user 1 1 1 0
12 access_progress_user genius.progress.user model_genius_progress group_genius_user 1 1 1 0
13 access_progress_instructor genius.progress.instructor model_genius_progress group_genius_instructor 1 1 1 1
14 access_tag_standard_user genius.tour.tag.standard.user model_genius_tour_tag base.group_user 1 0 0 0
15 access_tag_user genius.tour.tag.user model_genius_tour_tag group_genius_user 1 0 0 0
16 access_tag_instructor genius.tour.tag.instructor model_genius_tour_tag group_genius_instructor 1 1 1 1
17 access_quiz_standard_user genius.quiz.standard.user model_genius_quiz base.group_user 1 0 0 0
18 access_quiz_user genius.quiz.user model_genius_quiz group_genius_user 1 0 0 0
19 access_quiz_instructor genius.quiz.instructor model_genius_quiz group_genius_instructor 1 1 1 1
20 access_question_standard_user genius.quiz.question.standard.user model_genius_quiz_question base.group_user 1 0 0 0
21 access_question_user genius.quiz.question.user model_genius_quiz_question group_genius_user 1 0 0 0
22 access_question_instructor genius.quiz.question.instructor model_genius_quiz_question group_genius_instructor 1 1 1 1
23 access_answer_standard_user genius.quiz.answer.standard.user model_genius_quiz_answer base.group_user 1 0 0 0
24 access_answer_user genius.quiz.answer.user model_genius_quiz_answer group_genius_user 1 0 0 0
25 access_answer_instructor genius.quiz.answer.instructor model_genius_quiz_answer group_genius_instructor 1 1 1 1
26 access_attempt_standard_user genius.quiz.attempt.standard.user model_genius_quiz_attempt base.group_user 1 1 1 0
27 access_attempt_user genius.quiz.attempt.user model_genius_quiz_attempt group_genius_user 1 1 1 0
28 access_attempt_instructor genius.quiz.attempt.instructor model_genius_quiz_attempt group_genius_instructor 1 1 1 1
29 access_response_standard_user genius.quiz.response.user model_genius_quiz_response base.group_user 1 1 1 0
30 access_response_user genius.quiz.response.user model_genius_quiz_response group_genius_user 1 1 1 0
31 access_response_instructor genius.quiz.response.instructor model_genius_quiz_response group_genius_instructor 1 1 1 1
32 access_leaderboard_user genius.leaderboard.user model_genius_leaderboard group_genius_user 1 0 0 0

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Record Rule: Standard Users can only see Published Topics -->
<record id="rule_genius_topic_published_only" model="ir.rule">
<field name="name">Genius Topic: Published Only for Standard Users</field>
<field name="model_id" ref="model_genius_topic"/>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="domain_force">[('state', '=', 'published'), ('active', '=', True)]</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>
<!-- Record Rule: Genius Users/Instructors/Admins see everything (handled by existing group rules or lack thereof) -->
<!-- Odoo's additive nature means if they are in group_genius_user, they might need an explicit rule if global rules restrict them,
BUT here we are applying a GROUP rule to base.group_user.
If a Genius User is ALSO a base.group_user (which they are), this rule applies.
SO we must allow Genius Users to see everything via another rule or make the above rule EXCLUDE them?
Actually, access rights are additive but Record Rules are:
- Global rules (no group) are INTERSECTED (AND)
- Group rules are UNIONED (OR)
So if I add a rule for base.group_user, it allows access to published.
I need another rule for group_genius_user to allow access to ALL (or draft).
-->
<record id="rule_genius_topic_all_for_genius_users" model="ir.rule">
<field name="name">Genius Topic: All for Genius Users</field>
<field name="model_id" ref="model_genius_topic"/>
<field name="groups" eval="[(4, ref('group_genius_user'))]"/>
<field name="domain_force">[(1, '=', 1)]</field> <!-- Allow everything -->
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
</data>
</odoo>