from typing import Any, Dict, List, Optional, Tuple import requests from redminelib import Redmine from redminelib.exceptions import AuthError, ForbiddenError from redminelib.resources import Issue from urllib3.util.retry import Retry from .config import Config # Таймаут на один HTTP-запрос к Redmine (секунды). REQUEST_TIMEOUT = 30 # Размер чанка для запроса задач по issue_id, чтобы не превышать лимит длины URL (#21). ISSUE_ID_CHUNK_SIZE = 100 class RedmineAPIError(Exception): """Пользовательское исключение с понятным сообщением об ошибке Redmine API.""" def __init__(self, message: str, original: Optional[Exception] = None): super().__init__(message) self.message = message self.original = original def _get_redmine_auth_kwargs() -> Dict[str, Any]: """Return Redmine auth kwargs. API key has priority over legacy password auth.""" api_key = Config.get_redmine_api_key() if api_key: return {"key": api_key} return { "username": Config.get_redmine_user(), "password": Config.get_redmine_password(), } def _make_retry_adapter() -> requests.adapters.HTTPAdapter: """Создаёт HTTPAdapter с retry для временных ошибок (#24).""" retry = Retry( total=3, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET", "HEAD", "OPTIONS"], ) return requests.adapters.HTTPAdapter(max_retries=retry) def _create_redmine() -> Redmine: """Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24).""" redmine = Redmine( Config.get_redmine_url(), **_get_redmine_auth_kwargs(), requests={ "verify": Config.get_redmine_verify(), "timeout": REQUEST_TIMEOUT, }, ) # Монтируем retry-адаптер на сессию для автоматических повторов. # В python-redmine сессия живёт в engine, а redmine.session — контекстный менеджер. retry_adapter = _make_retry_adapter() redmine.engine.session.mount("https://", retry_adapter) redmine.engine.session.mount("http://", retry_adapter) return redmine def _format_redmine_error(exc: Exception) -> str: """Преобразует исключение Redmine/requests в понятное сообщение.""" if isinstance(exc, AuthError): return ( "Authentication failed: invalid API key, login or password. " "Check REDMINE_API_KEY / REDMINE_USER / REDMINE_PASSWORD." ) if isinstance(exc, ForbiddenError): return ( "Access denied: your Redmine account does not have permission " "to read time entries or issues." ) # requests HTTPError может быть обёрнуто в python-redmine original = getattr(exc, "response", None) if original is None: original = exc response = getattr(original, "response", None) if response is not None and hasattr(response, "status_code"): status = response.status_code if status == 401: return "Authentication failed (HTTP 401): check your API key or login/password." if status == 403: return "Access denied (HTTP 403): insufficient Redmine permissions." if status == 404: return "Redmine endpoint not found (HTTP 404): check REDMINE_URL." if status == 429: return "Too many requests (HTTP 429): Redmine rate limit exceeded." if 500 <= status < 600: return f"Redmine server error (HTTP {status}): try again later." return f"Redmine API returned HTTP {status}." if isinstance(exc, requests.exceptions.Timeout): return f"Redmine request timed out after {REQUEST_TIMEOUT} seconds." if isinstance(exc, requests.exceptions.ConnectionError): return "Cannot connect to Redmine: check the URL and network." if isinstance(exc, requests.exceptions.RequestException): return f"Network error while calling Redmine: {exc}" return str(exc) def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]: """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" all_issues: List[Issue] = [] for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE): chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE] issue_list_str = ",".join(str(x) for x in chunk) issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc") all_issues.extend(issues) return all_issues def fetch_issues_with_spent_time( from_date: str, to_date: str ) -> Optional[List[Tuple[Issue, float]]]: """ Fetch unique issues linked to time entries of the current user in given date range, along with total spent hours per issue. Returns list of (issue, total_hours) tuples. Raises RedmineAPIError on API/auth/network failures. """ try: redmine = _create_redmine() current_user = redmine.user.get("current") time_entries = redmine.time_entry.filter( user_id=current_user.id, from_date=from_date, to_date=to_date ) except Exception as exc: raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc # Агрегируем часы по issue.id spent_time: Dict[int, float] = {} issue_ids = set() for entry in time_entries: if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"): iid = entry.issue.id issue_ids.add(iid) spent_time[iid] = spent_time.get(iid, 0.0) + float(entry.hours) if not issue_ids: return None # Загружаем полные объекты задач чанками (#21) try: sorted_ids = sorted(issue_ids) issues = _fetch_issues_chunked(redmine, sorted_ids) except Exception as exc: raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc # Сопоставляем задачи с суммарным временем. # Сортировка выполняется в report_builder.build_grouped_report, # здесь оставляем порядок API как есть. result = [] for issue in issues: total_hours = spent_time.get(issue.id, 0.0) result.append((issue, total_hours)) return result