11"""DCI Client Service for making signed API requests."""
22
3+ import json
34import logging
5+ import time
46import uuid
57from datetime import UTC , datetime
68from typing import Any
@@ -904,6 +906,13 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
904906 # Get headers from data source (includes auth)
905907 headers = self .data_source .get_headers ()
906908
909+ # Track timing and result for outgoing log
910+ start_time = time .monotonic ()
911+ log_status = "success"
912+ log_status_code = None
913+ log_response_data = None
914+ log_error_detail = None
915+
907916 try :
908917 _logger .info (
909918 "Making DCI request to %s (action: %s)" ,
@@ -930,6 +939,13 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
930939 # Handle 401 Unauthorized - try refreshing token once
931940 if response .status_code == 401 and _retry_auth and self .data_source .auth_type == "oauth2" :
932941 _logger .warning ("Got 401 Unauthorized, clearing OAuth2 token cache and retrying with fresh token" )
942+ log_status = "http_error"
943+ log_status_code = 401
944+ log_error_detail = "401 Unauthorized - retrying with fresh token"
945+ try :
946+ log_response_data = response .json ()
947+ except json .JSONDecodeError :
948+ log_response_data = None
933949 self .data_source .clear_oauth2_token_cache ()
934950 return self ._make_request (endpoint , envelope , _retry_auth = False )
935951
@@ -938,6 +954,8 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
938954
939955 # Parse response
940956 response_data = response .json ()
957+ log_status_code = response .status_code
958+ log_response_data = response_data
941959
942960 _logger .info (
943961 "DCI request successful (status: %s, message_id: %s)" ,
@@ -949,18 +967,23 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
949967 return response_data
950968
951969 except httpx .HTTPStatusError as e :
970+ log_status = "http_error"
971+ log_status_code = e .response .status_code
972+
952973 # Log technical details for troubleshooting
953974 technical_detail = f"DCI request failed with status { e .response .status_code } "
954975 response_text = e .response .text
955976 try :
956977 error_data = e .response .json ()
978+ log_response_data = error_data
957979 if "header" in error_data and "status_reason_message" in error_data ["header" ]:
958980 technical_detail += f": { error_data ['header' ]['status_reason_message' ]} "
959981 else :
960982 technical_detail += f": { response_text } "
961- except Exception :
983+ except ( json . JSONDecodeError , KeyError , TypeError ) :
962984 technical_detail += f": { response_text } "
963985
986+ log_error_detail = technical_detail
964987 _logger .error (technical_detail )
965988 _logger .error ("Full response body: %s" , response_text )
966989 _logger .error ("Request envelope was: %s" , envelope )
@@ -978,26 +1001,126 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
9781001 error_str = str (e ).lower ()
9791002 if "timeout" in error_str or "timed out" in error_str :
9801003 connection_type = "timeout"
1004+ log_status = "timeout"
9811005 elif "ssl" in error_str or "certificate" in error_str :
9821006 connection_type = "ssl"
1007+ log_status = "connection_error"
9831008 elif "name or service not known" in error_str or "nodename nor servname" in error_str :
9841009 connection_type = "dns"
1010+ log_status = "connection_error"
9851011 else :
9861012 connection_type = "connection"
1013+ log_status = "connection_error"
1014+
1015+ log_error_detail = technical_detail
9871016
9881017 # Show user-friendly message
9891018 user_msg = format_connection_error (connection_type , technical_detail )
9901019 raise UserError (user_msg ) from e
9911020
9921021 except Exception as e :
1022+ log_status = "error"
1023+
9931024 # Log technical details for troubleshooting
9941025 technical_detail = f"Unexpected error during DCI request: { str (e )} "
1026+ log_error_detail = technical_detail
9951027 _logger .error (technical_detail , exc_info = True )
9961028
9971029 # Show generic user-friendly message
9981030 user_msg = _ ("An unexpected error occurred. Please contact your administrator." )
9991031 raise UserError (user_msg ) from e
10001032
1033+ finally :
1034+ duration_ms = int ((time .monotonic () - start_time ) * 1000 )
1035+ self ._log_outgoing_call (
1036+ url = url ,
1037+ endpoint = endpoint ,
1038+ envelope = envelope ,
1039+ response_data = log_response_data ,
1040+ status_code = log_status_code ,
1041+ duration_ms = duration_ms ,
1042+ status = log_status ,
1043+ error_detail = log_error_detail ,
1044+ )
1045+
1046+ # =========================================================================
1047+ # OUTGOING LOG
1048+ # =========================================================================
1049+
1050+ def _log_outgoing_call (
1051+ self ,
1052+ url : str ,
1053+ endpoint : str ,
1054+ envelope : dict ,
1055+ response_data : dict | None ,
1056+ status_code : int | None ,
1057+ duration_ms : int ,
1058+ status : str ,
1059+ error_detail : str | None ,
1060+ ):
1061+ """Log an outgoing API call to spp.api.outgoing.log (soft dependency).
1062+
1063+ Uses runtime check to avoid hard manifest dependency on spp_api_v2.
1064+ Uses a separate database cursor so log entries persist even when the
1065+ caller's transaction is rolled back (e.g., on UserError).
1066+ Logging failures are swallowed so they never block the actual request.
1067+ """
1068+ try :
1069+ if "spp.api.outgoing.log" not in self .env :
1070+ return
1071+
1072+ from odoo .addons .spp_api_v2 .services .outgoing_api_log_service import OutgoingApiLogService
1073+
1074+ # Capture values from the current env before opening a new cursor,
1075+ # since self.data_source won't be accessible from the new cursor's env.
1076+ service_code = getattr (self .data_source , "code" , None ) or "dci"
1077+ origin_record_id = self .data_source .id if hasattr (self .data_source , "id" ) else None
1078+ user_id = self .env .uid
1079+ sanitized_envelope = self ._copy_envelope_for_log (envelope )
1080+
1081+ # Use a separate cursor so log entries survive transaction rollback.
1082+ with self .env .registry .cursor () as new_cr :
1083+ new_env = self .env (cr = new_cr )
1084+ service = OutgoingApiLogService (
1085+ new_env ,
1086+ service_name = "DCI Client" ,
1087+ service_code = service_code ,
1088+ user_id = user_id ,
1089+ )
1090+
1091+ service .log_call (
1092+ url = url ,
1093+ endpoint = endpoint ,
1094+ http_method = "POST" ,
1095+ request_summary = sanitized_envelope ,
1096+ response_summary = response_data ,
1097+ response_status_code = status_code ,
1098+ duration_ms = duration_ms ,
1099+ origin_model = "spp.dci.data.source" ,
1100+ origin_record_id = origin_record_id ,
1101+ status = status ,
1102+ error_detail = error_detail ,
1103+ )
1104+ except Exception :
1105+ _logger .warning ("Failed to log outgoing API call (non-blocking)" , exc_info = True )
1106+
1107+ def _copy_envelope_for_log (self , envelope : dict ) -> dict | None :
1108+ """Copy the request envelope for audit log storage.
1109+
1110+ Returns a shallow copy suitable for JSON storage. The cryptographic
1111+ signature is preserved for auditability (non-repudiation).
1112+
1113+ Args:
1114+ envelope: Request envelope dict
1115+
1116+ Returns:
1117+ Copy of the envelope, or None if input is falsy
1118+ """
1119+ if not envelope :
1120+ return None
1121+
1122+ return dict (envelope )
1123+
10011124 # =========================================================================
10021125 # HELPER METHODS
10031126 # =========================================================================
0 commit comments