helpdesk_portal.py 22 KB

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