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:
12
README.md
12
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
|
||||
|
||||
@@ -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" }]
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.6.1"
|
||||
__version__ = "1.7.0"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 --
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user