diff --git a/README.md b/README.md index 34e7230..8cbd374 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ ## 🔧 Возможности - Безопасная передача учётных данных через переменные окружения или `.env` +- Авторизация через Redmine API token или через логин/пароль для обратной совместимости - Группировка задач по проекту и версии - Поддержка нескольких форматов экспорта: **ODT**, **CSV**, **Markdown**, **HTML** - Два режима вывода в консоль: табличный (красивая таблица) и компактный (для копирования) @@ -58,8 +59,7 @@ cat /etc/ssl/certs/ca-certificates.crt >> $(python -m certifi) ```ini REDMINE_URL=https://red.eltex.loc/ -REDMINE_USER=ваш.логин -REDMINE_PASSWORD=ваш_пароль +REDMINE_API_KEY=ваш_api_token REDMINE_AUTHOR=Иванов Иван Иванович # Опционально: диапазон дат по умолчанию @@ -67,12 +67,20 @@ DEFAULT_FROM_DATE=2026-01-01 DEFAULT_TO_DATE=2026-01-31 ``` +Если `REDMINE_API_KEY` задан, он используется в первую очередь. Старый способ с логином и паролем остаётся доступен для обратной совместимости: + +```ini +REDMINE_URL=https://red.eltex.loc/ +REDMINE_USER=ваш.логин +REDMINE_PASSWORD=ваш_пароль +REDMINE_AUTHOR=Иванов Иван Иванович +``` + Альтернатива — задать переменные вручную: ```bash export REDMINE_URL=https://red.eltex.loc/ -export REDMINE_USER=ваш.логин -export REDMINE_PASSWORD=... +export REDMINE_API_KEY=... export REDMINE_AUTHOR="Иванов Иван Иванович" ``` @@ -122,8 +130,7 @@ redmine-reporter --output report.html ```ini REDMINE_URL=https://red.eltex.loc/ -REDMINE_USER=ivanov.ivan -REDMINE_PASSWORD=supersecret +REDMINE_API_KEY=supersecret_api_token REDMINE_AUTHOR=Иванов Иван DEFAULT_FROM_DATE=2026-01-01 @@ -159,6 +166,6 @@ isort . --- > 🔒 **Важно**: -> - Никогда не коммитьте `.env`, пароли или логины. +> - Никогда не коммитьте `.env`, API token, пароли или логины. > - Файл `.gitignore` уже исключает все чувствительные артефакты. > - Инструмент работает только в режиме **чтения** — он не может изменять данные в Redmine. diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 734984b..e506444 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -2,7 +2,6 @@ import os import sys import argparse from typing import List, Optional -from redminelib.resources import Issue from .config import Config from .client import fetch_issues_with_spent_time @@ -31,7 +30,9 @@ def main(argv: Optional[List[str]] = None) -> int: help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default from .env or %(default)s)", ) parser.add_argument( - "--compact", action="store_true", help="Use compact plain-text output instead of table" + "--compact", + action="store_true", + help="Use compact plain-text output instead of table", ) parser.add_argument( "--output", @@ -82,7 +83,10 @@ def main(argv: Optional[List[str]] = None) -> int: return 1 formatter = get_formatter_by_extension( - output_ext, author=Config.get_author(args.author), from_date=from_date, to_date=to_date + output_ext, + author=Config.get_author(args.author), + from_date=from_date, + to_date=to_date, ) if not formatter: @@ -98,7 +102,10 @@ def main(argv: Optional[List[str]] = None) -> int: print(f"✅ Report saved to {args.output}") except ImportError as e: if output_ext == ".odt": - print("❌ odfpy is not installed. Install with: pip install odfpy", file=sys.stderr) + print( + "❌ odfpy is not installed. Install with: pip install odfpy", + file=sys.stderr, + ) else: print(f"❌ Import error: {e}", file=sys.stderr) return 1 diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py index 1fd476d..889965c 100644 --- a/redmine_reporter/client.py +++ b/redmine_reporter/client.py @@ -1,9 +1,18 @@ -from typing import List, Optional, Dict, Tuple +from typing import Any, Dict, List, Optional, Tuple from redminelib import Redmine from redminelib.resources import Issue from .config import Config from .utils import get_version +REQUESTS_OPTIONS = {"verify": "/etc/ssl/certs/ca-certificates.crt"} + + +def _get_redmine_auth_kwargs() -> Dict[str, Any]: + """Return Redmine auth kwargs. API key has priority over legacy password auth.""" + if Config.REDMINE_API_KEY: + return {"key": Config.REDMINE_API_KEY} + return {"username": Config.REDMINE_USER, "password": Config.REDMINE_PASSWORD} + def fetch_issues_with_spent_time( from_date: str, to_date: str @@ -16,9 +25,8 @@ def fetch_issues_with_spent_time( redmine = Redmine( Config.REDMINE_URL, - username=Config.REDMINE_USER, - password=Config.REDMINE_PASSWORD, - requests={"verify": "/etc/ssl/certs/ca-certificates.crt"}, + **_get_redmine_auth_kwargs(), + requests=REQUESTS_OPTIONS, ) current_user = redmine.user.get("current") @@ -40,7 +48,9 @@ def fetch_issues_with_spent_time( # Загружаем полные объекты задач issue_list_str = ",".join(str(i) for i in issue_ids) - issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc") + issues = redmine.issue.filter( + issue_id=issue_list_str, status_id="*", sort="project:asc" + ) # Сопоставляем задачи с суммарным временем result = [] diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 90ef923..37d1085 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -6,6 +6,7 @@ load_dotenv() class Config: REDMINE_URL = os.getenv("REDMINE_URL", "").strip().rstrip("/") + REDMINE_API_KEY = os.getenv("REDMINE_API_KEY") REDMINE_USER = os.getenv("REDMINE_USER") REDMINE_PASSWORD = os.getenv("REDMINE_PASSWORD") REDMINE_AUTHOR = os.getenv("REDMINE_AUTHOR") @@ -32,7 +33,9 @@ class Config: def validate(cls) -> None: if not cls.REDMINE_URL: raise ValueError("REDMINE_URL is required (set via env or .env)") - if not cls.REDMINE_USER: - raise ValueError("REDMINE_USER is required") - if not cls.REDMINE_PASSWORD: - raise ValueError("REDMINE_PASSWORD is required") + if cls.REDMINE_API_KEY: + return + if not (cls.REDMINE_USER and cls.REDMINE_PASSWORD): + raise ValueError( + "REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD" + ) diff --git a/redmine_reporter/formatters/csv.py b/redmine_reporter/formatters/csv.py index 2fa3c63..5d3b761 100644 --- a/redmine_reporter/formatters/csv.py +++ b/redmine_reporter/formatters/csv.py @@ -14,7 +14,9 @@ class CSVFormatter(Formatter): def format(self, rows: List[ReportRow]) -> str: output = io.StringIO() writer = csv.writer(output, dialect="excel") - writer.writerow(["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]) + writer.writerow( + ["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"] + ) for r in rows: writer.writerow( [ diff --git a/redmine_reporter/formatters/html.py b/redmine_reporter/formatters/html.py index 9202ca9..29b7765 100644 --- a/redmine_reporter/formatters/html.py +++ b/redmine_reporter/formatters/html.py @@ -1,4 +1,4 @@ -from typing import List, Dict, Any +from typing import Dict, List from .base import Formatter from ..types import ReportRow diff --git a/redmine_reporter/formatters/odt.py b/redmine_reporter/formatters/odt.py index 1e3b6fc..6e7172e 100644 --- a/redmine_reporter/formatters/odt.py +++ b/redmine_reporter/formatters/odt.py @@ -1,7 +1,6 @@ -import os from importlib import resources from typing import List -from odf.opendocument import load +from odf.opendocument import OpenDocument, load from odf.text import P from odf.table import Table, TableColumn, TableRow, TableCell from odf.style import Style, TableColumnProperties, TableCellProperties @@ -21,7 +20,7 @@ class ODTFormatter(Formatter): self.from_date = from_date self.to_date = to_date - def format(self, rows: List[ReportRow]) -> "OpenDocument": + def format(self, rows: List[ReportRow]) -> OpenDocument: """ Форматирует данные в объект OpenDocument. """ @@ -44,7 +43,9 @@ class ODTFormatter(Formatter): # Стиль ячеек cell_style_name = "TableCellStyle" cell_style = Style(name=cell_style_name, family="table-cell") - cell_props = TableCellProperties(padding="0.04in", border="0.05pt solid #000000") + cell_props = TableCellProperties( + padding="0.04in", border="0.05pt solid #000000" + ) cell_style.addElement(cell_props) doc.automaticstyles.addElement(cell_style) @@ -102,7 +103,9 @@ class ODTFormatter(Formatter): # Ячейка "Проект" - только в первой строке всего проекта if first_version_in_project and first_row_in_version: cell_project = TableCell(stylename=cell_style_name) - cell_project.setAttribute("numberrowsspanned", str(total_project_rows)) + cell_project.setAttribute( + "numberrowsspanned", str(total_project_rows) + ) p = P(stylename=para_style_name, text=project) cell_project.addElement(p) row.addElement(cell_project) @@ -110,7 +113,9 @@ class ODTFormatter(Formatter): # Ячейка "Версия" - только в первой строке каждой версии if first_row_in_version: cell_version = TableCell(stylename=cell_style_name) - cell_version.setAttribute("numberrowsspanned", str(row_span_version)) + cell_version.setAttribute( + "numberrowsspanned", str(row_span_version) + ) p = P(stylename=para_style_name, text=version) cell_version.addElement(p) row.addElement(cell_version) diff --git a/redmine_reporter/report_builder.py b/redmine_reporter/report_builder.py index 4955e47..2a703c8 100644 --- a/redmine_reporter/report_builder.py +++ b/redmine_reporter/report_builder.py @@ -33,7 +33,9 @@ def build_grouped_report( """ # Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе - issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]))) + issue_hours = sorted( + issue_hours, key=lambda x: (str(x[0].project), get_version(x[0])) + ) rows: List[ReportRow] = [] prev_project: str = "" @@ -47,7 +49,9 @@ def build_grouped_report( time_text = hours_to_human(hours) if fill_time else "" display_project = project if project != prev_project else "" - display_version = version if (project != prev_project or version != prev_version) else "" + display_version = ( + version if (project != prev_project or version != prev_version) else "" + ) rows.append( cast( diff --git a/tests/test_cli.py b/tests/test_cli.py index 5e71b21..9bdeb88 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,4 @@ import sys -import pytest from io import StringIO from unittest import mock from redmine_reporter.cli import main @@ -14,6 +13,7 @@ from redmine_reporter.config import Config @mock.patch.multiple( Config, REDMINE_URL="https://red.eltex.loc", + REDMINE_API_KEY=None, REDMINE_USER="x", REDMINE_PASSWORD="y", ) @@ -34,6 +34,7 @@ def test_cli_smoke_empty(mock_fetch): @mock.patch.multiple( Config, REDMINE_URL="https://red.eltex.loc", + REDMINE_API_KEY=None, REDMINE_USER="x", REDMINE_PASSWORD="y", ) @@ -48,6 +49,7 @@ def test_cli_returns_zero_on_no_entries(mock_fetch): @mock.patch.multiple( Config, REDMINE_URL="", + REDMINE_API_KEY=None, REDMINE_USER=None, REDMINE_PASSWORD=None, ) @@ -66,6 +68,7 @@ def test_cli_invalid_date_format(): @mock.patch.multiple( Config, REDMINE_URL="https://red.eltex.loc", + REDMINE_API_KEY=None, REDMINE_USER="x", REDMINE_PASSWORD="y", ) @@ -81,6 +84,7 @@ def test_cli_unknown_output_extension(mock_fetch, tmp_path): @mock.patch.multiple( Config, REDMINE_URL="https://red.eltex.loc", + REDMINE_API_KEY=None, REDMINE_USER="x", REDMINE_PASSWORD="y", ) diff --git a/tests/test_client.py b/tests/test_client.py index aca5a4e..4f6b8d3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,8 +1,15 @@ -import pytest from unittest import mock from redmine_reporter.client import fetch_issues_with_spent_time +from redmine_reporter.config import Config +@mock.patch.multiple( + Config, + REDMINE_URL="https://red.eltex.loc", + REDMINE_API_KEY=None, + REDMINE_USER="user", + REDMINE_PASSWORD="password", +) @mock.patch("redmine_reporter.client.Redmine") def test_fetch_aggregates_hours_per_issue(mock_redmine_class): """Два time entry на одну задачу -- часы суммируются.""" @@ -34,6 +41,13 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class): assert total_hours == 3.5 +@mock.patch.multiple( + Config, + REDMINE_URL="https://red.eltex.loc", + REDMINE_API_KEY=None, + REDMINE_USER="user", + REDMINE_PASSWORD="password", +) @mock.patch("redmine_reporter.client.Redmine") def test_fetch_returns_none_when_no_entries(mock_redmine_class): """Нет time entries -- возвращается None.""" @@ -47,6 +61,13 @@ def test_fetch_returns_none_when_no_entries(mock_redmine_class): assert result is None +@mock.patch.multiple( + Config, + REDMINE_URL="https://red.eltex.loc", + REDMINE_API_KEY=None, + REDMINE_USER="user", + REDMINE_PASSWORD="password", +) @mock.patch("redmine_reporter.client.Redmine") def test_fetch_skips_entries_without_issue(mock_redmine_class): """Time entry без привязки к задаче игнорируется.""" @@ -65,6 +86,13 @@ def test_fetch_skips_entries_without_issue(mock_redmine_class): assert result is None +@mock.patch.multiple( + Config, + REDMINE_URL="https://red.eltex.loc", + REDMINE_API_KEY=None, + REDMINE_USER="user", + REDMINE_PASSWORD="password", +) @mock.patch("redmine_reporter.client.Redmine") def test_fetch_multiple_issues(mock_redmine_class): """Несколько задач -- каждая с правильным суммарным временем.""" @@ -100,3 +128,51 @@ def test_fetch_multiple_issues(mock_redmine_class): hours_by_id = {issue.id: hours for issue, hours in result} assert hours_by_id[1] == 1.5 assert hours_by_id[2] == 2.0 + + +@mock.patch.multiple( + Config, + REDMINE_URL="https://red.eltex.loc", + REDMINE_API_KEY="api-token", + REDMINE_USER="user", + REDMINE_PASSWORD="password", +) +@mock.patch("redmine_reporter.client.Redmine") +def test_fetch_uses_api_key_when_present(mock_redmine_class): + """Если задан API key, он используется вместо логина/пароля.""" + mock_redmine = mock_redmine_class.return_value + mock_user = mock.MagicMock() + mock_user.id = 1 + mock_redmine.user.get.return_value = mock_user + mock_redmine.time_entry.filter.return_value = [] + + fetch_issues_with_spent_time("2026-01-01", "2026-01-31") + + _, kwargs = mock_redmine_class.call_args + assert kwargs["key"] == "api-token" + assert "username" not in kwargs + assert "password" not in kwargs + + +@mock.patch.multiple( + Config, + REDMINE_URL="https://red.eltex.loc", + REDMINE_API_KEY=None, + REDMINE_USER="user", + REDMINE_PASSWORD="password", +) +@mock.patch("redmine_reporter.client.Redmine") +def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class): + """Если API key не задан, остаётся старая схема логин/пароль.""" + mock_redmine = mock_redmine_class.return_value + mock_user = mock.MagicMock() + mock_user.id = 1 + mock_redmine.user.get.return_value = mock_user + mock_redmine.time_entry.filter.return_value = [] + + fetch_issues_with_spent_time("2026-01-01", "2026-01-31") + + _, kwargs = mock_redmine_class.call_args + assert kwargs["username"] == "user" + assert kwargs["password"] == "password" + assert "key" not in kwargs diff --git a/tests/test_config.py b/tests/test_config.py index 3dfa223..6ea1c88 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,3 @@ -import os import pytest from unittest import mock from redmine_reporter.config import Config @@ -11,24 +10,49 @@ from redmine_reporter.config import Config @mock.patch.multiple( Config, REDMINE_URL="https://red.eltex.loc", + REDMINE_API_KEY=None, REDMINE_USER="test", REDMINE_PASSWORD="secret", ) -def test_config_valid(): +def test_config_valid_with_password_fallback(): + Config.validate() # не должно быть исключения + + +@mock.patch.multiple( + Config, + REDMINE_URL="https://red.eltex.loc", + REDMINE_API_KEY="token", + REDMINE_USER=None, + REDMINE_PASSWORD=None, +) +def test_config_valid_with_api_key(): Config.validate() # не должно быть исключения @mock.patch.multiple( Config, REDMINE_URL="", + REDMINE_API_KEY=None, REDMINE_USER=None, REDMINE_PASSWORD=None, ) -def test_config_missing(): +def test_config_missing_url(): with pytest.raises(ValueError, match="REDMINE_URL"): Config.validate() +@mock.patch.multiple( + Config, + REDMINE_URL="https://red.eltex.loc", + REDMINE_API_KEY=None, + REDMINE_USER=None, + REDMINE_PASSWORD=None, +) +def test_config_missing_auth(): + with pytest.raises(ValueError, match="REDMINE_API_KEY"): + Config.validate() + + @mock.patch.multiple(Config, REDMINE_AUTHOR="Иванов И.И.") def test_get_author(): assert Config.get_author("") == "Иванов И.И." diff --git a/tests/test_formatters.py b/tests/test_formatters.py index ccd7aad..e20ba13 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -7,12 +7,12 @@ from redmine_reporter.formatters.console import TableFormatter, CompactFormatter from redmine_reporter.formatters.csv import CSVFormatter from redmine_reporter.formatters.markdown import MarkdownFormatter from redmine_reporter.formatters.odt import ODTFormatter -from odf.opendocument import OpenDocument, newdoc +from odf.opendocument import OpenDocument, OpenDocumentText def _make_empty_odt_bytes() -> bytes: """Создаёт минимальный валидный ODT-документ в памяти.""" - doc = newdoc(doctype="odt") + doc = OpenDocumentText() buf = io.BytesIO() doc.save(buf) return buf.getvalue() @@ -122,7 +122,9 @@ def odt_formatter(): ) ), ): - yield ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31") + yield ODTFormatter( + author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31" + ) # -- Параметризованные тесты текстовых форматтеров -- @@ -206,7 +208,9 @@ def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path): ) ), ): - formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31") + formatter = ODTFormatter( + author="Тест", from_date="2026-01-01", to_date="2026-01-31" + ) output_file = tmp_path / "report.odt" formatter.save(fake_rows, str(output_file)) diff --git a/tests/test_report_builder.py b/tests/test_report_builder.py index 98be112..30bbf6e 100644 --- a/tests/test_report_builder.py +++ b/tests/test_report_builder.py @@ -1,6 +1,4 @@ -import pytest from redmine_reporter.report_builder import build_grouped_report, STATUS_TRANSLATION -from redmine_reporter.utils import get_version class MockIssue: diff --git a/tests/test_utils.py b/tests/test_utils.py index bac7bda..6d516b3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,8 @@ -import pytest -from redmine_reporter.utils import hours_to_human, get_month_name_from_range, get_version +from redmine_reporter.utils import ( + hours_to_human, + get_month_name_from_range, + get_version, +) def test_hours_to_human_zero():