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 или логин/пароль.
|
- Авторизация через Redmine API token или логин/пароль.
|
||||||
- Группировка задач по проекту и версии.
|
- Группировка задач по проекту и версии.
|
||||||
- Перевод статусов задач на русский язык.
|
- Перевод статусов задач на русский язык.
|
||||||
@@ -91,6 +91,16 @@ redmine-reporter
|
|||||||
redmine-reporter --date 2026-02-01--2026-02-28
|
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`:
|
Переопределить URL/API-ключ из `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "redmine-reporter"
|
name = "redmine-reporter"
|
||||||
version = "1.6.1"
|
version = "1.7.0"
|
||||||
description = "Redmine time-entry based issue reporter for internal use"
|
description = "Redmine time-entry based issue reporter for internal use"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
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",
|
action="store_true",
|
||||||
help="Print summary (total hours by project/version) to stderr",
|
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)
|
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.
|
# CLI-переопределения имеют приоритет над .env/env.
|
||||||
if args.config:
|
if args.config:
|
||||||
Config.load_config(args.config)
|
Config.load_config(args.config)
|
||||||
@@ -105,7 +126,11 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
try:
|
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:
|
except RedmineAPIError as e:
|
||||||
print(f"❌ {e.message}", file=sys.stderr)
|
print(f"❌ {e.message}", file=sys.stderr)
|
||||||
if args.debug and e.original is not None:
|
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
|
import requests
|
||||||
from redminelib import Redmine
|
from redminelib import Redmine
|
||||||
from redminelib.exceptions import AuthError, ForbiddenError
|
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
|
||||||
from redminelib.resources import Issue
|
from redminelib.resources import Issue
|
||||||
from urllib3.util.retry import Retry
|
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 "
|
"Access denied: your Redmine account does not have permission "
|
||||||
"to read time entries or issues."
|
"to read time entries or issues."
|
||||||
)
|
)
|
||||||
|
if isinstance(exc, ResourceNotFoundError):
|
||||||
|
return "Requested Redmine resource not found: check user/project identifiers."
|
||||||
|
|
||||||
# requests HTTPError может быть обёрнуто в python-redmine
|
# requests HTTPError может быть обёрнуто в python-redmine
|
||||||
original = getattr(exc, "response", None)
|
original = getattr(exc, "response", None)
|
||||||
@@ -120,22 +122,91 @@ def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]
|
|||||||
return all_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)
|
||||||
|
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(
|
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]]]:
|
) -> 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.
|
along with total spent hours per issue.
|
||||||
|
If user_id is None, uses current user.
|
||||||
Returns list of (issue, total_hours) tuples.
|
Returns list of (issue, total_hours) tuples.
|
||||||
Raises RedmineAPIError on API/auth/network failures.
|
Raises RedmineAPIError on API/auth/network failures.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
redmine = _create_redmine()
|
redmine = _create_redmine()
|
||||||
current_user = redmine.user.get("current")
|
target_user_id = (
|
||||||
time_entries = redmine.time_entry.filter(
|
_resolve_user_id(redmine, user_id)
|
||||||
user_id=current_user.id, from_date=from_date, to_date=to_date
|
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:
|
except Exception as exc:
|
||||||
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from 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
|
_, kwargs = mock_get_formatter.call_args
|
||||||
assert kwargs.get("no_time") is True
|
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")
|
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 --
|
# -- #24: Таймаут и retry --
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user