From f6861382e6afd0fa9b7d22ffe64417bef0b6794d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Mon, 29 Jun 2026 15:15:17 +0700 Subject: [PATCH] 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 --- README.md | 12 +++- pyproject.toml | 2 +- redmine_reporter/__init__.py | 2 +- redmine_reporter/cli.py | 27 ++++++++- redmine_reporter/client.py | 85 +++++++++++++++++++++++++--- tests/test_cli.py | 36 ++++++++++++ tests/test_client.py | 106 +++++++++++++++++++++++++++++++++++ 7 files changed, 259 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5cfcb34..4df25e9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ CLI-инструмент для генерации отчётов по зада ## Возможности -- Получение time entries текущего пользователя из Redmine. +- Получение time entries **текущего** или **указанного** пользователя из Redmine. - Авторизация через Redmine API token или логин/пароль. - Группировка задач по проекту и версии. - Перевод статусов задач на русский язык. @@ -91,6 +91,16 @@ redmine-reporter redmine-reporter --date 2026-02-01--2026-02-28 ``` +Отчёт по другому пользователю: + +```bash +redmine-reporter --user-id 42 +redmine-reporter --user-login ivanov +redmine-reporter --user-name "Иванов И.И." +``` + +`--user-name` требует точного совпадения; если найдено несколько пользователей, CLI сообщает об ошибке и просит использовать `--user-id`. + Переопределить URL/API-ключ из `.env`: ```bash diff --git a/pyproject.toml b/pyproject.toml index d937082..9e485b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "redmine-reporter" -version = "1.6.1" +version = "1.7.0" description = "Redmine time-entry based issue reporter for internal use" readme = "README.md" authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] diff --git a/redmine_reporter/__init__.py b/redmine_reporter/__init__.py index f49459c..14d9d2f 100644 --- a/redmine_reporter/__init__.py +++ b/redmine_reporter/__init__.py @@ -1 +1 @@ -__version__ = "1.6.1" +__version__ = "1.7.0" diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 693e6bf..256dff5 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -76,8 +76,29 @@ def main(argv: Optional[List[str]] = None) -> int: action="store_true", help="Print summary (total hours by project/version) to stderr", ) + parser.add_argument( + "--user-id", + help="Redmine user ID for the report (default: current user)", + ) + parser.add_argument( + "--user-login", + help="Redmine user login for the report (alternative to --user-id)", + ) + parser.add_argument( + "--user-name", + help="Redmine user full name for the report (alternative to --user-id; ambiguous names are rejected)", + ) args = parser.parse_args(argv) + # Валидация взаимоисключающих флагов пользователя + user_args = [args.user_id, args.user_login, args.user_name] + if sum(bool(a) for a in user_args) > 1: + print( + "❌ Specify only one of --user-id, --user-login, or --user-name.", + file=sys.stderr, + ) + return 1 + # CLI-переопределения имеют приоритет над .env/env. if args.config: Config.load_config(args.config) @@ -105,7 +126,11 @@ def main(argv: Optional[List[str]] = None) -> int: return 1 try: - issue_hours = fetch_issues_with_spent_time(from_date, to_date) + issue_hours = fetch_issues_with_spent_time( + from_date, + to_date, + user_id=args.user_id or args.user_login or args.user_name, + ) except RedmineAPIError as e: print(f"❌ {e.message}", file=sys.stderr) if args.debug and e.original is not None: diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py index 9f20768..2bff626 100644 --- a/redmine_reporter/client.py +++ b/redmine_reporter/client.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 3da51c6..2d942ff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -265,3 +265,39 @@ def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path): _, kwargs = mock_get_formatter.call_args assert kwargs.get("no_time") is True + + +@mock.patch.dict(os.environ, VALID_ENV, clear=True) +@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") +def test_cli_passes_user_id_to_fetch(mock_fetch): + """CLI --user-id передаётся в fetch_issues_with_spent_time.""" + mock_fetch.return_value = None + main(["--date", "2026-01-01--2026-01-31", "--user-id", "42"]) + _, kwargs = mock_fetch.call_args + assert kwargs["user_id"] == "42" + + +@mock.patch.dict(os.environ, VALID_ENV, clear=True) +@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") +def test_cli_passes_user_login_to_fetch(mock_fetch): + """CLI --user-login передаётся в fetch_issues_with_spent_time.""" + mock_fetch.return_value = None + main(["--date", "2026-01-01--2026-01-31", "--user-login", "ivanov"]) + _, kwargs = mock_fetch.call_args + assert kwargs["user_id"] == "ivanov" + + +@mock.patch.dict(os.environ, VALID_ENV, clear=True) +def test_cli_rejects_multiple_user_flags(): + """CLI не принимает одновременно несколько флагов пользователя.""" + code = main( + [ + "--date", + "2026-01-01--2026-01-31", + "--user-id", + "42", + "--user-login", + "ivanov", + ] + ) + assert code == 1 diff --git a/tests/test_client.py b/tests/test_client.py index a8a6509..1089aec 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -249,6 +249,112 @@ def test_fetch_raises_redmine_api_error_on_http_500(mock_redmine_class): fetch_issues_with_spent_time("2026-01-01", "2026-01-31") +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_fetch_uses_specified_user_id(mock_redmine_class): + """user_id передаётся в time_entry.filter вместо текущего пользователя.""" + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine) + + mock_redmine.time_entry.filter.return_value = [] + + fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id=42) + + _, kwargs = mock_redmine.time_entry.filter.call_args + assert kwargs["user_id"] == 42 + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_fetch_resolves_user_login_to_id(mock_redmine_class): + """Строковый логин разрешается в числовой ID.""" + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine) + + mock_user = mock.MagicMock() + mock_user.id = 7 + mock_redmine.user.filter.return_value = [mock_user] + mock_redmine.time_entry.filter.return_value = [] + + fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="ivanov") + + login_filter_call = mock_redmine.user.filter.call_args_list[0] + assert login_filter_call.kwargs == {"login": "ivanov"} + + _, kwargs = mock_redmine.time_entry.filter.call_args + assert kwargs["user_id"] == 7 + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_fetch_resolves_user_name_to_id(mock_redmine_class): + """Строковое имя разрешается в числовой ID, если найден ровно один пользователь.""" + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine) + + mock_redmine.user.filter.side_effect = [ + [], # поиск по логину не дал результатов + [mock.MagicMock(id=9)], # поиск по имени нашёл одного + ] + mock_redmine.time_entry.filter.return_value = [] + + fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="Иванов И.И.") + + name_filter_call = mock_redmine.user.filter.call_args_list[1] + assert name_filter_call.kwargs == {"name": "Иванов И.И."} + + _, kwargs = mock_redmine.time_entry.filter.call_args + assert kwargs["user_id"] == 9 + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_fetch_rejects_ambiguous_user_name(mock_redmine_class): + """Если по имени найдено несколько пользователей — ошибка с понятным сообщением.""" + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine) + + mock_redmine.user.filter.side_effect = [ + [], # поиск по логину не дал результатов + [mock.MagicMock(id=1), mock.MagicMock(id=2)], # по имени — несколько + ] + + from redmine_reporter.client import RedmineAPIError + + with pytest.raises(RedmineAPIError, match="Multiple users match"): + fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="Иванов") + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_fetch_rejects_unknown_user_login(mock_redmine_class): + """Если пользователь не найден — ошибка с предложением использовать --user-id.""" + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine) + + mock_redmine.user.filter.return_value = [] + + from redmine_reporter.client import RedmineAPIError + + with pytest.raises(RedmineAPIError, match="not found"): + fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="unknown") + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_fetch_numeric_string_user_id(mock_redmine_class): + """Строка из цифр интерпретируется как числовой ID.""" + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine) + mock_redmine.time_entry.filter.return_value = [] + + fetch_issues_with_spent_time("2026-01-01", "2026-01-31", user_id="42") + + assert mock_redmine.user.filter.call_count == 0 + _, kwargs = mock_redmine.time_entry.filter.call_args + assert kwargs["user_id"] == 42 + + # -- #24: Таймаут и retry --