Files
Aegis/backend/app/services/tempo_service.py
kitos 69d92f500a
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
feat(tempo): per-user Tempo API token — same pattern as Jira token
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>
2026-05-27 10:46:38 +02:00

171 lines
5.3 KiB
Python

"""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
from sqlalchemy.orm import Session
from app.config import settings
from app.domain.exceptions import InvalidOperationError
from app.models.jira_link import JiraLink, JiraLinkEntityType
logger = logging.getLogger(__name__)
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=token)
except ImportError:
raise InvalidOperationError(
"tempo-api-python-client is not installed. "
"Run: pip install tempo-api-python-client"
)
def log_worklog(
user,
jira_issue_id: int,
author_account_id: str,
date: str,
time_spent_seconds: int,
description: str,
work_type: str | None = None,
) -> dict:
"""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,
"dateFrom": date,
"timeSpentSeconds": time_spent_seconds,
"description": description,
}
wt = work_type or settings.TEMPO_DEFAULT_WORK_TYPE
if wt:
kwargs["workType"] = wt
return tempo.create_worklog(**kwargs)
def auto_log_test_worklog(
db: Session,
test,
user,
activity_type: str,
) -> Optional[dict]:
"""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(
JiraLink.entity_id == test.id,
JiraLink.entity_type == JiraLinkEntityType.test,
)
.first()
)
if not link or not link.jira_issue_id:
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=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 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 (user %s): %s",
test.id, getattr(user, "username", user), e, exc_info=True,
)
return None
def _calculate_duration(test, activity_type: str) -> int:
"""Calculate real duration in seconds from the phase timing fields.
Uses the actual start/end timestamps recorded by the workflow buttons,
so the data cannot be falsified.
"""
from datetime import datetime
now = datetime.utcnow()
if activity_type == "red_team_execution" and test.red_started_at:
delta = now - test.red_started_at
return max(int(delta.total_seconds()), 1)
if activity_type == "blue_team_evaluation" and test.blue_started_at:
delta = now - test.blue_started_at
return max(int(delta.total_seconds()), 1)
# Fallback for legacy activity types
if activity_type == "execution" and test.execution_date and test.created_at:
delta = test.execution_date - test.created_at
return max(int(delta.total_seconds()), 0)
return 0