helpdesk_portal.py 23 KB

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