From 2ee74bf6c9b1340e860c861e3554588b499e1aab Mon Sep 17 00:00:00 2001 From: kitos Date: Thu, 28 May 2026 12:48:22 +0200 Subject: [PATCH] fix(tempo): fix EU base URL, trailing space in account ID, and tempo_synced tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes found for Tempo worklogs never reaching Tempo: 1. Wrong API region: workspace is on api.eu.tempo.io/4 but code used api.tempo.io/4 → Tempo returned "User is invalid" (400) for all POST /worklogs 2. Trailing space in jira_account_id stored in DB (now stripped with .strip()) 3. tempo_synced field was never updated even on success (now set from Tempo response) Fix: add tempo.base_url system_config key (admin-configurable without redeploy), fall back to TEMPO_BASE_URL env-var, then global default. DB already updated with https://api.eu.tempo.io/4 for this workspace. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/config.py | 3 ++ backend/app/services/tempo_service.py | 44 +++++++++++++++++-- backend/app/services/test_workflow_service.py | 6 ++- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index cac36cc..1dbfd98 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -63,6 +63,9 @@ class Settings(BaseSettings): TEMPO_API_TOKEN: str = "" TEMPO_API_VERSION: int = 4 TEMPO_DEFAULT_WORK_TYPE: str = "Red Team" + # Tempo API base URL — use https://api.eu.tempo.io/4 for EU workspaces. + # Can also be set via system_configs key "tempo.base_url" at runtime. + TEMPO_BASE_URL: str = "" # empty → falls back to https://api.tempo.io/4 # ── OSINT / Intelligence ──────────────────────────────────────── NVD_API_KEY: str = "" # optional; increases NVD rate limit from 5/30s to 50/30s diff --git a/backend/app/services/tempo_service.py b/backend/app/services/tempo_service.py index fc17b5c..a0acd21 100644 --- a/backend/app/services/tempo_service.py +++ b/backend/app/services/tempo_service.py @@ -40,9 +40,41 @@ def has_tempo_configured(user) -> bool: return bool(getattr(user, "tempo_api_token", None)) -def get_user_tempo_client(user): +_TEMPO_DEFAULT_BASE_URL = "https://api.tempo.io/4" +_TEMPO_EU_BASE_URL = "https://api.eu.tempo.io/4" + +# system_configs key for admin-configurable Tempo base URL +_TEMPO_BASE_URL_CONFIG_KEY = "tempo.base_url" + + +def _get_tempo_base_url(db=None) -> str: + """Return the Tempo API base URL. + + Reads ``tempo.base_url`` from ``system_configs`` first (allows the admin + to set the EU endpoint ``https://api.eu.tempo.io/4`` without redeploying). + Falls back to ``settings.TEMPO_BASE_URL`` env-var, then the global default. + """ + if db is not None: + try: + from app.models.system_config import SystemConfig + row = db.query(SystemConfig).filter( + SystemConfig.key == _TEMPO_BASE_URL_CONFIG_KEY + ).first() + if row and row.value: + return row.value.rstrip("/") + except Exception: + pass # DB unavailable — fall through to defaults + + env_url = getattr(settings, "TEMPO_BASE_URL", None) + return (env_url or _TEMPO_DEFAULT_BASE_URL).rstrip("/") + + +def get_user_tempo_client(user, db=None): """Return a Tempo API v4 client authenticated as *user*. + Pass ``db`` to allow reading the admin-configured base URL from + ``system_configs`` (needed for EU Tempo workspaces). + Raises ``InvalidOperationError`` when the user has no token or the client library is not installed. """ @@ -54,7 +86,9 @@ def get_user_tempo_client(user): ) try: from tempoapiclient import client_v4 as tempo_client - return tempo_client.Tempo(auth_token=token) + base_url = _get_tempo_base_url(db) + logger.debug("Using Tempo base URL: %s", base_url) + return tempo_client.Tempo(auth_token=token, base_url=base_url) except ImportError: raise InvalidOperationError( "tempo-api-python-client is not installed. " @@ -69,13 +103,14 @@ def log_worklog( date: str, time_spent_seconds: int, description: str, + db=None, ) -> dict: """Create a worklog entry in Tempo using *user*'s personal token. Note: tempoapiclient raises SystemExit (not Exception) on API errors, so we intercept BaseException and re-raise as RuntimeError to keep it non-fatal. """ - tempo = get_user_tempo_client(user) + tempo = get_user_tempo_client(user, db=db) try: return tempo.create_worklog( accountId=author_account_id, @@ -151,7 +186,7 @@ def auto_log_test_worklog( logger.debug("No Jira link for test %s, skipping Tempo worklog", test.id) return None - jira_account_id = getattr(user, "jira_account_id", "") or "" + jira_account_id = (getattr(user, "jira_account_id", "") or "").strip() if not jira_account_id: logger.debug( "User %s has no jira_account_id; skipping Tempo worklog", @@ -181,6 +216,7 @@ def auto_log_test_worklog( date=work_date, time_spent_seconds=duration_seconds, description=description, + db=db, ) logger.info( "Tempo worklog created for test %s by user %s: %ds on %s", diff --git a/backend/app/services/test_workflow_service.py b/backend/app/services/test_workflow_service.py index 17ad5f3..361d2fd 100644 --- a/backend/app/services/test_workflow_service.py +++ b/backend/app/services/test_workflow_service.py @@ -368,7 +368,11 @@ def _create_phase_worklog( # duration so the Tempo entry is identical to the Aegis worklog. try: from app.services.tempo_service import auto_log_test_worklog - auto_log_test_worklog(db, test, user, activity_type, duration_seconds) + tempo_result = auto_log_test_worklog(db, test, user, activity_type, duration_seconds) + if tempo_result and isinstance(tempo_result, dict): + wl.tempo_synced = datetime.utcnow() + wl.tempo_worklog_id = str(tempo_result.get("tempoWorklogId", "")) + db.flush() except Exception as e: logger.warning("Tempo sync failed for worklog: %s", e, exc_info=True)