odex25_standard/odex25_base/tour_genius/models/tour.py

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,
}