Add Redmine API token authentication
This commit is contained in:
21
README.md
21
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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("") == "Иванов И.И."
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user