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` - Безопасная передача учётных данных через переменные окружения или `.env`
- Авторизация через Redmine API token или через логин/пароль для обратной совместимости
- Группировка задач по проекту и версии - Группировка задач по проекту и версии
- Поддержка нескольких форматов экспорта: **ODT**, **CSV**, **Markdown**, **HTML** - Поддержка нескольких форматов экспорта: **ODT**, **CSV**, **Markdown**, **HTML**
- Два режима вывода в консоль: табличный (красивая таблица) и компактный (для копирования) - Два режима вывода в консоль: табличный (красивая таблица) и компактный (для копирования)
@@ -58,8 +59,7 @@ cat /etc/ssl/certs/ca-certificates.crt >> $(python -m certifi)
```ini ```ini
REDMINE_URL=https://red.eltex.loc/ REDMINE_URL=https://red.eltex.loc/
REDMINE_USER=ваш.логин REDMINE_API_KEY=ваш_api_token
REDMINE_PASSWORD=ваш_пароль
REDMINE_AUTHOR=Иванов Иван Иванович REDMINE_AUTHOR=Иванов Иван Иванович
# Опционально: диапазон дат по умолчанию # Опционально: диапазон дат по умолчанию
@@ -67,12 +67,20 @@ DEFAULT_FROM_DATE=2026-01-01
DEFAULT_TO_DATE=2026-01-31 DEFAULT_TO_DATE=2026-01-31
``` ```
Если `REDMINE_API_KEY` задан, он используется в первую очередь. Старый способ с логином и паролем остаётся доступен для обратной совместимости:
```ini
REDMINE_URL=https://red.eltex.loc/
REDMINE_USER=ваш.логин
REDMINE_PASSWORD=ваш_пароль
REDMINE_AUTHOR=Иванов Иван Иванович
```
Альтернатива — задать переменные вручную: Альтернатива — задать переменные вручную:
```bash ```bash
export REDMINE_URL=https://red.eltex.loc/ export REDMINE_URL=https://red.eltex.loc/
export REDMINE_USER=ваш.логин export REDMINE_API_KEY=...
export REDMINE_PASSWORD=...
export REDMINE_AUTHOR="Иванов Иван Иванович" export REDMINE_AUTHOR="Иванов Иван Иванович"
``` ```
@@ -122,8 +130,7 @@ redmine-reporter --output report.html
```ini ```ini
REDMINE_URL=https://red.eltex.loc/ REDMINE_URL=https://red.eltex.loc/
REDMINE_USER=ivanov.ivan REDMINE_API_KEY=supersecret_api_token
REDMINE_PASSWORD=supersecret
REDMINE_AUTHOR=Иванов Иван REDMINE_AUTHOR=Иванов Иван
DEFAULT_FROM_DATE=2026-01-01 DEFAULT_FROM_DATE=2026-01-01
@@ -159,6 +166,6 @@ isort .
--- ---
> 🔒 **Важно**: > 🔒 **Важно**:
> - Никогда не коммитьте `.env`, пароли или логины. > - Никогда не коммитьте `.env`, API token, пароли или логины.
> - Файл `.gitignore` уже исключает все чувствительные артефакты. > - Файл `.gitignore` уже исключает все чувствительные артефакты.
> - Инструмент работает только в режиме **чтения** — он не может изменять данные в Redmine. > - Инструмент работает только в режиме **чтения** — он не может изменять данные в Redmine.

View File

@@ -2,7 +2,6 @@ import os
import sys import sys
import argparse import argparse
from typing import List, Optional from typing import List, Optional
from redminelib.resources import Issue
from .config import Config from .config import Config
from .client import fetch_issues_with_spent_time 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)", help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default from .env or %(default)s)",
) )
parser.add_argument( 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( parser.add_argument(
"--output", "--output",
@@ -82,7 +83,10 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1 return 1
formatter = get_formatter_by_extension( 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: if not formatter:
@@ -98,7 +102,10 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"✅ Report saved to {args.output}") print(f"✅ Report saved to {args.output}")
except ImportError as e: except ImportError as e:
if output_ext == ".odt": 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: else:
print(f"❌ Import error: {e}", file=sys.stderr) print(f"❌ Import error: {e}", file=sys.stderr)
return 1 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 import Redmine
from redminelib.resources import Issue from redminelib.resources import Issue
from .config import Config from .config import Config
from .utils import get_version 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( def fetch_issues_with_spent_time(
from_date: str, to_date: str from_date: str, to_date: str
@@ -16,9 +25,8 @@ def fetch_issues_with_spent_time(
redmine = Redmine( redmine = Redmine(
Config.REDMINE_URL, Config.REDMINE_URL,
username=Config.REDMINE_USER, **_get_redmine_auth_kwargs(),
password=Config.REDMINE_PASSWORD, requests=REQUESTS_OPTIONS,
requests={"verify": "/etc/ssl/certs/ca-certificates.crt"},
) )
current_user = redmine.user.get("current") 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) 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 = [] result = []

View File

@@ -6,6 +6,7 @@ load_dotenv()
class Config: class Config:
REDMINE_URL = os.getenv("REDMINE_URL", "").strip().rstrip("/") REDMINE_URL = os.getenv("REDMINE_URL", "").strip().rstrip("/")
REDMINE_API_KEY = os.getenv("REDMINE_API_KEY")
REDMINE_USER = os.getenv("REDMINE_USER") REDMINE_USER = os.getenv("REDMINE_USER")
REDMINE_PASSWORD = os.getenv("REDMINE_PASSWORD") REDMINE_PASSWORD = os.getenv("REDMINE_PASSWORD")
REDMINE_AUTHOR = os.getenv("REDMINE_AUTHOR") REDMINE_AUTHOR = os.getenv("REDMINE_AUTHOR")
@@ -32,7 +33,9 @@ class Config:
def validate(cls) -> None: def validate(cls) -> None:
if not cls.REDMINE_URL: if not cls.REDMINE_URL:
raise ValueError("REDMINE_URL is required (set via env or .env)") raise ValueError("REDMINE_URL is required (set via env or .env)")
if not cls.REDMINE_USER: if cls.REDMINE_API_KEY:
raise ValueError("REDMINE_USER is required") return
if not cls.REDMINE_PASSWORD: if not (cls.REDMINE_USER and cls.REDMINE_PASSWORD):
raise ValueError("REDMINE_PASSWORD is required") 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: def format(self, rows: List[ReportRow]) -> str:
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output, dialect="excel") 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: for r in rows:
writer.writerow( writer.writerow(
[ [

View File

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

View File

@@ -1,7 +1,6 @@
import os
from importlib import resources from importlib import resources
from typing import List from typing import List
from odf.opendocument import load from odf.opendocument import OpenDocument, load
from odf.text import P from odf.text import P
from odf.table import Table, TableColumn, TableRow, TableCell from odf.table import Table, TableColumn, TableRow, TableCell
from odf.style import Style, TableColumnProperties, TableCellProperties from odf.style import Style, TableColumnProperties, TableCellProperties
@@ -21,7 +20,7 @@ class ODTFormatter(Formatter):
self.from_date = from_date self.from_date = from_date
self.to_date = to_date self.to_date = to_date
def format(self, rows: List[ReportRow]) -> "OpenDocument": def format(self, rows: List[ReportRow]) -> OpenDocument:
""" """
Форматирует данные в объект OpenDocument. Форматирует данные в объект OpenDocument.
""" """
@@ -44,7 +43,9 @@ class ODTFormatter(Formatter):
# Стиль ячеек # Стиль ячеек
cell_style_name = "TableCellStyle" cell_style_name = "TableCellStyle"
cell_style = Style(name=cell_style_name, family="table-cell") 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) cell_style.addElement(cell_props)
doc.automaticstyles.addElement(cell_style) doc.automaticstyles.addElement(cell_style)
@@ -102,7 +103,9 @@ class ODTFormatter(Formatter):
# Ячейка "Проект" - только в первой строке всего проекта # Ячейка "Проект" - только в первой строке всего проекта
if first_version_in_project and first_row_in_version: if first_version_in_project and first_row_in_version:
cell_project = TableCell(stylename=cell_style_name) 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) p = P(stylename=para_style_name, text=project)
cell_project.addElement(p) cell_project.addElement(p)
row.addElement(cell_project) row.addElement(cell_project)
@@ -110,7 +113,9 @@ class ODTFormatter(Formatter):
# Ячейка "Версия" - только в первой строке каждой версии # Ячейка "Версия" - только в первой строке каждой версии
if first_row_in_version: if first_row_in_version:
cell_version = TableCell(stylename=cell_style_name) 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) p = P(stylename=para_style_name, text=version)
cell_version.addElement(p) cell_version.addElement(p)
row.addElement(cell_version) 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] = [] rows: List[ReportRow] = []
prev_project: str = "" prev_project: str = ""
@@ -47,7 +49,9 @@ def build_grouped_report(
time_text = hours_to_human(hours) if fill_time else "" time_text = hours_to_human(hours) if fill_time else ""
display_project = project if project != prev_project 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( rows.append(
cast( cast(

View File

@@ -1,5 +1,4 @@
import sys import sys
import pytest
from io import StringIO from io import StringIO
from unittest import mock from unittest import mock
from redmine_reporter.cli import main from redmine_reporter.cli import main
@@ -14,6 +13,7 @@ from redmine_reporter.config import Config
@mock.patch.multiple( @mock.patch.multiple(
Config, Config,
REDMINE_URL="https://red.eltex.loc", REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="x", REDMINE_USER="x",
REDMINE_PASSWORD="y", REDMINE_PASSWORD="y",
) )
@@ -34,6 +34,7 @@ def test_cli_smoke_empty(mock_fetch):
@mock.patch.multiple( @mock.patch.multiple(
Config, Config,
REDMINE_URL="https://red.eltex.loc", REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="x", REDMINE_USER="x",
REDMINE_PASSWORD="y", REDMINE_PASSWORD="y",
) )
@@ -48,6 +49,7 @@ def test_cli_returns_zero_on_no_entries(mock_fetch):
@mock.patch.multiple( @mock.patch.multiple(
Config, Config,
REDMINE_URL="", REDMINE_URL="",
REDMINE_API_KEY=None,
REDMINE_USER=None, REDMINE_USER=None,
REDMINE_PASSWORD=None, REDMINE_PASSWORD=None,
) )
@@ -66,6 +68,7 @@ def test_cli_invalid_date_format():
@mock.patch.multiple( @mock.patch.multiple(
Config, Config,
REDMINE_URL="https://red.eltex.loc", REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="x", REDMINE_USER="x",
REDMINE_PASSWORD="y", REDMINE_PASSWORD="y",
) )
@@ -81,6 +84,7 @@ def test_cli_unknown_output_extension(mock_fetch, tmp_path):
@mock.patch.multiple( @mock.patch.multiple(
Config, Config,
REDMINE_URL="https://red.eltex.loc", REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="x", REDMINE_USER="x",
REDMINE_PASSWORD="y", REDMINE_PASSWORD="y",
) )

View File

@@ -1,8 +1,15 @@
import pytest
from unittest import mock from unittest import mock
from redmine_reporter.client import fetch_issues_with_spent_time 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") @mock.patch("redmine_reporter.client.Redmine")
def test_fetch_aggregates_hours_per_issue(mock_redmine_class): def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
"""Два time entry на одну задачу -- часы суммируются.""" """Два time entry на одну задачу -- часы суммируются."""
@@ -34,6 +41,13 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
assert total_hours == 3.5 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") @mock.patch("redmine_reporter.client.Redmine")
def test_fetch_returns_none_when_no_entries(mock_redmine_class): def test_fetch_returns_none_when_no_entries(mock_redmine_class):
"""Нет time entries -- возвращается None.""" """Нет time entries -- возвращается None."""
@@ -47,6 +61,13 @@ def test_fetch_returns_none_when_no_entries(mock_redmine_class):
assert result is None 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") @mock.patch("redmine_reporter.client.Redmine")
def test_fetch_skips_entries_without_issue(mock_redmine_class): def test_fetch_skips_entries_without_issue(mock_redmine_class):
"""Time entry без привязки к задаче игнорируется.""" """Time entry без привязки к задаче игнорируется."""
@@ -65,6 +86,13 @@ def test_fetch_skips_entries_without_issue(mock_redmine_class):
assert result is None 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") @mock.patch("redmine_reporter.client.Redmine")
def test_fetch_multiple_issues(mock_redmine_class): 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} hours_by_id = {issue.id: hours for issue, hours in result}
assert hours_by_id[1] == 1.5 assert hours_by_id[1] == 1.5
assert hours_by_id[2] == 2.0 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 import pytest
from unittest import mock from unittest import mock
from redmine_reporter.config import Config from redmine_reporter.config import Config
@@ -11,24 +10,49 @@ from redmine_reporter.config import Config
@mock.patch.multiple( @mock.patch.multiple(
Config, Config,
REDMINE_URL="https://red.eltex.loc", REDMINE_URL="https://red.eltex.loc",
REDMINE_API_KEY=None,
REDMINE_USER="test", REDMINE_USER="test",
REDMINE_PASSWORD="secret", 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() # не должно быть исключения Config.validate() # не должно быть исключения
@mock.patch.multiple( @mock.patch.multiple(
Config, Config,
REDMINE_URL="", REDMINE_URL="",
REDMINE_API_KEY=None,
REDMINE_USER=None, REDMINE_USER=None,
REDMINE_PASSWORD=None, REDMINE_PASSWORD=None,
) )
def test_config_missing(): def test_config_missing_url():
with pytest.raises(ValueError, match="REDMINE_URL"): with pytest.raises(ValueError, match="REDMINE_URL"):
Config.validate() 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="Иванов И.И.") @mock.patch.multiple(Config, REDMINE_AUTHOR="Иванов И.И.")
def test_get_author(): def test_get_author():
assert Config.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.csv import CSVFormatter
from redmine_reporter.formatters.markdown import MarkdownFormatter from redmine_reporter.formatters.markdown import MarkdownFormatter
from redmine_reporter.formatters.odt import ODTFormatter 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: def _make_empty_odt_bytes() -> bytes:
"""Создаёт минимальный валидный ODT-документ в памяти.""" """Создаёт минимальный валидный ODT-документ в памяти."""
doc = newdoc(doctype="odt") doc = OpenDocumentText()
buf = io.BytesIO() buf = io.BytesIO()
doc.save(buf) doc.save(buf)
return buf.getvalue() 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" output_file = tmp_path / "report.odt"
formatter.save(fake_rows, str(output_file)) 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.report_builder import build_grouped_report, STATUS_TRANSLATION
from redmine_reporter.utils import get_version
class MockIssue: class MockIssue:

View File

@@ -1,5 +1,8 @@
import pytest from redmine_reporter.utils import (
from redmine_reporter.utils import hours_to_human, get_month_name_from_range, get_version hours_to_human,
get_month_name_from_range,
get_version,
)
def test_hours_to_human_zero(): def test_hours_to_human_zero():