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