helpdesk_portal.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. from odoo import http, fields, _
  4. from odoo.http import request
  5. from odoo.exceptions import AccessError, MissingError, UserError
  6. from odoo.osv import expression
  7. # Try to import from helpdesk_extras first, fallback to helpdesk base
  8. try:
  9. from odoo.addons.helpdesk_extras.controllers.helpdesk_portal import (
  10. CustomerPortal as HelpdeskCustomerPortal,
  11. )
  12. except ImportError:
  13. from odoo.addons.helpdesk.controllers.portal import (
  14. CustomerPortal as HelpdeskCustomerPortal,
  15. )
  16. class CustomerPortal(HelpdeskCustomerPortal):
  17. """
  18. Extend helpdesk portal controller to:
  19. - Add dashboard route at /my/tickets-dashboard
  20. - Set default grouping by stage for list view
  21. - Calculate dashboard metrics
  22. """
  23. def _prepare_portal_layout_values(self):
  24. values = super()._prepare_portal_layout_values()
  25. sales_user = request.env['res.users']
  26. partner = request.env.user.partner_id.sudo()
  27. if partner.user_id and not partner.user_id._is_public():
  28. sales_user = partner.user_id
  29. else:
  30. fallback_sales_user = partner.commercial_partner_id.sudo().user_id
  31. if fallback_sales_user and not fallback_sales_user._is_public():
  32. sales_user = fallback_sales_user
  33. values.update({'sales_user': sales_user.sudo()})
  34. return values
  35. def _prepare_tickets_dashboard_values(self):
  36. """
  37. Calculate dashboard metrics for tickets.
  38. Returns dict with all metrics needed for the dashboard.
  39. """
  40. partner = request.env.user.partner_id.sudo().commercial_partner_id
  41. HelpdeskTicket = request.env['helpdesk.ticket']
  42. # Base domain for partner's tickets
  43. base_domain = self._prepare_helpdesk_tickets_domain()
  44. # Get all tickets for the partner
  45. all_tickets = HelpdeskTicket.search(base_domain)
  46. # 1. TIME STATS (Tiempo usado/disponible)
  47. time_stats = self._calculate_time_stats(partner)
  48. # 2. TICKET SUMMARY (Resumen de tickets)
  49. ticket_summary = self._calculate_ticket_summary(all_tickets)
  50. # 3. SLA COMPLIANCE (Cumplimiento SLA)
  51. sla_compliance = self._calculate_sla_compliance(all_tickets)
  52. # 4. WAITING RESPONSE (Tickets esperando respuesta)
  53. waiting_response = self._get_waiting_response_tickets(all_tickets, partner)
  54. # 5. RECENT TICKETS (Tickets recientes - últimos 10)
  55. recent_tickets = all_tickets.sorted('create_date', reverse=True)[:10]
  56. return {
  57. 'time_stats': time_stats,
  58. 'ticket_summary': ticket_summary,
  59. 'sla_compliance': sla_compliance,
  60. 'waiting_response': waiting_response,
  61. 'recent_tickets': recent_tickets,
  62. }
  63. def _calculate_time_stats(self, partner):
  64. """
  65. Calculate time used and available for the partner.
  66. Reuses the logic from helpdesk_extras controller to avoid code duplication.
  67. Calls the same method that the widget uses, ensuring identical calculation.
  68. """
  69. import logging
  70. _logger = logging.getLogger(__name__)
  71. try:
  72. # Try to use helpdesk_extras controller if available
  73. if 'helpdesk_extras' in request.env.registry._init_modules:
  74. try:
  75. from odoo.addons.helpdesk_extras.controllers.website_helpdesk_hours import (
  76. WebsiteHelpdeskHours,
  77. )
  78. # Create instance and call the method directly
  79. # The method uses request.env which is available in the current context
  80. hours_controller = WebsiteHelpdeskHours()
  81. # IMPORTANT: Call the method directly - it's a regular Python method
  82. # The @http.route decorator doesn't prevent direct method calls
  83. # This ensures we use the exact same logic as the widget
  84. hours_data = hours_controller.get_available_hours()
  85. # Log the raw data received for debugging
  86. _logger.info(f"[DASHBOARD] Raw hours data from controller: {hours_data}")
  87. _logger.info(f"[DASHBOARD] User: {request.env.user.name}, Portal: {request.env.user._is_portal()}")
  88. _logger.info(f"[DASHBOARD] Partner: {partner.name}, ID: {partner.id}")
  89. # Check if there was an error
  90. if hours_data.get('error'):
  91. _logger.warning(f"[DASHBOARD] Error in hours data: {hours_data.get('error')}")
  92. # If error, return defaults
  93. return {
  94. 'used': 0.0,
  95. 'available': 0.0,
  96. 'total': 0.0,
  97. 'prepaid_hours': 0.0,
  98. 'prepaid_hours_used': 0.0,
  99. 'prepaid_hours_total': 0.0,
  100. 'credit_hours': 0.0,
  101. 'credit_hours_used': 0.0,
  102. 'credit_hours_total': 0.0,
  103. 'credit_available': 0.0,
  104. 'highest_price': 0.0,
  105. 'percentage': 0.0,
  106. }
  107. # Map the response to match dashboard format
  108. # Ensure all values are floats to avoid type issues
  109. total_available = float(hours_data.get('total_available', 0.0) or 0.0)
  110. hours_used = float(hours_data.get('hours_used', 0.0) or 0.0)
  111. prepaid_hours = float(hours_data.get('prepaid_hours', 0.0) or 0.0)
  112. prepaid_hours_used = float(hours_data.get('prepaid_hours_used', 0.0) or 0.0)
  113. prepaid_hours_total = float(hours_data.get('prepaid_hours_total', 0.0) or 0.0)
  114. credit_hours = float(hours_data.get('credit_hours', 0.0) or 0.0)
  115. credit_hours_used = float(hours_data.get('credit_hours_used', 0.0) or 0.0)
  116. credit_hours_total = float(hours_data.get('credit_hours_total', 0.0) or 0.0)
  117. credit_available = float(hours_data.get('credit_available', 0.0) or 0.0)
  118. highest_price = float(hours_data.get('highest_price', 0.0) or 0.0)
  119. # Calculate total hours (prepaid total + credit total)
  120. # This is the sum of all hours: available + used
  121. total_hours = prepaid_hours_total + credit_hours_total
  122. # Calculate percentage (used / total) - using the correct total
  123. percentage = (hours_used / total_hours * 100) if total_hours > 0 else 0.0
  124. result = {
  125. 'used': round(hours_used, 2),
  126. 'available': round(total_available, 2),
  127. 'total': round(total_hours, 2), # Total hours (available + used)
  128. 'prepaid_hours': round(prepaid_hours, 2),
  129. 'prepaid_hours_used': round(prepaid_hours_used, 2),
  130. 'prepaid_hours_total': round(prepaid_hours_total, 2),
  131. 'credit_hours': round(credit_hours, 2),
  132. 'credit_hours_used': round(credit_hours_used, 2),
  133. 'credit_hours_total': round(credit_hours_total, 2),
  134. 'credit_available': round(credit_available, 2),
  135. 'highest_price': round(highest_price, 2),
  136. 'percentage': round(percentage, 1),
  137. }
  138. _logger.info(f"[DASHBOARD] Final time stats result: {result}")
  139. return result
  140. except ImportError as e:
  141. _logger.warning(f"[DASHBOARD] helpdesk_extras controller not available: {str(e)}")
  142. except Exception as e:
  143. _logger.error(f"[DASHBOARD] Error calling helpdesk_extras controller: {str(e)}", exc_info=True)
  144. except Exception as e:
  145. _logger.error(f"[DASHBOARD] Error calculating time stats: {str(e)}", exc_info=True)
  146. # Fallback: return defaults if helpdesk_extras not available
  147. _logger.warning("[DASHBOARD] Returning default time stats (helpdesk_extras not available or error occurred)")
  148. return {
  149. 'used': 0.0,
  150. 'available': 0.0,
  151. 'total': 0.0,
  152. 'prepaid_hours': 0.0,
  153. 'prepaid_hours_used': 0.0,
  154. 'prepaid_hours_total': 0.0,
  155. 'credit_hours': 0.0,
  156. 'credit_hours_used': 0.0,
  157. 'credit_hours_total': 0.0,
  158. 'credit_available': 0.0,
  159. 'highest_price': 0.0,
  160. 'percentage': 0.0,
  161. }
  162. def _calculate_ticket_summary(self, tickets):
  163. """
  164. Calculate ticket summary: total, open, closed, by stage, by priority.
  165. Returns by_stage as list of tuples for QWeb template compatibility, ordered by stage sequence.
  166. """
  167. open_tickets = tickets.filtered(lambda t: not t.stage_id.fold)
  168. closed_tickets = tickets.filtered(lambda t: t.stage_id.fold)
  169. # By stage with ordering by stage sequence
  170. by_stage_counts = {}
  171. by_stage_order = {}
  172. for ticket in open_tickets:
  173. stage = ticket.stage_id
  174. stage_name = stage.name or 'Sin etapa'
  175. by_stage_counts[stage_name] = by_stage_counts.get(stage_name, 0) + 1
  176. seq = stage.sequence or 0
  177. if stage_name not in by_stage_order:
  178. by_stage_order[stage_name] = seq
  179. else:
  180. by_stage_order[stage_name] = min(by_stage_order[stage_name], seq)
  181. # Build ordered list by min sequence, then by name for stability
  182. by_stage_list = sorted(
  183. [(name, count) for name, count in by_stage_counts.items()],
  184. key=lambda item: (by_stage_order.get(item[0], 0), item[0].lower())
  185. )
  186. # By priority
  187. priority_labels = {
  188. '0': 'Baja',
  189. '1': 'Media',
  190. '2': 'Alta',
  191. '3': 'Urgente',
  192. }
  193. by_priority = {}
  194. for ticket in open_tickets:
  195. priority_label = priority_labels.get(ticket.priority, 'Sin prioridad')
  196. by_priority[priority_label] = by_priority.get(priority_label, 0) + 1
  197. return {
  198. 'total': len(tickets),
  199. 'open': len(open_tickets),
  200. 'closed': len(closed_tickets),
  201. 'by_stage': by_stage_list, # List of tuples for QWeb
  202. 'by_priority': by_priority,
  203. }
  204. def _calculate_sla_compliance(self, tickets):
  205. """
  206. Calculate SLA compliance percentage.
  207. """
  208. tickets_with_sla = tickets.filtered(lambda t: t.sudo().sla_ids)
  209. if not tickets_with_sla:
  210. return {
  211. 'total_with_sla': 0,
  212. 'success': 0,
  213. 'failed': 0,
  214. 'percentage': 0.0,
  215. 'is_good': False, # For icon color
  216. }
  217. success_count = len(tickets_with_sla.filtered(lambda t: t.sla_reached and not t.sla_reached_late))
  218. failed_count = len(tickets_with_sla.filtered(lambda t: t.sla_reached_late))
  219. total = len(tickets_with_sla)
  220. percentage = (success_count / total * 100) if total > 0 else 0.0
  221. return {
  222. 'total_with_sla': total,
  223. 'success': success_count,
  224. 'failed': failed_count,
  225. 'percentage': round(percentage, 1),
  226. 'is_good': percentage >= 80, # For icon color (green if >= 80%)
  227. }
  228. def _get_waiting_response_tickets(self, tickets, partner):
  229. """
  230. Get tickets waiting for CUSTOMER response (not team response).
  231. CORRECTED LOGIC:
  232. PRIMARY INDICATOR: Tickets in excluded stages
  233. - Any ticket in an SLA excluded stage = explicitly waiting for customer
  234. - Examples: "Esperando Información", "En Revisión", etc.
  235. - This is the most reliable indicator
  236. SECONDARY INDICATOR: Last message is from helpdesk team
  237. - If last visible message is from team (not customer), team is waiting
  238. - This covers cases where team asked something but ticket is still in active stage
  239. - We check the LAST message, not oldest_unanswered_customer_message_date
  240. (that field indicates customer waiting for team, which is the opposite)
  241. IMPORTANT:
  242. - oldest_unanswered_customer_message_date = customer sent message, team hasn't responded
  243. → This means "waiting for TEAM response", NOT customer response
  244. - We need the opposite: last message from TEAM = "waiting for CUSTOMER response"
  245. """
  246. open_tickets = tickets.filtered(lambda t: not t.stage_id.fold)
  247. if not open_tickets:
  248. return {
  249. 'count': 0,
  250. 'tickets': [],
  251. 'by_stage': {},
  252. 'by_reason': {
  253. 'excluded_stage': 0,
  254. 'last_message_from_team': 0,
  255. },
  256. }
  257. waiting_tickets = []
  258. waiting_by_stage = {}
  259. waiting_by_reason = {
  260. 'excluded_stage': 0,
  261. 'last_message_from_team': 0,
  262. }
  263. # Get all messages for open tickets in one efficient query
  264. # Exclude system messages (author_id = False or OdooBot)
  265. comment_subtype = request.env.ref('mail.mt_comment').sudo()
  266. odoobot = request.env.ref('base.partner_root', raise_if_not_found=False).sudo()
  267. odoobot_id = odoobot.id if odoobot else False
  268. all_messages = request.env['mail.message'].sudo().search([
  269. ('model', '=', 'helpdesk.ticket'),
  270. ('res_id', 'in', open_tickets.ids),
  271. ('subtype_id', '=', comment_subtype.id),
  272. ('message_type', 'in', ['email', 'comment']),
  273. ('author_id', '!=', False), # Exclude messages without author
  274. ], order='res_id, date desc')
  275. # Filter out OdooBot messages if exists
  276. if odoobot_id:
  277. all_messages = all_messages.filtered(lambda m: m.author_id.id != odoobot_id)
  278. # Group messages by ticket and get last message for each
  279. last_message_map = {}
  280. current_ticket_id = None
  281. for msg in all_messages:
  282. if msg.res_id != current_ticket_id:
  283. # First message for this ticket (already sorted desc)
  284. last_message_map[msg.res_id] = msg
  285. current_ticket_id = msg.res_id
  286. for ticket in open_tickets:
  287. # PRIMARY: Check if in SLA excluded stage (waiting stage)
  288. # This is explicit and most reliable - if in excluded stage, definitely waiting
  289. excluded_stages = ticket.sudo().sla_ids.mapped('exclude_stage_ids')
  290. is_in_waiting_stage = ticket.stage_id in excluded_stages
  291. if is_in_waiting_stage:
  292. # Ticket is in excluded stage = explicitly waiting for customer
  293. # BUT: Only if ticket has been assigned (team has interacted)
  294. # This prevents new unassigned tickets from appearing
  295. if ticket.assign_date or ticket.user_id:
  296. waiting_tickets.append(ticket)
  297. waiting_by_reason['excluded_stage'] += 1
  298. stage_name = ticket.stage_id.name
  299. if stage_name not in waiting_by_stage:
  300. waiting_by_stage[stage_name] = []
  301. waiting_by_stage[stage_name].append(ticket)
  302. continue
  303. # SECONDARY: Check if last message is from helpdesk team
  304. # Only if ticket is NOT in excluded stage
  305. last_msg = last_message_map.get(ticket.id)
  306. if last_msg:
  307. # Check if last message is from helpdesk team (internal user)
  308. is_helpdesk_msg = False
  309. last_msg_sudo = last_msg.sudo()
  310. if last_msg_sudo.author_id:
  311. # Check if author has internal users (not share/portal)
  312. author_users = last_msg_sudo.author_id.sudo().user_ids
  313. if author_users:
  314. # Message is from helpdesk if any user is internal (not share/portal)
  315. is_helpdesk_msg = any(not user.share for user in author_users)
  316. else:
  317. # No users associated with author = likely external/customer
  318. is_helpdesk_msg = False
  319. if is_helpdesk_msg:
  320. # Last message is from team → waiting for customer response
  321. # BUT: Only if ticket has been assigned (team has started working)
  322. # This excludes brand new tickets that are waiting for team, not customer
  323. # Also exclude if ticket was just created (less than 1 hour ago) and not assigned
  324. from datetime import datetime, timedelta
  325. one_hour_ago = datetime.now() - timedelta(hours=1)
  326. # Only include if:
  327. # 1. Ticket has been assigned (team started working), OR
  328. # 2. Ticket is older than 1 hour (not brand new)
  329. if ticket.assign_date or ticket.user_id or ticket.create_date < one_hour_ago:
  330. waiting_tickets.append(ticket)
  331. waiting_by_reason['last_message_from_team'] += 1
  332. continue
  333. # If no messages at all, it's a new ticket
  334. # New tickets are NOT waiting for customer response (they're waiting for team)
  335. # So we don't include them
  336. # Sort by waiting time (oldest first)
  337. waiting_tickets_sorted = sorted(
  338. waiting_tickets,
  339. key=lambda t: (
  340. t.date_last_stage_update if t.stage_id in t.sudo().sla_ids.mapped('exclude_stage_ids')
  341. else last_message_map.get(t.id, request.env['mail.message']).date if last_message_map.get(t.id)
  342. else t.date_last_stage_update
  343. or t.create_date
  344. )
  345. )
  346. return {
  347. 'count': len(waiting_tickets),
  348. 'tickets': waiting_tickets_sorted[:10],
  349. 'by_stage': {name: len(tickets) for name, tickets in waiting_by_stage.items()},
  350. 'by_reason': waiting_by_reason,
  351. }
  352. @http.route(['/my/tickets-dashboard'], type='http', auth="user", website=True)
  353. def my_helpdesk_tickets_dashboard(self, **kw):
  354. """
  355. Dashboard view for tickets at /my/tickets-dashboard.
  356. Shows metrics and summary instead of full list.
  357. """
  358. values = self._prepare_portal_layout_values()
  359. dashboard_data = self._prepare_tickets_dashboard_values()
  360. values.update({
  361. 'page_name': 'ticket',
  362. 'default_url': '/my/tickets-dashboard',
  363. 'dashboard_data': dashboard_data,
  364. })
  365. return request.render("theme_m22tc.portal_helpdesk_ticket_dashboard", values)
  366. @http.route(['/my/tickets', '/my/tickets/page/<int:page>'], type='http', auth="user", website=True)
  367. 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):
  368. """
  369. Override native /my/tickets route to set default groupby='stage_id' when not specified.
  370. This preserves native behavior while adding default grouping.
  371. """
  372. # Set default groupby to 'stage_id' only if not provided (None)
  373. if groupby is None:
  374. groupby = 'stage_id'
  375. # Call parent method with the updated groupby
  376. return super().my_helpdesk_tickets(
  377. page=page,
  378. date_begin=date_begin,
  379. date_end=date_end,
  380. sortby=sortby,
  381. filterby=filterby,
  382. search=search,
  383. groupby=groupby,
  384. search_in=search_in,
  385. **kw
  386. )
  387. @http.route([
  388. '/my/ticket/approve/<int:ticket_id>',
  389. '/my/ticket/approve/<int:ticket_id>/<access_token>',
  390. ], type='http', auth="public", website=True)
  391. def ticket_approve(self, ticket_id=None, access_token=None, **kw):
  392. """
  393. Approve ticket when it's in an excluded stage (waiting for approval).
  394. Moves ticket to next stage (typically Resolved/Closed).
  395. """
  396. try:
  397. ticket_sudo = self._document_check_access('helpdesk.ticket', ticket_id, access_token)
  398. except (AccessError, MissingError):
  399. return request.redirect('/my')
  400. # Check if ticket is in an excluded stage (waiting for customer)
  401. excluded_stages = ticket_sudo.sla_ids.mapped('exclude_stage_ids')
  402. if ticket_sudo.stage_id not in excluded_stages:
  403. raise UserError(_("This ticket is not waiting for your approval."))
  404. # Find next stage (higher sequence, typically Resolved/Closed)
  405. next_stages = ticket_sudo.team_id.stage_ids.filtered(
  406. lambda s: s.sequence > ticket_sudo.stage_id.sequence
  407. ).sorted('sequence')
  408. if not next_stages:
  409. # If no next stage, try to find closing stage
  410. closing_stage = ticket_sudo.team_id._get_closing_stage()
  411. if closing_stage:
  412. next_stage = closing_stage[0]
  413. else:
  414. raise UserError(_("No next stage found for this ticket."))
  415. else:
  416. next_stage = next_stages[0]
  417. # Move to next stage
  418. ticket_sudo.write({'stage_id': next_stage.id})
  419. # Post message
  420. body = _('Ticket approved by the customer')
  421. ticket_sudo.with_context(mail_create_nosubscribe=True).message_post(
  422. body=body,
  423. message_type='comment',
  424. subtype_xmlid='mail.mt_note'
  425. )
  426. return request.redirect('/my/ticket/%s/%s?ticket_approved=1' % (ticket_id, access_token or ''))
  427. @http.route([
  428. '/my/ticket/reject/<int:ticket_id>',
  429. '/my/ticket/reject/<int:ticket_id>/<access_token>',
  430. ], type='http', auth="public", website=True)
  431. def ticket_reject(self, ticket_id=None, access_token=None, **kw):
  432. """
  433. Reject ticket when it's in an excluded stage (waiting for approval).
  434. Moves ticket back to previous non-excluded stage (typically In Progress).
  435. """
  436. try:
  437. ticket_sudo = self._document_check_access('helpdesk.ticket', ticket_id, access_token)
  438. except (AccessError, MissingError):
  439. return request.redirect('/my')
  440. # Check if ticket is in an excluded stage (waiting for customer)
  441. excluded_stages = ticket_sudo.sla_ids.mapped('exclude_stage_ids')
  442. if ticket_sudo.stage_id not in excluded_stages:
  443. raise UserError(_("This ticket is not waiting for your approval."))
  444. # Find previous non-excluded stage (lower sequence, not in excluded stages)
  445. prev_stages = ticket_sudo.team_id.stage_ids.filtered(
  446. lambda s: s.sequence < ticket_sudo.stage_id.sequence
  447. and s not in excluded_stages
  448. ).sorted('sequence', reverse=True)
  449. if not prev_stages:
  450. # If no previous stage, try to find first non-excluded stage
  451. first_stages = ticket_sudo.team_id.stage_ids.filtered(
  452. lambda s: s not in excluded_stages
  453. ).sorted('sequence')
  454. if first_stages:
  455. prev_stage = first_stages[0]
  456. else:
  457. raise UserError(_("No previous stage found for this ticket."))
  458. else:
  459. prev_stage = prev_stages[0]
  460. # Move to previous stage
  461. ticket_sudo.write({'stage_id': prev_stage.id})
  462. # Post message
  463. body = _('Ticket rejected by the customer - needs more work')
  464. ticket_sudo.with_context(mail_create_nosubscribe=True).message_post(
  465. body=body,
  466. message_type='comment',
  467. subtype_xmlid='mail.mt_note'
  468. )
  469. return request.redirect('/my/ticket/%s/%s?ticket_rejected=1' % (ticket_id, access_token or ''))