Add Redmine API token authentication
This commit is contained in:
21
README.md
21
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import List, Dict, Any
|
||||
from typing import Dict, List
|
||||
from .base import Formatter
|
||||
from ..types import ReportRow
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("") == "Иванов И.И."
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user