feat(user): report on another user's time entries

- Add user_id parameter to fetch_issues_with_spent_time().
- Support numeric ID, login, or full name resolution.
- Reject ambiguous names and unknown users with clear messages.
- Add CLI flags: --user-id, --user-login, --user-name.
- Only allow one user flag at a time.
- Add ResourceNotFoundError handling.
- Update README with usage examples.
- Add tests for user resolution and CLI flags.

Closes #40
This commit is contained in:
Кокос Артем Николаевич
2026-06-29 15:15:17 +07:00
parent 67b5d093d9
commit f6861382e6
7 changed files with 259 additions and 11 deletions

View File

@@ -1,8 +1,8 @@
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple, Union
import requests
from redminelib import Redmine
from redminelib.exceptions import AuthError, ForbiddenError
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
from redminelib.resources import Issue
from urllib3.util.retry import Retry
@@ -78,6 +78,8 @@ def _format_redmine_error(exc: Exception) -> str:
"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)
@@ -120,22 +122,91 @@ def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]
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)
if users:
return int(users[0].id)
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
from_date: str,
to_date: str,
user_id: Optional[Union[int, str]] = None,
) -> Optional[List[Tuple[Issue, float]]]:
"""
Fetch unique issues linked to time entries of the current user in given date range,
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.
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
target_user_id = (
_resolve_user_id(redmine, user_id)
if user_id is not None
else _get_current_user_id(redmine)
)
time_entries = 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