This commit is contained in:
Кокос Артем Николаевич
2026-02-05 15:31:31 +07:00
parent d7e927e6eb
commit 06cd57e2c4
14 changed files with 102 additions and 87 deletions

View File

@@ -22,32 +22,26 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
def main(argv: Optional[List[str]] = None) -> int: def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="redmine-reporter", prog="redmine-reporter",
description="Generate Redmine issue report based on your time entries." description="Generate Redmine issue report based on your time entries.",
) )
parser.add_argument( parser.add_argument(
"--date", "--date",
default=Config.get_default_date_range(), default=Config.get_default_date_range(),
# help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: %(default)s)" # help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default: %(default)s)"
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", "--compact", action="store_true", help="Use compact plain-text output instead of table"
action="store_true",
help="Use compact plain-text output instead of table"
) )
parser.add_argument( parser.add_argument(
"--output", "--output",
help="Path to output .odt file (e.g., report.odt). If omitted, prints to stdout." help="Path to output .odt file (e.g., report.odt). If omitted, prints to stdout.",
) )
parser.add_argument( parser.add_argument(
"--author", "--author", default="", help="Override author name from .env (REDMINE_AUTHOR)"
default="",
help="Override author name from .env (REDMINE_AUTHOR)"
) )
parser.add_argument( parser.add_argument(
"--no-time", "--no-time", action="store_true", help="Do not include spent time into table"
action="store_true",
help="Do not include spent time into table"
) )
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -80,10 +74,8 @@ def main(argv: Optional[List[str]] = None) -> int:
if args.output: if args.output:
output_ext = os.path.splitext(args.output)[1].lower() output_ext = os.path.splitext(args.output)[1].lower()
formatter = get_formatter_by_extension(output_ext, formatter = get_formatter_by_extension(
author=Config.get_author(args.author), output_ext, author=Config.get_author(args.author), from_date=from_date, to_date=to_date
from_date=from_date,
to_date=to_date
) )
if not formatter: if not formatter:

View File

@@ -5,7 +5,9 @@ from .config import Config
from .utils import get_version from .utils import get_version
def fetch_issues_with_spent_time(from_date: str, to_date: str) -> Optional[List[Tuple[Issue, float]]]: def fetch_issues_with_spent_time(
from_date: str, to_date: str
) -> Optional[List[Tuple[Issue, float]]]:
""" """
Fetch unique issues linked to time entries of the current user in given date range, Fetch unique issues linked to time entries of the current user in given date range,
along with total spent hours per issue. along with total spent hours per issue.
@@ -16,21 +18,19 @@ def fetch_issues_with_spent_time(from_date: str, to_date: str) -> Optional[List[
Config.REDMINE_URL, Config.REDMINE_URL,
username=Config.REDMINE_USER, username=Config.REDMINE_USER,
password=Config.REDMINE_PASSWORD, password=Config.REDMINE_PASSWORD,
requests={'verify': '/etc/ssl/certs/ca-certificates.crt'} requests={"verify": "/etc/ssl/certs/ca-certificates.crt"},
) )
current_user = redmine.user.get('current') current_user = redmine.user.get("current")
time_entries = redmine.time_entry.filter( time_entries = redmine.time_entry.filter(
user_id=current_user.id, user_id=current_user.id, from_date=from_date, to_date=to_date
from_date=from_date,
to_date=to_date
) )
# Агрегируем часы по issue.id # Агрегируем часы по issue.id
spent_time: Dict[int, float] = {} spent_time: Dict[int, float] = {}
issue_ids = set() issue_ids = set()
for entry in time_entries: for entry in time_entries:
if hasattr(entry, 'issue') and entry.issue and hasattr(entry, 'hours'): if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"):
iid = entry.issue.id iid = entry.issue.id
issue_ids.add(iid) issue_ids.add(iid)
spent_time[iid] = spent_time.get(iid, 0.0) + float(entry.hours) spent_time[iid] = spent_time.get(iid, 0.0) + float(entry.hours)
@@ -39,12 +39,8 @@ def fetch_issues_with_spent_time(from_date: str, to_date: str) -> Optional[List[
return None return None
# Загружаем полные объекты задач # Загружаем полные объекты задач
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( issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc")
issue_id=issue_list_str,
status_id='*',
sort='project:asc'
)
# Сопоставляем задачи с суммарным временем # Сопоставляем задачи с суммарным временем
result = [] result = []

View File

@@ -1,7 +1,6 @@
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()

View File

@@ -8,15 +8,17 @@ class TableFormatter(Formatter):
"""Форматтер для вывода красивой таблицы в консоль.""" """Форматтер для вывода красивой таблицы в консоль."""
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:
table_rows = [['Проект', 'Версия', 'Задача', 'Статус', 'Затрачено']] table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]]
for r in rows: for r in rows:
table_rows.append([ table_rows.append(
r['display_project'], [
r['display_version'], r["display_project"],
f"{r['issue_id']}. {r['subject']}", r["display_version"],
r['status_ru'], f"{r['issue_id']}. {r['subject']}",
r['time_text'] r["status_ru"],
]) r["time_text"],
]
)
return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid") return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: List[ReportRow], output_path: str) -> None:
@@ -24,6 +26,7 @@ class TableFormatter(Formatter):
# Это делается в CLI. # Это делается в CLI.
raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.") raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.")
class CompactFormatter(Formatter): class CompactFormatter(Formatter):
"""Форматтер для компактного вывода в консоль.""" """Форматтер для компактного вывода в консоль."""

View File

@@ -16,14 +16,16 @@ class CSVFormatter(Formatter):
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(
r["project"], [
r["version"], r["project"],
r["issue_id"], r["version"],
r["subject"], r["issue_id"],
r["status_ru"], r["subject"],
r["time_text"] r["status_ru"],
]) r["time_text"],
]
)
return output.getvalue() return output.getvalue()
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: List[ReportRow], output_path: str) -> None:

View File

@@ -6,7 +6,6 @@ from .markdown import MarkdownFormatter
from .odt import ODTFormatter from .odt import ODTFormatter
from .html import HTMLFormatter from .html import HTMLFormatter
# Словарь для сопоставления расширений файлов с классами форматтеров # Словарь для сопоставления расширений файлов с классами форматтеров
FORMATTER_MAP: Dict[str, Type[Formatter]] = { FORMATTER_MAP: Dict[str, Type[Formatter]] = {
".odt": ODTFormatter, ".odt": ODTFormatter,

View File

@@ -12,7 +12,7 @@ class MarkdownFormatter(Formatter):
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:
lines = [ lines = [
"| Проект | Версия | Задача | Статус | Затрачено |", "| Проект | Версия | Задача | Статус | Затрачено |",
"|--------|--------|--------|--------|-----------|" "|--------|--------|--------|--------|-----------|",
] ]
for r in rows: for r in rows:
task_cell = f"{r['issue_id']}. {r['subject']}" task_cell = f"{r['issue_id']}. {r['subject']}"

View File

@@ -26,7 +26,11 @@ class ODTFormatter(Formatter):
Форматирует данные в объект OpenDocument. Форматирует данные в объект OpenDocument.
""" """
with resources.files("redmine_reporter").joinpath("templates", "template.odt").open("rb") as f: with (
resources.files("redmine_reporter")
.joinpath("templates", "template.odt")
.open("rb") as f
):
doc = load(f) doc = load(f)
para_style_name = "Standard" para_style_name = "Standard"
@@ -56,7 +60,13 @@ class ODTFormatter(Formatter):
# Заголовки # Заголовки
header_row = TableRow() header_row = TableRow()
for text in ["Наименование Проекта", "Номер версии*", "Задача", "Статус Готовность*", "Затрачено за отчетный период"]: for text in [
"Наименование Проекта",
"Номер версии*",
"Задача",
"Статус Готовность*",
"Затрачено за отчетный период",
]:
cell = TableCell(stylename=cell_style_name) cell = TableCell(stylename=cell_style_name)
cell.addElement(P(stylename=para_style_name, text=text)) cell.addElement(P(stylename=para_style_name, text=text))
header_row.addElement(cell) header_row.addElement(cell)
@@ -77,7 +87,9 @@ class ODTFormatter(Formatter):
# Данные с двухуровневой группировкой и объединением ячеек # Данные с двухуровневой группировкой и объединением ячеек
for project, versions in projects.items(): for project, versions in projects.items():
total_project_rows = sum(len(rows_for_version) for rows_for_version in versions.values()) total_project_rows = sum(
len(rows_for_version) for rows_for_version in versions.values()
)
first_version_in_project = True first_version_in_project = True
for version, rows_for_version in versions.items(): for version, rows_for_version in versions.items():
@@ -137,7 +149,7 @@ class ODTFormatter(Formatter):
"«Задача» - Номер по Redmine и формулировка.", "«Задача» - Номер по Redmine и формулировка.",
"«Статус» - Актуальное состояние задачи на момент отчета. Статусы: закрыто, в работе, ожидание, решена.", "«Статус» - Актуальное состояние задачи на момент отчета. Статусы: закрыто, в работе, ожидание, решена.",
"«Готовность» Опциональное поле в процентах.", "«Готовность» Опциональное поле в процентах.",
"«Затрачено за отчетный период» - в днях или часах." "«Затрачено за отчетный период» - в днях или часах.",
]: ]:
doc.text.addElement(P(stylename=para_style_name, text=line)) doc.text.addElement(P(stylename=para_style_name, text=line))

View File

@@ -3,21 +3,20 @@ from redminelib.resources import Issue
from .types import ReportRow from .types import ReportRow
from .utils import get_version, hours_to_human from .utils import get_version, hours_to_human
STATUS_TRANSLATION = { STATUS_TRANSLATION = {
'New': 'В работе', "New": "В работе",
'In Progress': 'В работе', "In Progress": "В работе",
'Feedback': 'В работе', "Feedback": "В работе",
'Re-opened': 'В работе', "Re-opened": "В работе",
'Code Review': 'Решена', "Code Review": "Решена",
'Wait Release': 'Закрыто', "Wait Release": "Закрыто",
'Pending': 'Ожидание', "Pending": "Ожидание",
'Resolved': 'Решена', "Resolved": "Решена",
'Testing': 'Решена', "Testing": "Решена",
'Confirming': 'Ожидание', "Confirming": "Ожидание",
'Closed': 'Закрыто', "Closed": "Закрыто",
'Rejected': 'Закрыто', "Rejected": "Закрыто",
'Frozen': 'Ожидание', "Frozen": "Ожидание",
} }

View File

@@ -10,8 +10,19 @@ def get_month_name_from_range(from_date: str, to_date: str) -> str:
return "Январь" return "Январь"
months = [ months = [
"", "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь" "Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
] ]
return months[end.month] return months[end.month]
@@ -19,7 +30,7 @@ def get_month_name_from_range(from_date: str, to_date: str) -> str:
def get_version(issue) -> str: def get_version(issue) -> str:
"""Возвращает версию задачи или '<N/A>', если не задана.""" """Возвращает версию задачи или '<N/A>', если не задана."""
return str(getattr(issue, 'fixed_version', '<N/A>')) return str(getattr(issue, "fixed_version", "<N/A>"))
def hours_to_human(hours: float) -> str: def hours_to_human(hours: float) -> str:

View File

@@ -4,11 +4,10 @@ from unittest import mock
from redmine_reporter.cli import main from redmine_reporter.cli import main
@mock.patch.dict("os.environ", { @mock.patch.dict(
"REDMINE_URL": "https://red.eltex.loc/", "os.environ",
"REDMINE_USER": "x", {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_USER": "x", "REDMINE_PASSWORD": "y"},
"REDMINE_PASSWORD": "y" )
})
@mock.patch("redmine_reporter.client.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.client.fetch_issues_with_spent_time")
def test_cli_smoke(mock_fetch): def test_cli_smoke(mock_fetch):
mock_fetch.return_value = [] mock_fetch.return_value = []

View File

@@ -4,11 +4,10 @@ from unittest import mock
from redmine_reporter.config import Config from redmine_reporter.config import Config
@mock.patch.dict(os.environ, { @mock.patch.dict(
"REDMINE_URL": "https://red.eltex.loc/", os.environ,
"REDMINE_USER": "test", {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_USER": "test", "REDMINE_PASSWORD": "secret"},
"REDMINE_PASSWORD": "secret" )
})
def test_config_valid(): def test_config_valid():
Config.validate() # не должно быть исключения Config.validate() # не должно быть исключения

View File

@@ -27,7 +27,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 101, "issue_id": 101,
"subject": "Реализовать фичу X", "subject": "Реализовать фичу X",
"status_ru": "В работе", "status_ru": "В работе",
"time_text": "4ч 30м" "time_text": "4ч 30м",
}, },
{ {
"project": "Проект A", "project": "Проект A",
@@ -37,7 +37,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 102, "issue_id": 102,
"subject": "Исправить баг Y", "subject": "Исправить баг Y",
"status_ru": "Решена", "status_ru": "Решена",
"time_text": "" "time_text": "",
}, },
# Проект A, v2.0 # Проект A, v2.0
{ {
@@ -48,7 +48,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 103, "issue_id": 103,
"subject": "Документация Z", "subject": "Документация Z",
"status_ru": "Ожидание", "status_ru": "Ожидание",
"time_text": "" "time_text": "",
}, },
# Проект B, без версии # Проект B, без версии
{ {
@@ -59,7 +59,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 201, "issue_id": 201,
"subject": "Обновить README", "subject": "Обновить README",
"status_ru": "Закрыто", "status_ru": "Закрыто",
"time_text": "" "time_text": "",
}, },
# Проект C, v1.0 # Проект C, v1.0
{ {
@@ -70,7 +70,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 301, "issue_id": 301,
"subject": "Настроить CI", "subject": "Настроить CI",
"status_ru": "В работе", "status_ru": "В работе",
"time_text": "3ч 15м" "time_text": "3ч 15м",
}, },
# Проект C, v1.1 # Проект C, v1.1
{ {
@@ -81,7 +81,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 302, "issue_id": 302,
"subject": "Добавить тесты", "subject": "Добавить тесты",
"status_ru": "В работе", "status_ru": "В работе",
"time_text": "" "time_text": "",
}, },
{ {
"project": "Проект C", "project": "Проект C",
@@ -91,7 +91,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"issue_id": 303, "issue_id": 303,
"subject": "Рефакторинг", "subject": "Рефакторинг",
"status_ru": "Решена", "status_ru": "Решена",
"time_text": "6ч 45м" "time_text": "6ч 45м",
}, },
] ]
@@ -117,7 +117,10 @@ FORMATTER_FACTORIES = [
("compact", lambda: CompactFormatter()), ("compact", lambda: CompactFormatter()),
("csv", lambda: CSVFormatter()), ("csv", lambda: CSVFormatter()),
("markdown", lambda: MarkdownFormatter()), ("markdown", lambda: MarkdownFormatter()),
("odt", lambda: ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31")), (
"odt",
lambda: ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31"),
),
] ]

View File

@@ -19,6 +19,7 @@ def test_get_month_name_from_range():
def test_get_version(): def test_get_version():
class MockIssue: class MockIssue:
pass pass
issue_with = MockIssue() issue_with = MockIssue()
issue_with.fixed_version = "v2.5.0" issue_with.fixed_version = "v2.5.0"
assert get_version(issue_with) == "v2.5.0" assert get_version(issue_with) == "v2.5.0"