# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging from odoo import http from odoo.http import request from odoo.osv import expression _logger = logging.getLogger(__name__) class WebsiteHelpdeskHours(http.Controller): """Controller for helpdesk hours widget""" @http.route("/helpdesk/hours/available", type="json", auth="user", website=True) def get_available_hours(self): """ Calculate available hours for the authenticated portal user's partner. Returns: dict: { 'total_available': float, # Total hours available 'hours_used': float, # Hours already delivered/used 'prepaid_hours': float, # Hours from prepaid orders (not delivered) 'credit_hours': float, # Hours calculated from available credit 'credit_available': float, # Available credit amount 'highest_price': float, # Highest price unit for hours } """ try: # Get contact information early for use in all return cases company = request.env.company config_param = request.env["ir.config_parameter"].sudo() whatsapp_number = config_param.get_param( "helpdesk_extras.whatsapp_number", "" ) company_email = company.email or "" packages_url = config_param.get_param( "helpdesk_extras.packages_url", "/shop" ) # Check if user is portal if not request.env.user._is_portal(): return { "error": "Access denied: User is not a portal user", "total_available": 0.0, "hours_used": 0.0, "prepaid_hours": 0.0, "credit_hours": 0.0, "credit_available": 0.0, "highest_price": 0.0, "whatsapp_number": whatsapp_number, "email": company_email, "packages_url": packages_url, } partner = request.env.user.partner_id.commercial_partner_id # Get UoM hour reference (use sudo to access uom.uom) try: uom_hour = request.env.ref("uom.product_uom_hour").sudo() except Exception as e: return { "error": f"Error getting UoM hour: {str(e)}", "total_available": 0.0, "hours_used": 0.0, "prepaid_hours": 0.0, "credit_hours": 0.0, "credit_available": 0.0, "highest_price": 0.0, "whatsapp_number": whatsapp_number, "email": company_email, "packages_url": packages_url, } # Get helpdesk teams where this user is a collaborator collaborator_teams = ( request.env["helpdesk.team.collaborator"] .sudo() .search([("partner_id", "=", partner.id)]) .mapped("team_id") ) # If user is not a collaborator in any team, return empty results if not collaborator_teams: return { "total_available": 0.0, "hours_used": 0.0, "prepaid_hours": 0.0, "credit_hours": 0.0, "credit_available": 0.0, "highest_price": 0.0, "whatsapp_number": whatsapp_number, "email": company_email, "packages_url": packages_url, } # Get all prepaid sale order lines for the partner # Following Odoo's standard procedure from helpdesk_sale_timesheet SaleOrderLine = request.env["sale.order.line"].sudo() # Use the same domain that Odoo uses in _get_last_sol_of_customer # This ensures we follow Odoo's standard procedure domain = [ ("company_id", "=", company.id), ("order_partner_id", "child_of", partner.id), ("state", "in", ["sale", "done"]), ("remaining_hours", ">", 0), # Only lines with remaining hours ] # Check if sale_timesheet module is installed has_sale_timesheet = "sale_timesheet" in request.env.registry._init_modules if has_sale_timesheet: # Use _domain_sale_line_service to filter service products correctly # This is the same method Odoo uses internally in _get_last_sol_of_customer try: service_domain = SaleOrderLine._domain_sale_line_service( check_state=False ) # Combine domains using expression.AND() as Odoo does domain = expression.AND([domain, service_domain]) except Exception: # Fallback if _domain_sale_line_service is not available domain = expression.AND( [ domain, [ ("product_id.type", "=", "service"), ("product_id.service_policy", "=", "ordered_prepaid"), ("remaining_hours_available", "=", True), ], ] ) # Search for prepaid lines following Odoo's standard procedure prepaid_sol_lines = SaleOrderLine.search(domain) # Filter lines from orders that have received payment # Only consider hours from orders with paid invoices helpdesk_team_model = request.env["helpdesk.team"] # Filter lines from orders that have received payment # Use explicit loop to handle exceptions properly paid_prepaid_lines = request.env["sale.order.line"].sudo() for line in prepaid_sol_lines: try: if helpdesk_team_model._is_order_paid(line.order_id): paid_prepaid_lines |= line except Exception as e: # Log exception only in debug mode _logger.debug( "Error checking payment for line %s, order %s: %s", line.id, line.order_id.id, str(e), exc_info=True ) # Calculate prepaid hours using Odoo's remaining_hours field # This is the correct way as it handles UOM conversion automatically prepaid_hours = 0.0 highest_price = 0.0 for line in paid_prepaid_lines: # Use remaining_hours directly (already in hours, handles UOM conversion) # This is the field Odoo uses and calculates correctly remaining = line.remaining_hours or 0.0 prepaid_hours += max(0.0, remaining) # Track highest price unit if line.price_unit > highest_price: highest_price = line.price_unit # If no paid lines with price, try to get price from all prepaid lines (historical) # This is needed to calculate credit_hours even if there are no paid lines currently if highest_price == 0 and prepaid_sol_lines: for line in prepaid_sol_lines: if line.price_unit > highest_price: highest_price = line.price_unit # Calculate hours used from ALL prepaid lines (including those fully consumed) # This gives a complete picture of hours used by the customer hours_used_domain = [ ("company_id", "=", company.id), ("order_partner_id", "child_of", partner.id), ("state", "in", ["sale", "done"]), ] if has_sale_timesheet: try: service_domain = SaleOrderLine._domain_sale_line_service( check_state=False ) hours_used_domain = expression.AND( [hours_used_domain, service_domain] ) except Exception: hours_used_domain = expression.AND( [ hours_used_domain, [ ("product_id.type", "=", "service"), ("product_id.service_policy", "=", "ordered_prepaid"), ("remaining_hours_available", "=", True), ], ] ) all_prepaid_lines = SaleOrderLine.search(hours_used_domain) # Filter lines from orders that have received payment # Only consider hours used from orders with paid invoices # Use explicit loop to handle exceptions properly paid_all_prepaid_lines = request.env["sale.order.line"].sudo() for line in all_prepaid_lines: try: if helpdesk_team_model._is_order_paid(line.order_id): paid_all_prepaid_lines |= line except Exception as e: # Log exception only in debug mode _logger.debug( "Error checking payment for line %s, order %s: %s", line.id, line.order_id.id, str(e), exc_info=True ) hours_used = 0.0 for line in paid_all_prepaid_lines: # Calculate hours used: qty_delivered converted to hours # Use the same UOM conversion that Odoo uses qty_delivered = line.qty_delivered or 0.0 if qty_delivered > 0: qty_delivered_hours = ( line.product_uom._compute_quantity( qty_delivered, uom_hour, raise_if_failure=False ) or 0.0 ) hours_used += qty_delivered_hours # Calculate credit hours credit_hours = 0.0 credit_available = 0.0 # Check if credit limit is configured # Use sudo to access credit fields which may have restricted access partner_sudo = partner.sudo() if company.account_use_credit_limit and partner_sudo.credit_limit > 0: credit_used = partner_sudo.credit or 0.0 credit_available = max(0.0, partner_sudo.credit_limit - credit_used) # Convert credit to hours using highest price if highest_price > 0 and credit_available > 0: credit_hours = credit_available / highest_price elif highest_price == 0 and credit_available > 0: # If no hours sold yet, we can't calculate credit hours # But we still show the credit available credit_hours = 0.0 total_available = prepaid_hours + credit_hours return { "total_available": round(total_available, 2), "hours_used": round(hours_used, 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), "whatsapp_number": whatsapp_number, "email": company_email, "packages_url": packages_url, } except Exception as e: # Log critical errors with full traceback _logger.error( "Error in get_available_hours for partner %s: %s", request.env.user.partner_id.id if request.env.user else "unknown", str(e), exc_info=True ) # Get contact information for error case try: company = request.env.company config_param = request.env["ir.config_parameter"].sudo() whatsapp_number = config_param.get_param( "helpdesk_extras.whatsapp_number", "" ) company_email = company.email or "" packages_url = config_param.get_param( "helpdesk_extras.packages_url", "/shop" ) except: whatsapp_number = "" company_email = "" packages_url = "/shop" return { "error": f"Error al calcular horas disponibles: {str(e)}", "total_available": 0.0, "hours_used": 0.0, "prepaid_hours": 0.0, "credit_hours": 0.0, "credit_available": 0.0, "highest_price": 0.0, "whatsapp_number": whatsapp_number, "email": company_email, "packages_url": packages_url, } @http.route("/helpdesk/form/check_block", type="json", auth="public", website=True) def check_form_block(self, team_id=None): """ Check if the helpdesk ticket form should be blocked. Returns True if form should be blocked (has collaborators and no available hours). Args: team_id: ID of the helpdesk team Returns: dict: { 'should_block': bool, # True if form should be blocked 'has_collaborators': bool, # True if team has collaborators 'has_hours': bool, # True if user has available hours 'message': str, # Message to show if blocked } """ try: # If user is not portal or public, don't block if not request.env.user or not request.env.user._is_portal(): return { "should_block": False, "has_collaborators": False, "has_hours": True, "message": "", } if not team_id: return { "should_block": False, "has_collaborators": False, "has_hours": True, "message": "", } # Get the team team = request.env["helpdesk.team"].sudo().browse(team_id) if not team.exists(): return { "should_block": False, "has_collaborators": False, "has_hours": True, "message": "", } # Check if team has collaborators has_collaborators = bool(team.collaborator_ids) # If no collaborators, don't block if not has_collaborators: return { "should_block": False, "has_collaborators": False, "has_hours": True, "message": "", } # Check if user has available hours hours_data = self.get_available_hours() has_hours = hours_data.get("total_available", 0.0) > 0.0 # Block only if has collaborators AND no hours should_block = has_collaborators and not has_hours # Get contact information for message config_param = request.env["ir.config_parameter"].sudo() whatsapp_number = config_param.get_param( "helpdesk_extras.whatsapp_number", "" ) company_email = request.env.company.email or "" packages_url = config_param.get_param( "helpdesk_extras.packages_url", "/shop" ) message = "" if should_block: message = "No tienes horas disponibles para crear un ticket. Por favor, contacta con nosotros para adquirir más horas." if whatsapp_number or company_email: contact_info = [] if whatsapp_number: contact_info.append(f"WhatsApp: {whatsapp_number}") if company_email: contact_info.append(f"Email: {company_email}") if contact_info: message += " " + " | ".join(contact_info) return { "should_block": should_block, "has_collaborators": has_collaborators, "has_hours": has_hours, "message": message, } except Exception as e: # Log critical errors with full traceback _logger.error( "Error in check_form_block for team_id %s: %s", team_id, str(e), exc_info=True ) # On error, don't block to avoid breaking the form return { "should_block": False, "has_collaborators": False, "has_hours": True, "message": "", }