4 Commits
v1.4.0 ... main

Author SHA1 Message Date
Кокос Артем Николаевич
06cd57e2c4 Blacked 2026-02-05 15:31:31 +07:00
Кокос Артем Николаевич
d7e927e6eb Bump version: v1.4.1 2026-02-05 15:18:40 +07:00
Кокос Артем Николаевич
000bf37503 Update status map 2026-02-05 15:15:44 +07:00
Артём Кокос
dfb8d474b4 Update README.md 2026-01-26 21:03:24 +07:00
17 changed files with 137 additions and 114 deletions

View File

@@ -1,7 +1,6 @@
# redmine-reporter # redmine-reporter
Инструмент для генерации отчётов по задачам в Redmine на основе ваших записей о затраченном времени. Инструмент для генерации отчётов по задачам в Redmine на основе ваших записей о затраченном времени.
> Предназначен для внутреннего использования в Eltex. Работает с `https://red.eltex.loc/`. > Предназначен для внутреннего использования в Eltex. Работает с `https://red.eltex.loc/`.
📄 **Лицензия**: [MIT](./LICENSE) — делайте что угодно. 📄 **Лицензия**: [MIT](./LICENSE) — делайте что угодно.
@@ -11,16 +10,17 @@
## 🔧 Возможности ## 🔧 Возможности
- Безопасная передача учётных данных через переменные окружения или `.env` - Безопасная передача учётных данных через переменные окружения или `.env`
- Два режима вывода: компактный (для копирования) и табличный (для просмотра)
- Группировка задач по проекту и версии - Группировка задач по проекту и версии
- Перевод статусов на русский язык - Поддержка нескольких форматов экспорта: **ODT**, **CSV**, **Markdown**, **HTML**
- Два режима вывода в консоль: табличный (красивая таблица) и компактный (для копирования)
- Перевод статусов задач на русский язык
- Автоматическое определение месяца отчёта по дате окончания периода (для **ODT**)
- Простой CLI с понятными аргументами - Простой CLI с понятными аргументами
- Поддержка настройки диапазона дат по умолчанию через `.env` - Поддержка настройки диапазона дат по умолчанию через `.env`
- Экспорт в ODT, CSV и Markdown
--- ---
## 🚀 Установка и настройка (продакшен) ## 🚀 Установка и настройка
### 1. Клонируйте репозиторий ### 1. Клонируйте репозиторий
@@ -29,7 +29,7 @@ git clone https://git.akokos.ru/artem.kokos/redmine-reporter.git
cd redmine-reporter cd redmine-reporter
``` ```
### 2. Создайте изолированное окружение и установите зависимости ### 2. Создайте виртуальное окружение и установите зависимости
```bash ```bash
python3 -m venv .venv python3 -m venv .venv
@@ -52,7 +52,7 @@ cat /etc/ssl/certs/ca-certificates.crt >> $(python -m certifi)
> ✅ Это безопасно: вы просто добавляете доверенные системные сертификаты к Python. > ✅ Это безопасно: вы просто добавляете доверенные системные сертификаты к Python.
> Не используйте `verify=False` — это создаёт уязвимость. > Не используйте `verify=False` — это создаёт уязвимость.
### 4. Настройте учётные данные и (опционально) даты ### 4. Настройте учётные данные
Создайте файл `.env` в корне проекта (**никогда не коммитьте его!**): Создайте файл `.env` в корне проекта (**никогда не коммитьте его!**):
@@ -74,12 +74,9 @@ export REDMINE_URL=https://red.eltex.loc/
export REDMINE_USER=ваш.логин export REDMINE_USER=ваш.логин
export REDMINE_PASSWORD=... export REDMINE_PASSWORD=...
export REDMINE_AUTHOR="Иванов Иван Иванович" export REDMINE_AUTHOR="Иванов Иван Иванович"
export DEFAULT_FROM_DATE=2026-01-01
export DEFAULT_TO_DATE=2026-01-31
``` ```
> 🔐 Рекомендуется использовать аккаунт с минимальными правами (только чтение time entries и задач). > 🔐 Рекомендуется использовать аккаунт с минимальными правами (только чтение time entries и задач).
> 💡 Если `DEFAULT_FROM_DATE` и `DEFAULT_TO_DATE` не заданы, используется встроенный диапазон: `2025-12-19--2026-01-31`.
--- ---
@@ -97,45 +94,52 @@ source .venv/bin/activate
# Отчёт за период по умолчанию (из .env или встроенный) # Отчёт за период по умолчанию (из .env или встроенный)
redmine-reporter redmine-reporter
# Отчёт за произвольный период (переопределяет .env) # Отчёт за произвольный период
redmine-reporter --date 2026-02-01--2026-02-28 redmine-reporter --date 2026-02-01--2026-02-28
# Компактный вывод (удобно копировать в письмо) # Компактный вывод (удобно копировать в письмо)
redmine-reporter --compact redmine-reporter --compact
# Экспорт в ODT с указанием автора (если не задано в .env) # Экспорт в ODT
redmine-reporter --output report.odt --author "Иванов Иван Иванович" redmine-reporter --output report.odt
# Экспорт в CSV
redmine-reporter --output report.csv
# Экспорт в Markdown
redmine-reporter --output report.md
# Экспорт в HTML
redmine-reporter --output report.html
``` ```
> 💡 **Автоматика в ODT-отчёте**: > 💡 **Автоматика в ODT-отчёте**:
> - Месяц в заголовке определяется **автоматически** по дате окончания периода (`to_date`). > - Месяц в заголовке определяется **автоматически** по дате окончания периода (`to_date`).
> Например: `2025-12-20--2026-01-15` → **«Январь»**. > Например: `2025-12-20--2026-01-15` → **«Январь»**.
> - Имя автора берётся из переменной окружения `REDMINE_AUTHOR` (в `.env`) или CLI-аргумента `--author`. > - Имя автора берётся из `REDMINE_AUTHOR` (в `.env`) или CLI-аргумента `--author`.
> - Первая пустая строка из шаблона `template.odt` **автоматически удаляется**.
Пример содержимого `.env` с автором: Пример содержимого `.env`:
```ini ```ini
REDMINE_URL=https://red.eltex.loc/ REDMINE_URL=https://red.eltex.loc/
REDMINE_USER=ваш.логин REDMINE_USER=ivanov.ivan
REDMINE_PASSWORD=ваш_пароль REDMINE_PASSWORD=supersecret
REDMINE_AUTHOR=Иванов Иван Иванович REDMINE_AUTHOR=Иванов Иван
DEFAULT_FROM_DATE=2026-01-01 DEFAULT_FROM_DATE=2026-01-01
DEFAULT_TO_DATE=2026-01-31 DEFAULT_TO_DATE=2026-01-31
``` ```
Пример вывода в ODT (заголовок):
> **Иванов Иван Иванович. Отчёт за месяц Январь.**
Пример консольного вывода: Пример консольного вывода:
``` ```
✅ Total issues: 7 [2026-01-01--2026-01-31] ✅ Total issues: 7 [2026-01-01--2026-01-31]
╒════════════╤═══════════╤══════════════════════════════════════╤═══════════╤════════════╕ ╒════════════╤═══════════╤══════════════════════════════════════╤═══════════╤════════════╕
│ Проект │ Версия │ Задача │ Статус │ Затрачено │ │ Проект │ Версия │ Задача │ Статус │ Затрачено │
╞════════════╪═══════════╪══════════════════════════════════════╪═══════════╪════════════╡ ╞════════════╪═══════════╪══════════════════════════════════════╪═══════════╪════════════╡
│ Камеры │ v2.5.0 │ 12345. Поддержка нового датчика │ В работе │ 2.00h │ Камеры │ v2.5.0 │ 12345. Поддержка нового датчика │ В работе │ 2ч 30м
│ │ │ 12346. Исправить утечку памяти │ Решена │ 2.00h │ │ │ 12346. Исправить утечку памяти │ Решена │ 2ч
│ ПО │ <N/A> │ 12350. Обновить документацию │ Ожидание │ 12.00h │ ПО │ <N/A> │ 12350. Обновить документацию │ Ожидание │ 12ч
╘════════════╧═══════════╧══════════════════════════════════════╧═══════════╧════════════╛ ╘════════════╧═══════════╧══════════════════════════════════════╧═══════════╧════════════╛
``` ```
@@ -154,7 +158,7 @@ isort .
--- ---
> 🔒 **Важно**: > 🔒 **Важно**:
> - Никогда не коммитьте `.env`, пароли или логины. > - Никогда не коммитьте `.env`, пароли или логины.
> - Файл `.gitignore` уже исключает все чувствительные артефакты. > - Файл `.gitignore` уже исключает все чувствительные артефакты.
> - Инструмент работает только в режиме **чтения** — он не может изменять данные в Redmine. > - Инструмент работает только в режиме **чтения** — он не может изменять данные в Redmine.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "redmine-reporter" name = "redmine-reporter"
version = "1.4.0" version = "1.4.1"
description = "Redmine time-entry based issue reporter for internal use" description = "Redmine time-entry based issue reporter for internal use"
readme = "README.md" readme = "README.md"
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]

View File

@@ -1 +1 @@
__version__ = "1.4.0" __version__ = "1.4.1"

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,17 +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 = {
'Closed': 'Закрыто', "New": "В работе",
'Re-opened': 'В работе', "In Progress": "В работе",
'New': 'В работе', "Feedback": "В работе",
'Resolved': 'Решена', "Re-opened": "В работе",
'Pending': 'Ожидание', "Code Review": "Решена",
'Feedback': 'В работе', "Wait Release": "Закрыто",
'In Progress': 'В работе', "Pending": "Ожидание",
'Rejected': 'Закрыто', "Resolved": "Решена",
'Confirming': 'Ожидание', "Testing": "Решена",
"Confirming": "Ожидание",
"Closed": "Закрыто",
"Rejected": "Закрыто",
"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"