Add Redmine API token authentication

This commit is contained in:
Кокос Артем Николаевич
2026-05-22 17:18:30 +07:00
parent 7bc6e024c0
commit 8bc8181ce3
14 changed files with 190 additions and 43 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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 = []

View File

@@ -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"
)

View File

@@ -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(
[

View File

@@ -1,4 +1,4 @@
from typing import List, Dict, Any
from typing import Dict, List
from .base import Formatter
from ..types import ReportRow

View File

@@ -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)

View File

@@ -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(

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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("") == "Иванов И.И."

View File

@@ -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))

View File

@@ -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:

View File

@@ -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():