950 lines
38 KiB
Python
950 lines
38 KiB
Python
# -*- 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,
|
|
}
|