| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499 |
- # -*- 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,
- 'prepaid_hours': 0.0,
- 'credit_hours': 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)
- credit_hours = float(hours_data.get('credit_hours', 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 percentage (used / (used + available)) - same as widget
- total_with_used = total_available + hours_used
- percentage = (hours_used / total_with_used * 100) if total_with_used > 0 else 0.0
-
- result = {
- 'used': round(hours_used, 2),
- 'available': round(total_available, 2),
- 'prepaid_hours': round(prepaid_hours, 2),
- 'credit_hours': round(credit_hours, 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,
- 'prepaid_hours': 0.0,
- 'credit_hours': 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/<int: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/<int:ticket_id>',
- '/my/ticket/approve/<int:ticket_id>/<access_token>',
- ], 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/<int:ticket_id>',
- '/my/ticket/reject/<int:ticket_id>/<access_token>',
- ], 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 ''))
|