feat(tempo): per-user Tempo API token — same pattern as Jira token
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Each user can now store their own personal Tempo API token in their profile settings. Time is logged using each user's own credentials. Backend: - Migration b044: adds tempo_api_token column to users table - User model: adds tempo_api_token column - UserPreferencesUpdate: adds tempo_api_token field (write-only) - UserOut: adds tempo_api_token (excluded) + tempo_token_set bool; @model_validator derives both jira_token_set and tempo_token_set - users router: handles tempo_api_token same as jira_api_token (empty string clears it, never returned in responses) - tempo_service: refactored to per-user token; has_tempo_configured(), get_user_tempo_client(user) use user.tempo_api_token; global TEMPO_ENABLED still acts as kill-switch - system router: /system/tempo-test now uses current user's personal token (any role); removed global TEMPO_API_TOKEN dependency Frontend: - settings.ts: UserPreferencesUpdate.tempo_api_token, UserMeOut.tempo_token_set - SettingsPage ProfileSection: Tempo Integration section with password field, show/hide toggle, configured badge, and Test Tempo button — mirrors the Jira token UX exactly - JiraConfigSection: removed stale global Tempo test block Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,14 @@
|
||||
"""Tempo time-tracking integration service."""
|
||||
"""Tempo time-tracking integration service.
|
||||
|
||||
Authentication model
|
||||
--------------------
|
||||
Each user authenticates to Tempo with their own personal Tempo API token,
|
||||
stored in ``user.tempo_api_token``. This is different from the Jira API token.
|
||||
Obtain a Tempo token at: Jira → Apps → Tempo → Settings → API Integration.
|
||||
|
||||
The global ``settings.TEMPO_ENABLED`` flag acts as a kill-switch. When False,
|
||||
all Tempo calls are silently skipped regardless of whether users have tokens.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
@@ -12,22 +22,35 @@ from app.models.jira_link import JiraLink, JiraLinkEntityType
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_tempo_client():
|
||||
"""Return a Tempo API client, or raise if disabled."""
|
||||
if not settings.TEMPO_ENABLED:
|
||||
raise InvalidOperationError("Tempo integration is not enabled")
|
||||
def has_tempo_configured(user) -> bool:
|
||||
"""Return True if *user* has a personal Tempo API token stored."""
|
||||
return bool(getattr(user, "tempo_api_token", None))
|
||||
|
||||
|
||||
def get_user_tempo_client(user):
|
||||
"""Return a Tempo API v4 client authenticated as *user*.
|
||||
|
||||
Raises ``InvalidOperationError`` when the user has no token or the
|
||||
client library is not installed.
|
||||
"""
|
||||
token = getattr(user, "tempo_api_token", None)
|
||||
if not token:
|
||||
raise InvalidOperationError(
|
||||
"No Tempo API token configured. "
|
||||
"Add it in Settings → Profile → Tempo Integration."
|
||||
)
|
||||
try:
|
||||
from tempoapiclient import client_v4 as tempo_client
|
||||
|
||||
return tempo_client.Tempo(auth_token=settings.TEMPO_API_TOKEN)
|
||||
return tempo_client.Tempo(auth_token=token)
|
||||
except ImportError:
|
||||
raise InvalidOperationError(
|
||||
"tempo-api-python-client is not installed. "
|
||||
"Install it with: pip install tempo-api-python-client"
|
||||
"Run: pip install tempo-api-python-client"
|
||||
)
|
||||
|
||||
|
||||
def log_worklog(
|
||||
user,
|
||||
jira_issue_id: int,
|
||||
author_account_id: str,
|
||||
date: str,
|
||||
@@ -35,8 +58,8 @@ def log_worklog(
|
||||
description: str,
|
||||
work_type: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a worklog entry in Tempo."""
|
||||
tempo = get_tempo_client()
|
||||
"""Create a worklog entry in Tempo using *user*'s personal token."""
|
||||
tempo = get_user_tempo_client(user)
|
||||
kwargs: dict = {
|
||||
"accountId": author_account_id,
|
||||
"issueId": jira_issue_id,
|
||||
@@ -56,13 +79,24 @@ def auto_log_test_worklog(
|
||||
user,
|
||||
activity_type: str,
|
||||
) -> Optional[dict]:
|
||||
"""If the test has a Jira link, log time to Tempo automatically.
|
||||
"""If the test has a Jira link and *user* has a Tempo token, log time.
|
||||
|
||||
Returns the Tempo worklog response, or None if skipped.
|
||||
Completely non-fatal — errors are logged and swallowed.
|
||||
"""
|
||||
# Global kill-switch
|
||||
if not settings.TEMPO_ENABLED:
|
||||
return None
|
||||
|
||||
# Per-user token required
|
||||
if not has_tempo_configured(user):
|
||||
logger.debug(
|
||||
"User %s has no Tempo token; skipping worklog for test %s",
|
||||
getattr(user, "username", user), test.id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Need a Jira link with a numeric issue ID
|
||||
link = (
|
||||
db.query(JiraLink)
|
||||
.filter(
|
||||
@@ -76,24 +110,37 @@ 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 ""
|
||||
if not jira_account_id:
|
||||
logger.debug(
|
||||
"User %s has no jira_account_id; skipping Tempo worklog",
|
||||
getattr(user, "username", user),
|
||||
)
|
||||
return None
|
||||
|
||||
duration = _calculate_duration(test, activity_type)
|
||||
if duration <= 0:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = log_worklog(
|
||||
user=user,
|
||||
jira_issue_id=int(link.jira_issue_id),
|
||||
author_account_id=getattr(user, "jira_account_id", "") or "",
|
||||
date=(getattr(test, "updated_at", None) or test.created_at).strftime(
|
||||
"%Y-%m-%d",
|
||||
),
|
||||
author_account_id=jira_account_id,
|
||||
date=(getattr(test, "updated_at", None) or test.created_at).strftime("%Y-%m-%d"),
|
||||
time_spent_seconds=duration,
|
||||
description=f"[Aegis] {activity_type}: {test.name}",
|
||||
)
|
||||
logger.info("Tempo worklog created for test %s, %ds", test.id, duration)
|
||||
logger.info(
|
||||
"Tempo worklog created for test %s by user %s, %ds",
|
||||
test.id, getattr(user, "username", user), duration,
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning("Tempo worklog failed for test %s: %s", test.id, e, exc_info=True)
|
||||
logger.warning(
|
||||
"Tempo worklog failed for test %s (user %s): %s",
|
||||
test.id, getattr(user, "username", user), e, exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user