# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import http, fields, _ from odoo.http import request from odoo.exceptions import AccessError, MissingError, UserError from odoo.osv import expression # Try to import from helpdesk_extras first, fallback to helpdesk base try: from odoo.addons.helpdesk_extras.controllers.helpdesk_portal import ( CustomerPortal as HelpdeskCustomerPortal, ) except ImportError: from odoo.addons.helpdesk.controllers.portal import ( CustomerPortal as HelpdeskCustomerPortal, ) class CustomerPortal(HelpdeskCustomerPortal): """ Extend helpdesk portal controller to: - Add dashboard route at /my/tickets-dashboard - Set default grouping by stage for list view - Calculate dashboard metrics """ def _prepare_tickets_dashboard_values(self): """ Calculate dashboard metrics for tickets. Returns dict with all metrics needed for the dashboard. """ partner = request.env.user.partner_id.commercial_partner_id HelpdeskTicket = request.env['helpdesk.ticket'] # Base domain for partner's tickets base_domain = self._prepare_helpdesk_tickets_domain() # Get all tickets for the partner all_tickets = HelpdeskTicket.search(base_domain) # 1. TIME STATS (Tiempo usado/disponible) time_stats = self._calculate_time_stats(partner) # 2. TICKET SUMMARY (Resumen de tickets) ticket_summary = self._calculate_ticket_summary(all_tickets) # 3. SLA COMPLIANCE (Cumplimiento SLA) sla_compliance = self._calculate_sla_compliance(all_tickets) # 4. WAITING RESPONSE (Tickets esperando respuesta) waiting_response = self._get_waiting_response_tickets(all_tickets, partner) # 5. RECENT TICKETS (Tickets recientes - últimos 10) recent_tickets = all_tickets.sorted('create_date', reverse=True)[:10] return { 'time_stats': time_stats, 'ticket_summary': ticket_summary, 'sla_compliance': sla_compliance, 'waiting_response': waiting_response, 'recent_tickets': recent_tickets, } def _calculate_time_stats(self, partner): """ Calculate time used and available for the partner. Reuses the logic from helpdesk_extras controller to avoid code duplication. Calls the same method that the widget uses, ensuring identical calculation. """ import logging _logger = logging.getLogger(__name__) try: # Try to use helpdesk_extras controller if available if 'helpdesk_extras' in request.env.registry._init_modules: try: from odoo.addons.helpdesk_extras.controllers.website_helpdesk_hours import ( WebsiteHelpdeskHours, ) # Create instance and call the method directly # The method uses request.env which is available in the current context hours_controller = WebsiteHelpdeskHours() # IMPORTANT: Call the method directly - it's a regular Python method # The @http.route decorator doesn't prevent direct method calls # This ensures we use the exact same logic as the widget hours_data = hours_controller.get_available_hours() # Log the raw data received for debugging _logger.info(f"[DASHBOARD] Raw hours data from controller: {hours_data}") _logger.info(f"[DASHBOARD] User: {request.env.user.name}, Portal: {request.env.user._is_portal()}") _logger.info(f"[DASHBOARD] Partner: {partner.name}, ID: {partner.id}") # Check if there was an error if hours_data.get('error'): _logger.warning(f"[DASHBOARD] Error in hours data: {hours_data.get('error')}") # If error, return defaults return { 'used': 0.0, 'available': 0.0, 'total': 0.0, 'prepaid_hours': 0.0, 'prepaid_hours_used': 0.0, 'prepaid_hours_total': 0.0, 'credit_hours': 0.0, 'credit_hours_used': 0.0, 'credit_hours_total': 0.0, 'credit_available': 0.0, 'highest_price': 0.0, 'percentage': 0.0, } # Map the response to match dashboard format # Ensure all values are floats to avoid type issues total_available = float(hours_data.get('total_available', 0.0) or 0.0) hours_used = float(hours_data.get('hours_used', 0.0) or 0.0) prepaid_hours = float(hours_data.get('prepaid_hours', 0.0) or 0.0) prepaid_hours_used = float(hours_data.get('prepaid_hours_used', 0.0) or 0.0) prepaid_hours_total = float(hours_data.get('prepaid_hours_total', 0.0) or 0.0) credit_hours = float(hours_data.get('credit_hours', 0.0) or 0.0) credit_hours_used = float(hours_data.get('credit_hours_used', 0.0) or 0.0) credit_hours_total = float(hours_data.get('credit_hours_total', 0.0) or 0.0) credit_available = float(hours_data.get('credit_available', 0.0) or 0.0) highest_price = float(hours_data.get('highest_price', 0.0) or 0.0) # Calculate total hours (prepaid total + credit total) # This is the sum of all hours: available + used total_hours = prepaid_hours_total + credit_hours_total # Calculate percentage (used / total) - using the correct total percentage = (hours_used / total_hours * 100) if total_hours > 0 else 0.0 result = { 'used': round(hours_used, 2), 'available': round(total_available, 2), 'total': round(total_hours, 2), # Total hours (available + used) 'prepaid_hours': round(prepaid_hours, 2), 'prepaid_hours_used': round(prepaid_hours_used, 2), 'prepaid_hours_total': round(prepaid_hours_total, 2), 'credit_hours': round(credit_hours, 2), 'credit_hours_used': round(credit_hours_used, 2), 'credit_hours_total': round(credit_hours_total, 2), 'credit_available': round(credit_available, 2), 'highest_price': round(highest_price, 2), 'percentage': round(percentage, 1), } _logger.info(f"[DASHBOARD] Final time stats result: {result}") return result except ImportError as e: _logger.warning(f"[DASHBOARD] helpdesk_extras controller not available: {str(e)}") except Exception as e: _logger.error(f"[DASHBOARD] Error calling helpdesk_extras controller: {str(e)}", exc_info=True) except Exception as e: _logger.error(f"[DASHBOARD] Error calculating time stats: {str(e)}", exc_info=True) # Fallback: return defaults if helpdesk_extras not available _logger.warning("[DASHBOARD] Returning default time stats (helpdesk_extras not available or error occurred)") return { 'used': 0.0, 'available': 0.0, 'total': 0.0, 'prepaid_hours': 0.0, 'prepaid_hours_used': 0.0, 'prepaid_hours_total': 0.0, 'credit_hours': 0.0, 'credit_hours_used': 0.0, 'credit_hours_total': 0.0, 'credit_available': 0.0, 'highest_price': 0.0, 'percentage': 0.0, } def _calculate_ticket_summary(self, tickets): """ Calculate ticket summary: total, open, closed, by stage, by priority. Returns by_stage as list of tuples for QWeb template compatibility. """ open_tickets = tickets.filtered(lambda t: not t.stage_id.fold) closed_tickets = tickets.filtered(lambda t: t.stage_id.fold) # By stage - convert to list of tuples for QWeb by_stage_dict = {} for ticket in open_tickets: stage_name = ticket.stage_id.name or 'Sin etapa' by_stage_dict[stage_name] = by_stage_dict.get(stage_name, 0) + 1 # Convert to list of tuples for QWeb iteration by_stage_list = [(name, count) for name, count in by_stage_dict.items()] # By priority priority_labels = { '0': 'Baja', '1': 'Media', '2': 'Alta', '3': 'Urgente', } by_priority = {} for ticket in open_tickets: priority_label = priority_labels.get(ticket.priority, 'Sin prioridad') by_priority[priority_label] = by_priority.get(priority_label, 0) + 1 return { 'total': len(tickets), 'open': len(open_tickets), 'closed': len(closed_tickets), 'by_stage': by_stage_list, # List of tuples for QWeb 'by_priority': by_priority, } def _calculate_sla_compliance(self, tickets): """ Calculate SLA compliance percentage. """ tickets_with_sla = tickets.filtered(lambda t: t.sudo().sla_ids) if not tickets_with_sla: return { 'total_with_sla': 0, 'success': 0, 'failed': 0, 'percentage': 0.0, 'is_good': False, # For icon color } success_count = len(tickets_with_sla.filtered(lambda t: t.sla_reached and not t.sla_reached_late)) failed_count = len(tickets_with_sla.filtered(lambda t: t.sla_reached_late)) total = len(tickets_with_sla) percentage = (success_count / total * 100) if total > 0 else 0.0 return { 'total_with_sla': total, 'success': success_count, 'failed': failed_count, 'percentage': round(percentage, 1), 'is_good': percentage >= 80, # For icon color (green if >= 80%) } def _get_waiting_response_tickets(self, tickets, partner): """ Get tickets waiting for CUSTOMER response (not team response). CORRECTED LOGIC: PRIMARY INDICATOR: Tickets in excluded stages - Any ticket in an SLA excluded stage = explicitly waiting for customer - Examples: "Esperando Información", "En Revisión", etc. - This is the most reliable indicator SECONDARY INDICATOR: Last message is from helpdesk team - If last visible message is from team (not customer), team is waiting - This covers cases where team asked something but ticket is still in active stage - We check the LAST message, not oldest_unanswered_customer_message_date (that field indicates customer waiting for team, which is the opposite) IMPORTANT: - oldest_unanswered_customer_message_date = customer sent message, team hasn't responded → This means "waiting for TEAM response", NOT customer response - We need the opposite: last message from TEAM = "waiting for CUSTOMER response" """ open_tickets = tickets.filtered(lambda t: not t.stage_id.fold) if not open_tickets: return { 'count': 0, 'tickets': [], 'by_stage': {}, 'by_reason': { 'excluded_stage': 0, 'last_message_from_team': 0, }, } waiting_tickets = [] waiting_by_stage = {} waiting_by_reason = { 'excluded_stage': 0, 'last_message_from_team': 0, } # Get all messages for open tickets in one efficient query # Exclude system messages (author_id = False or OdooBot) comment_subtype = request.env.ref('mail.mt_comment') odoobot = request.env.ref('base.partner_root', raise_if_not_found=False) odoobot_id = odoobot.id if odoobot else False all_messages = request.env['mail.message'].search([ ('model', '=', 'helpdesk.ticket'), ('res_id', 'in', open_tickets.ids), ('subtype_id', '=', comment_subtype.id), ('message_type', 'in', ['email', 'comment']), ('author_id', '!=', False), # Exclude messages without author ], order='res_id, date desc') # Filter out OdooBot messages if exists if odoobot_id: all_messages = all_messages.filtered(lambda m: m.author_id.id != odoobot_id) # Group messages by ticket and get last message for each last_message_map = {} current_ticket_id = None for msg in all_messages: if msg.res_id != current_ticket_id: # First message for this ticket (already sorted desc) last_message_map[msg.res_id] = msg current_ticket_id = msg.res_id for ticket in open_tickets: # PRIMARY: Check if in SLA excluded stage (waiting stage) # This is explicit and most reliable - if in excluded stage, definitely waiting excluded_stages = ticket.sudo().sla_ids.mapped('exclude_stage_ids') is_in_waiting_stage = ticket.stage_id in excluded_stages if is_in_waiting_stage: # Ticket is in excluded stage = explicitly waiting for customer # BUT: Only if ticket has been assigned (team has interacted) # This prevents new unassigned tickets from appearing if ticket.assign_date or ticket.user_id: waiting_tickets.append(ticket) waiting_by_reason['excluded_stage'] += 1 stage_name = ticket.stage_id.name if stage_name not in waiting_by_stage: waiting_by_stage[stage_name] = [] waiting_by_stage[stage_name].append(ticket) continue # SECONDARY: Check if last message is from helpdesk team # Only if ticket is NOT in excluded stage last_msg = last_message_map.get(ticket.id) if last_msg: # Check if last message is from helpdesk team (internal user) is_helpdesk_msg = False if last_msg.author_id: # Check if author has internal users (not share/portal) author_users = last_msg.author_id.user_ids if author_users: # Message is from helpdesk if any user is internal (not share/portal) is_helpdesk_msg = any(not user.share for user in author_users) else: # No users associated with author = likely external/customer is_helpdesk_msg = False if is_helpdesk_msg: # Last message is from team → waiting for customer response # BUT: Only if ticket has been assigned (team has started working) # This excludes brand new tickets that are waiting for team, not customer # Also exclude if ticket was just created (less than 1 hour ago) and not assigned from datetime import datetime, timedelta one_hour_ago = datetime.now() - timedelta(hours=1) # Only include if: # 1. Ticket has been assigned (team started working), OR # 2. Ticket is older than 1 hour (not brand new) if ticket.assign_date or ticket.user_id or ticket.create_date < one_hour_ago: waiting_tickets.append(ticket) waiting_by_reason['last_message_from_team'] += 1 continue # If no messages at all, it's a new ticket # New tickets are NOT waiting for customer response (they're waiting for team) # So we don't include them # Sort by waiting time (oldest first) waiting_tickets_sorted = sorted( waiting_tickets, key=lambda t: ( t.date_last_stage_update if t.stage_id in t.sudo().sla_ids.mapped('exclude_stage_ids') else last_message_map.get(t.id, request.env['mail.message']).date if last_message_map.get(t.id) else t.date_last_stage_update or t.create_date ) ) return { 'count': len(waiting_tickets), 'tickets': waiting_tickets_sorted[:10], 'by_stage': {name: len(tickets) for name, tickets in waiting_by_stage.items()}, 'by_reason': waiting_by_reason, } @http.route(['/my/tickets-dashboard'], type='http', auth="user", website=True) def my_helpdesk_tickets_dashboard(self, **kw): """ Dashboard view for tickets at /my/tickets-dashboard. Shows metrics and summary instead of full list. """ values = self._prepare_portal_layout_values() dashboard_data = self._prepare_tickets_dashboard_values() values.update({ 'page_name': 'ticket', 'default_url': '/my/tickets-dashboard', 'dashboard_data': dashboard_data, }) return request.render("theme_m22tc.portal_helpdesk_ticket_dashboard", values) @http.route(['/my/tickets', '/my/tickets/page/'], type='http', auth="user", website=True) def my_helpdesk_tickets(self, page=1, date_begin=None, date_end=None, sortby=None, filterby='all', search=None, groupby=None, search_in='name', **kw): """ Override native /my/tickets route to set default groupby='stage_id' when not specified. This preserves native behavior while adding default grouping. """ # Set default groupby to 'stage_id' only if not provided (None) if groupby is None: groupby = 'stage_id' # Call parent method with the updated groupby return super().my_helpdesk_tickets( page=page, date_begin=date_begin, date_end=date_end, sortby=sortby, filterby=filterby, search=search, groupby=groupby, search_in=search_in, **kw ) @http.route([ '/my/ticket/approve/', '/my/ticket/approve//', ], type='http', auth="public", website=True) def ticket_approve(self, ticket_id=None, access_token=None, **kw): """ Approve ticket when it's in an excluded stage (waiting for approval). Moves ticket to next stage (typically Resolved/Closed). """ try: ticket_sudo = self._document_check_access('helpdesk.ticket', ticket_id, access_token) except (AccessError, MissingError): return request.redirect('/my') # Check if ticket is in an excluded stage (waiting for customer) excluded_stages = ticket_sudo.sla_ids.mapped('exclude_stage_ids') if ticket_sudo.stage_id not in excluded_stages: raise UserError(_("This ticket is not waiting for your approval.")) # Find next stage (higher sequence, typically Resolved/Closed) next_stages = ticket_sudo.team_id.stage_ids.filtered( lambda s: s.sequence > ticket_sudo.stage_id.sequence ).sorted('sequence') if not next_stages: # If no next stage, try to find closing stage closing_stage = ticket_sudo.team_id._get_closing_stage() if closing_stage: next_stage = closing_stage[0] else: raise UserError(_("No next stage found for this ticket.")) else: next_stage = next_stages[0] # Move to next stage ticket_sudo.write({'stage_id': next_stage.id}) # Post message body = _('Ticket approved by the customer') ticket_sudo.with_context(mail_create_nosubscribe=True).message_post( body=body, message_type='comment', subtype_xmlid='mail.mt_note' ) return request.redirect('/my/ticket/%s/%s?ticket_approved=1' % (ticket_id, access_token or '')) @http.route([ '/my/ticket/reject/', '/my/ticket/reject//', ], type='http', auth="public", website=True) def ticket_reject(self, ticket_id=None, access_token=None, **kw): """ Reject ticket when it's in an excluded stage (waiting for approval). Moves ticket back to previous non-excluded stage (typically In Progress). """ try: ticket_sudo = self._document_check_access('helpdesk.ticket', ticket_id, access_token) except (AccessError, MissingError): return request.redirect('/my') # Check if ticket is in an excluded stage (waiting for customer) excluded_stages = ticket_sudo.sla_ids.mapped('exclude_stage_ids') if ticket_sudo.stage_id not in excluded_stages: raise UserError(_("This ticket is not waiting for your approval.")) # Find previous non-excluded stage (lower sequence, not in excluded stages) prev_stages = ticket_sudo.team_id.stage_ids.filtered( lambda s: s.sequence < ticket_sudo.stage_id.sequence and s not in excluded_stages ).sorted('sequence', reverse=True) if not prev_stages: # If no previous stage, try to find first non-excluded stage first_stages = ticket_sudo.team_id.stage_ids.filtered( lambda s: s not in excluded_stages ).sorted('sequence') if first_stages: prev_stage = first_stages[0] else: raise UserError(_("No previous stage found for this ticket.")) else: prev_stage = prev_stages[0] # Move to previous stage ticket_sudo.write({'stage_id': prev_stage.id}) # Post message body = _('Ticket rejected by the customer - needs more work') ticket_sudo.with_context(mail_create_nosubscribe=True).message_post( body=body, message_type='comment', subtype_xmlid='mail.mt_note' ) return request.redirect('/my/ticket/%s/%s?ticket_rejected=1' % (ticket_id, access_token or ''))