from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple, Union import requests from redminelib import Redmine from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError 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." ) if isinstance(exc, ResourceNotFoundError): return "Requested Redmine resource not found: check user/project identifiers." # 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 _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]: """Загружает справочник типов активности time entries. Возвращает словарь id -> name. Если справочник недоступен, возвращает пустой словарь — тогда будем использовать данные из самих entries. """ try: activities = redmine.enumeration.filter(resource="time_entry_activities") return {int(a.id): str(a.name) for a in activities} except Exception: return {} def _get_activity_name(entry, activities: Dict[int, str]) -> str: """Определяет название активности для time entry.""" activity = getattr(entry, "activity", None) if activity is None: return "" # activity может быть объектом с id/name или просто значением activity_id = getattr(activity, "id", None) if activity_id is not None: name = activities.get(int(activity_id)) if name: return name activity_name = getattr(activity, "name", None) if activity_name: return str(activity_name) return str(activity_id) return str(activity) def _parse_datetime(value: Any) -> Optional[datetime]: """Parse a datetime from Redmine API response. Accepts datetime objects, ISO strings (with or without timezone), or None. Returns a timezone-aware datetime or None. Naive datetimes are treated as UTC (#58). """ if value is None: return None if isinstance(value, datetime): return _ensure_aware_utc(value) if isinstance(value, str): try: dt = datetime.fromisoformat(value.replace("Z", "+00:00")) return _ensure_aware_utc(dt) except (ValueError, TypeError): return None return None def _ensure_aware_utc(dt: datetime) -> datetime: """Возвращает aware datetime; naive трактуется как UTC (#58).""" if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt 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 _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int: """Преобразует строковый идентификатор пользователя в числовой ID. Если аргумент — число, возвращает его как есть. Если строка, пытается найти пользователя по логину или имени. """ if isinstance(user_arg, int): return user_arg text = str(user_arg).strip() if not text: raise RedmineAPIError("User identifier cannot be empty.") # Сначала пробуем интерпретировать как числовой ID if text.isdigit(): return int(text) # Затем ищем по логину try: users = redmine.user.filter(login=text) # Фильтр Redmine по логину неточный (substring-поиск), поэтому # выбираем только точные регистрозависимые совпадения логина (#60). exact_matches = [u for u in users if getattr(u, "login", None) == text] if len(exact_matches) == 1: return int(exact_matches[0].id) if len(exact_matches) > 1: matches = ", ".join(str(u.id) for u in exact_matches[:5]) raise RedmineAPIError( f"Multiple users match '{text}': {matches}. Use --user-id with numeric ID." ) except RedmineAPIError: raise except Exception as exc: raise RedmineAPIError( f"Cannot resolve user login '{text}': {_format_redmine_error(exc)}", original=exc, ) from exc # Потом по имени try: users = redmine.user.filter(name=text) if len(users) == 1: return int(users[0].id) if len(users) > 1: matches = ", ".join(str(getattr(u, "login", u.id)) for u in users[:5]) raise RedmineAPIError( f"Multiple users match '{text}': {matches}. Use --user-id with numeric ID." ) except RedmineAPIError: raise except Exception as exc: raise RedmineAPIError( f"Cannot resolve user name '{text}': {_format_redmine_error(exc)}", original=exc, ) from exc raise RedmineAPIError( f"User '{text}' not found. Check the login/name or use --user-id with numeric Redmine ID." ) def _get_current_user_id(redmine: Redmine) -> int: """Возвращает ID текущего пользователя.""" try: current_user = redmine.user.get("current") return int(current_user.id) except Exception as exc: raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc def fetch_issues_with_spent_time( from_date: str, to_date: str, user_id: Optional[Union[int, str]] = None, by_activity: bool = False, dedup_before: Optional[datetime] = None, ) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]: """ Fetch unique issues linked to time entries of the given user in date range, along with total spent hours per issue. If user_id is None, uses current user. If by_activity is True, returns per-activity breakdown as third tuple element. If dedup_before is set, filters out time entries whose created_on AND updated_on are both before dedup_before (AND logic: both must be < cutoff to exclude). Returns list of (issue, total_hours, activities) tuples. Raises RedmineAPIError on API/auth/network failures. """ try: redmine = _create_redmine() target_user_id = ( _resolve_user_id(redmine, user_id) if user_id is not None else _get_current_user_id(redmine) ) activities_lookup = _load_time_entry_activities(redmine) if by_activity else {} time_entries = list( redmine.time_entry.filter( user_id=target_user_id, from_date=from_date, to_date=to_date ) ) except RedmineAPIError: raise except Exception as exc: raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc # Дедупликация: отсекаем записи, которые были учтены в предыдущем отчёте. # Запись исключается, если BOTH created_on AND updated_on < dedup_before. # Записи без метаданных (created_on/updated_on == None) не фильтруются. if dedup_before is not None: # Нормализуем cutoff к aware UTC (#58): naive cutoff трактуем как UTC, # чтобы сравнение с нормализованными created_on/updated_on было корректным. dedup_before = _ensure_aware_utc(dedup_before) filtered: list = [] try: for entry in time_entries: created = _parse_datetime(getattr(entry, "created_on", None)) updated = _parse_datetime(getattr(entry, "updated_on", None)) if created is None and updated is None: filtered.append(entry) elif created is not None and updated is not None: if created >= dedup_before and updated >= dedup_before: filtered.append(entry) elif created is not None and created >= dedup_before: filtered.append(entry) elif updated is not None and updated >= dedup_before: filtered.append(entry) except TypeError as exc: raise RedmineAPIError( f"Failed to compare time entry dates with deduplication cutoff: {exc}", original=exc, ) from exc time_entries = filtered # Агрегируем часы по issue.id (и активности, если требуется) spent_time: Dict[int, float] = {} spent_by_activity: Dict[int, Dict[str, float]] = {} issue_ids = set() for entry in time_entries: if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"): iid = entry.issue.id hours = float(entry.hours) issue_ids.add(iid) spent_time[iid] = spent_time.get(iid, 0.0) + hours if by_activity: activity_name = _get_activity_name(entry, activities_lookup) by_act = spent_by_activity.setdefault(iid, {}) by_act[activity_name] = by_act.get(activity_name, 0.0) + 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: iid = issue.id if iid not in spent_time: continue total_hours = spent_time[iid] activity_breakdown = spent_by_activity.get(iid) if by_activity else None result.append((issue, total_hours, activity_breakdown)) return result