11"""DCI Client Service for making signed API requests."""
22
33import logging
4+ import time
45import uuid
56from datetime import UTC , datetime
67from typing import Any
@@ -904,6 +905,13 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
904905 # Get headers from data source (includes auth)
905906 headers = self .data_source .get_headers ()
906907
908+ # Track timing and result for outgoing log
909+ start_time = time .monotonic ()
910+ log_status = "success"
911+ log_status_code = None
912+ log_response_data = None
913+ log_error_detail = None
914+
907915 try :
908916 _logger .info (
909917 "Making DCI request to %s (action: %s)" ,
@@ -930,6 +938,13 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
930938 # Handle 401 Unauthorized - try refreshing token once
931939 if response .status_code == 401 and _retry_auth and self .data_source .auth_type == "oauth2" :
932940 _logger .warning ("Got 401 Unauthorized, clearing OAuth2 token cache and retrying with fresh token" )
941+ log_status = "http_error"
942+ log_status_code = 401
943+ log_error_detail = "401 Unauthorized - retrying with fresh token"
944+ try :
945+ log_response_data = response .json ()
946+ except Exception :
947+ log_response_data = None
933948 self .data_source .clear_oauth2_token_cache ()
934949 return self ._make_request (endpoint , envelope , _retry_auth = False )
935950
@@ -938,6 +953,8 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
938953
939954 # Parse response
940955 response_data = response .json ()
956+ log_status_code = response .status_code
957+ log_response_data = response_data
941958
942959 _logger .info (
943960 "DCI request successful (status: %s, message_id: %s)" ,
@@ -949,18 +966,23 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
949966 return response_data
950967
951968 except httpx .HTTPStatusError as e :
969+ log_status = "http_error"
970+ log_status_code = e .response .status_code
971+
952972 # Log technical details for troubleshooting
953973 technical_detail = f"DCI request failed with status { e .response .status_code } "
954974 response_text = e .response .text
955975 try :
956976 error_data = e .response .json ()
977+ log_response_data = error_data
957978 if "header" in error_data and "status_reason_message" in error_data ["header" ]:
958979 technical_detail += f": { error_data ['header' ]['status_reason_message' ]} "
959980 else :
960981 technical_detail += f": { response_text } "
961982 except Exception :
962983 technical_detail += f": { response_text } "
963984
985+ log_error_detail = technical_detail
964986 _logger .error (technical_detail )
965987 _logger .error ("Full response body: %s" , response_text )
966988 _logger .error ("Request envelope was: %s" , envelope )
@@ -978,26 +1000,126 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
9781000 error_str = str (e ).lower ()
9791001 if "timeout" in error_str or "timed out" in error_str :
9801002 connection_type = "timeout"
1003+ log_status = "timeout"
9811004 elif "ssl" in error_str or "certificate" in error_str :
9821005 connection_type = "ssl"
1006+ log_status = "connection_error"
9831007 elif "name or service not known" in error_str or "nodename nor servname" in error_str :
9841008 connection_type = "dns"
1009+ log_status = "connection_error"
9851010 else :
9861011 connection_type = "connection"
1012+ log_status = "connection_error"
1013+
1014+ log_error_detail = technical_detail
9871015
9881016 # Show user-friendly message
9891017 user_msg = format_connection_error (connection_type , technical_detail )
9901018 raise UserError (user_msg ) from e
9911019
9921020 except Exception as e :
1021+ log_status = "error"
1022+
9931023 # Log technical details for troubleshooting
9941024 technical_detail = f"Unexpected error during DCI request: { str (e )} "
1025+ log_error_detail = technical_detail
9951026 _logger .error (technical_detail , exc_info = True )
9961027
9971028 # Show generic user-friendly message
9981029 user_msg = _ ("An unexpected error occurred. Please contact your administrator." )
9991030 raise UserError (user_msg ) from e
10001031
1032+ finally :
1033+ duration_ms = int ((time .monotonic () - start_time ) * 1000 )
1034+ self ._log_outgoing_call (
1035+ url = url ,
1036+ endpoint = endpoint ,
1037+ envelope = envelope ,
1038+ response_data = log_response_data ,
1039+ status_code = log_status_code ,
1040+ duration_ms = duration_ms ,
1041+ status = log_status ,
1042+ error_detail = log_error_detail ,
1043+ )
1044+
1045+ # =========================================================================
1046+ # OUTGOING LOG
1047+ # =========================================================================
1048+
1049+ def _log_outgoing_call (
1050+ self ,
1051+ url : str ,
1052+ endpoint : str ,
1053+ envelope : dict ,
1054+ response_data : dict | None ,
1055+ status_code : int | None ,
1056+ duration_ms : int ,
1057+ status : str ,
1058+ error_detail : str | None ,
1059+ ):
1060+ """Log an outgoing API call to spp.api.outgoing.log (soft dependency).
1061+
1062+ Uses runtime check to avoid hard manifest dependency on spp_api_v2.
1063+ Uses a separate database cursor so log entries persist even when the
1064+ caller's transaction is rolled back (e.g., on UserError).
1065+ Logging failures are swallowed so they never block the actual request.
1066+ """
1067+ try :
1068+ if "spp.api.outgoing.log" not in self .env :
1069+ return
1070+
1071+ from odoo .addons .spp_api_v2 .services .outgoing_api_log_service import OutgoingApiLogService
1072+
1073+ # Capture values from the current env before opening a new cursor,
1074+ # since self.data_source won't be accessible from the new cursor's env.
1075+ service_code = getattr (self .data_source , "code" , None ) or "dci"
1076+ origin_record_id = self .data_source .id if hasattr (self .data_source , "id" ) else None
1077+ user_id = self .env .uid
1078+ sanitized_envelope = self ._copy_envelope_for_log (envelope )
1079+
1080+ # Use a separate cursor so log entries survive transaction rollback.
1081+ with self .env .registry .cursor () as new_cr :
1082+ new_env = self .env (cr = new_cr )
1083+ service = OutgoingApiLogService (
1084+ new_env ,
1085+ service_name = "DCI Client" ,
1086+ service_code = service_code ,
1087+ user_id = user_id ,
1088+ )
1089+
1090+ service .log_call (
1091+ url = url ,
1092+ endpoint = endpoint ,
1093+ http_method = "POST" ,
1094+ request_summary = sanitized_envelope ,
1095+ response_summary = response_data ,
1096+ response_status_code = status_code ,
1097+ duration_ms = duration_ms ,
1098+ origin_model = "spp.dci.data.source" ,
1099+ origin_record_id = origin_record_id ,
1100+ status = status ,
1101+ error_detail = error_detail ,
1102+ )
1103+ except Exception :
1104+ _logger .warning ("Failed to log outgoing API call (non-blocking)" , exc_info = True )
1105+
1106+ def _copy_envelope_for_log (self , envelope : dict ) -> dict | None :
1107+ """Copy the request envelope for audit log storage.
1108+
1109+ Returns a shallow copy suitable for JSON storage. The cryptographic
1110+ signature is preserved for auditability (non-repudiation).
1111+
1112+ Args:
1113+ envelope: Request envelope dict
1114+
1115+ Returns:
1116+ Copy of the envelope, or None if input is falsy
1117+ """
1118+ if not envelope :
1119+ return None
1120+
1121+ return dict (envelope )
1122+
10011123 # =========================================================================
10021124 # HELPER METHODS
10031125 # =========================================================================
0 commit comments