|
| 1 | +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. |
| 2 | +"""Audit log for outgoing API calls to external services.""" |
| 3 | + |
| 4 | +import logging |
| 5 | + |
| 6 | +from odoo import api, fields, models |
| 7 | + |
| 8 | +_logger = logging.getLogger(__name__) |
| 9 | + |
| 10 | + |
| 11 | +class ApiOutgoingLog(models.Model): |
| 12 | + """ |
| 13 | + Audit log for outgoing HTTP calls to external services. |
| 14 | +
|
| 15 | + Captures all outgoing API requests (DCI, webhooks, etc.) with |
| 16 | + request/response details for troubleshooting and compliance. |
| 17 | + """ |
| 18 | + |
| 19 | + _name = "spp.api.outgoing.log" |
| 20 | + _description = "Outgoing API Log" |
| 21 | + _order = "timestamp desc" |
| 22 | + _rec_name = "display_name" |
| 23 | + |
| 24 | + # ========================================== |
| 25 | + # Request - WHAT was sent |
| 26 | + # ========================================== |
| 27 | + url = fields.Char( |
| 28 | + required=True, |
| 29 | + index=True, |
| 30 | + groups="spp_api_v2.group_api_v2_auditor", |
| 31 | + help="Full URL called (may contain sensitive query parameters)", |
| 32 | + ) |
| 33 | + |
| 34 | + endpoint = fields.Char( |
| 35 | + index=True, |
| 36 | + help="Path portion of the URL, e.g. /registry/sync/search", |
| 37 | + ) |
| 38 | + |
| 39 | + http_method = fields.Selection( |
| 40 | + [ |
| 41 | + ("POST", "POST"), |
| 42 | + ("GET", "GET"), |
| 43 | + ("PUT", "PUT"), |
| 44 | + ("PATCH", "PATCH"), |
| 45 | + ("DELETE", "DELETE"), |
| 46 | + ], |
| 47 | + default="POST", |
| 48 | + required=True, |
| 49 | + ) |
| 50 | + |
| 51 | + request_summary = fields.Json( |
| 52 | + groups="spp_api_v2.group_api_v2_auditor", |
| 53 | + help="Request payload (secrets redacted)", |
| 54 | + ) |
| 55 | + |
| 56 | + # ========================================== |
| 57 | + # Response - WHAT came back |
| 58 | + # ========================================== |
| 59 | + response_summary = fields.Json( |
| 60 | + groups="spp_api_v2.group_api_v2_auditor", |
| 61 | + help="Response payload", |
| 62 | + ) |
| 63 | + |
| 64 | + response_status_code = fields.Integer( |
| 65 | + index=True, |
| 66 | + ) |
| 67 | + |
| 68 | + # ========================================== |
| 69 | + # Context - WHO triggered it |
| 70 | + # ========================================== |
| 71 | + user_id = fields.Many2one( |
| 72 | + "res.users", |
| 73 | + default=lambda self: self.env.uid, |
| 74 | + index=True, |
| 75 | + ) |
| 76 | + |
| 77 | + origin_model = fields.Char( |
| 78 | + index=True, |
| 79 | + help="Model that triggered the call, e.g. spp.dci.data.source", |
| 80 | + ) |
| 81 | + |
| 82 | + origin_record_id = fields.Integer( |
| 83 | + help="Record ID that triggered the call", |
| 84 | + ) |
| 85 | + |
| 86 | + # ========================================== |
| 87 | + # Timestamps & Performance |
| 88 | + # ========================================== |
| 89 | + timestamp = fields.Datetime( |
| 90 | + required=True, |
| 91 | + default=fields.Datetime.now, |
| 92 | + index=True, |
| 93 | + ) |
| 94 | + |
| 95 | + duration_ms = fields.Integer( |
| 96 | + help="Request duration in milliseconds", |
| 97 | + ) |
| 98 | + |
| 99 | + # ========================================== |
| 100 | + # Service Context |
| 101 | + # ========================================== |
| 102 | + service_name = fields.Char( |
| 103 | + index=True, |
| 104 | + help="Human-readable service name, e.g. DCI Client", |
| 105 | + ) |
| 106 | + |
| 107 | + service_code = fields.Char( |
| 108 | + index=True, |
| 109 | + help="Machine-readable service code, e.g. crvs_main", |
| 110 | + ) |
| 111 | + |
| 112 | + # ========================================== |
| 113 | + # Result |
| 114 | + # ========================================== |
| 115 | + status = fields.Selection( |
| 116 | + [ |
| 117 | + ("success", "Success"), |
| 118 | + ("http_error", "HTTP Error"), |
| 119 | + ("connection_error", "Connection Error"), |
| 120 | + ("timeout", "Timeout"), |
| 121 | + ("error", "Error"), |
| 122 | + ], |
| 123 | + default="success", |
| 124 | + required=True, |
| 125 | + index=True, |
| 126 | + ) |
| 127 | + |
| 128 | + error_detail = fields.Text( |
| 129 | + groups="spp_api_v2.group_api_v2_auditor", |
| 130 | + help="Error message or traceback", |
| 131 | + ) |
| 132 | + |
| 133 | + # ========================================== |
| 134 | + # Computed fields |
| 135 | + # ========================================== |
| 136 | + display_name = fields.Char( |
| 137 | + compute="_compute_display_name", |
| 138 | + store=True, |
| 139 | + ) |
| 140 | + |
| 141 | + @api.depends("http_method", "endpoint", "url", "timestamp") |
| 142 | + def _compute_display_name(self): |
| 143 | + for record in self: |
| 144 | + timestamp_str = record.timestamp.strftime("%Y-%m-%d %H:%M") if record.timestamp else "" |
| 145 | + path = record.endpoint or record.url or "API Call" |
| 146 | + record.display_name = f"{record.http_method} {path} @ {timestamp_str}" |
| 147 | + |
| 148 | + # ========================================== |
| 149 | + # API Methods |
| 150 | + # ========================================== |
| 151 | + @api.model |
| 152 | + def log_call( |
| 153 | + self, |
| 154 | + url: str, |
| 155 | + http_method: str = "POST", |
| 156 | + endpoint: str = None, |
| 157 | + request_summary: dict = None, |
| 158 | + response_summary: dict = None, |
| 159 | + response_status_code: int = None, |
| 160 | + user_id: int = None, |
| 161 | + origin_model: str = None, |
| 162 | + origin_record_id: int = None, |
| 163 | + duration_ms: int = None, |
| 164 | + service_name: str = None, |
| 165 | + service_code: str = None, |
| 166 | + status: str = "success", |
| 167 | + error_detail: str = None, |
| 168 | + ): |
| 169 | + """ |
| 170 | + Log an outgoing API call. |
| 171 | +
|
| 172 | + Args: |
| 173 | + url: Full URL called |
| 174 | + http_method: HTTP method (POST, GET, PUT, PATCH, DELETE) |
| 175 | + endpoint: Path portion of the URL |
| 176 | + request_summary: Request payload (secrets redacted) |
| 177 | + response_summary: Response payload |
| 178 | + response_status_code: HTTP response status code |
| 179 | + user_id: User who triggered the call |
| 180 | + origin_model: Odoo model that triggered the call |
| 181 | + origin_record_id: Record ID that triggered the call |
| 182 | + duration_ms: Request duration in milliseconds |
| 183 | + service_name: Human-readable service name |
| 184 | + service_code: Machine-readable service code |
| 185 | + status: Result status |
| 186 | + error_detail: Error message or traceback |
| 187 | +
|
| 188 | + Returns: |
| 189 | + Created spp.api.outgoing.log record |
| 190 | + """ |
| 191 | + vals = { |
| 192 | + "url": url, |
| 193 | + "http_method": http_method, |
| 194 | + "status": status, |
| 195 | + "timestamp": fields.Datetime.now(), |
| 196 | + } |
| 197 | + |
| 198 | + # Optional fields |
| 199 | + if endpoint: |
| 200 | + vals["endpoint"] = endpoint |
| 201 | + if request_summary is not None: |
| 202 | + vals["request_summary"] = request_summary |
| 203 | + if response_summary is not None: |
| 204 | + vals["response_summary"] = response_summary |
| 205 | + if response_status_code is not None: |
| 206 | + vals["response_status_code"] = response_status_code |
| 207 | + if user_id is not None: |
| 208 | + vals["user_id"] = user_id |
| 209 | + if origin_model: |
| 210 | + vals["origin_model"] = origin_model |
| 211 | + if origin_record_id is not None: |
| 212 | + vals["origin_record_id"] = origin_record_id |
| 213 | + if duration_ms is not None: |
| 214 | + vals["duration_ms"] = duration_ms |
| 215 | + if service_name: |
| 216 | + vals["service_name"] = service_name |
| 217 | + if service_code: |
| 218 | + vals["service_code"] = service_code |
| 219 | + if error_detail: |
| 220 | + vals["error_detail"] = error_detail |
| 221 | + |
| 222 | + return self.create(vals) |
0 commit comments