Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06cd57e2c4 | ||
|
|
d7e927e6eb | ||
|
|
000bf37503 | ||
|
|
dfb8d474b4 |
62
README.md
62
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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" }]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.4.0"
|
__version__ = "1.4.1"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
"""Форматтер для компактного вывода в консоль."""
|
"""Форматтер для компактного вывода в консоль."""
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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']}"
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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": "Ожидание",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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() # не должно быть исключения
|
||||||
|
|
||||||
|
|||||||
@@ -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": "2ч"
|
"time_text": "2ч",
|
||||||
},
|
},
|
||||||
# Проект 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": "1ч"
|
"time_text": "1ч",
|
||||||
},
|
},
|
||||||
# Проект 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": "0ч"
|
"time_text": "0ч",
|
||||||
},
|
},
|
||||||
# Проект 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": "5ч"
|
"time_text": "5ч",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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"),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user