15 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
937885a12b Bump version: 1.2.0 2026-01-22 10:59:48 +07:00
Кокос Артем Николаевич
932dd1198a Update .gitignore: ignore report.odt always 2026-01-22 10:58:42 +07:00
Кокос Артем Николаевич
0bff2363dc Add empty line after title 2026-01-22 10:55:42 +07:00
Кокос Артем Николаевич
9b260b27fd Use right cells style in ODT table 2026-01-22 10:45:37 +07:00
Кокос Артем Николаевич
a8511368ce Fix cells width 2026-01-21 18:02:00 +07:00
Кокос Артем Николаевич
7a8b629c7c Bump version: 1.1.0 2026-01-21 14:23:06 +07:00
Кокос Артем Николаевич
41c7ef24a3 Update README.md 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
5b813c76e9 Use last month in ODT-report 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
5a5ee00726 Config: Report author name 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
4a5dee7a14 Update template with table-styles 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
2c123e9ae7 ODT: Group by project & versions 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
9a2f753480 Album orient by using templ. doc 2026-01-21 14:22:18 +07:00
Artem Kokos
bca24189c7 ODT table support 2026-01-21 14:22:18 +07:00
Кокос Артем Николаевич
6fcc834617 Add lic 2026-01-21 10:44:53 +07:00
Кокос Артем Николаевич
9b28bf21e7 Fix grouping by version 2026-01-21 10:16:23 +07:00
13 changed files with 348 additions and 20 deletions

3
.gitignore vendored
View File

@@ -85,3 +85,6 @@ secrets.json
# Temporary files
*.tmp
*.bak
# Just in case
report.odt

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Artem Kokos
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -4,6 +4,8 @@
> Предназначен для внутреннего использования в Eltex. Работает с `https://red.eltex.loc/`.
📄 **Лицензия**: [MIT](./LICENSE) — делайте что угодно.
---
## 🔧 Возможности
@@ -14,6 +16,7 @@
- Перевод статусов на русский язык
- Простой CLI с понятными аргументами
- Поддержка настройки диапазона дат по умолчанию через `.env`
- Экспорт в ODT с автоматическим заголовком (автор + месяц)
---
@@ -57,6 +60,7 @@ cat /etc/ssl/certs/ca-certificates.crt >> $(python -m certifi)
REDMINE_URL=https://red.eltex.loc/
REDMINE_USER=ваш.логин
REDMINE_PASSWORD=ваш_пароль
REDMINE_AUTHOR=Иванов Иван Иванович
# Опционально: диапазон дат по умолчанию
DEFAULT_FROM_DATE=2026-01-01
@@ -69,6 +73,7 @@ DEFAULT_TO_DATE=2026-01-31
export REDMINE_URL=https://red.eltex.loc/
export REDMINE_USER=ваш.логин
export REDMINE_PASSWORD=...
export REDMINE_AUTHOR="Иванов Иван Иванович"
export DEFAULT_FROM_DATE=2026-01-01
export DEFAULT_TO_DATE=2026-01-31
```
@@ -97,9 +102,32 @@ redmine-reporter --date 2026-02-01--2026-02-28
# Компактный вывод (удобно копировать в письмо)
redmine-reporter --compact
# Экспорт в ODT с указанием автора (если не задано в .env)
redmine-reporter --output report.odt --author "Иванов Иван Иванович"
```
Пример вывода:
> 💡 **Автоматика в ODT-отчёте**:
> - Месяц в заголовке определяется **автоматически** по дате окончания периода (`to_date`).
> Например: `2025-12-20--2026-01-15` → **«Январь»**.
> - Имя автора берётся из переменной окружения `REDMINE_AUTHOR` (в `.env`) или CLI-аргумента `--author`.
> - Первая пустая строка из шаблона `template.odt` **автоматически удаляется**.
Пример содержимого `.env` с автором:
```ini
REDMINE_URL=https://red.eltex.loc/
REDMINE_USER=ваш.логин
REDMINE_PASSWORD=ваш_пароль
REDMINE_AUTHOR=Иванов Иван Иванович
DEFAULT_FROM_DATE=2026-01-01
DEFAULT_TO_DATE=2026-01-31
```
Пример вывода в ODT (заголовок):
> **Иванов Иван Иванович. Отчёт за месяц Январь.**
Пример консольного вывода:
```
✅ Total issues: 7 [2026-01-01--2026-01-31]
╒════════════╤═══════════╤══════════════════════════════════════╤═══════════╤════════════╕
@@ -127,6 +155,6 @@ isort .
---
> 🔒 **Важно**:
> - Никогда не коммитьте `.env`, пароли или логины.
> - Никогда не коммитьте `.env`, пароли или логины.
> - Файл `.gitignore` уже исключает все чувствительные артефакты.
> - Инструмент работает только в режиме **чтения** — он не может изменять данные в Redmine.

View File

@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "redmine-reporter"
version = "1.0.0"
version = "1.2.0"
description = "Redmine time-entry based issue reporter for internal use"
readme = "README.md"
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
license = { text = "Proprietary" }
license = { text = "MIT" }
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
@@ -23,6 +23,7 @@ dependencies = [
"python-redmine>=2.4.0",
"tabulate>=0.9.0",
"python-dotenv>=1.0.0",
"odfpy>=1.4.0",
]
[project.optional-dependencies]

View File

@@ -1 +1 @@
__version__ = "1.0.0"
__version__ = "1.2.0"

View File

@@ -2,9 +2,11 @@ import sys
import argparse
from typing import List, Optional
from redminelib.resources import Issue
from .config import Config
from .client import fetch_issues_with_spent_time
from .formatter import format_compact, format_table
from .formatter_odt import format_odt
def parse_date_range(date_arg: str) -> tuple[str, str]:
@@ -32,6 +34,15 @@ def main(argv: Optional[List[str]] = None) -> int:
action="store_true",
help="Use compact plain-text output instead of table"
)
parser.add_argument(
"--output",
help="Path to output .odt file (e.g., report.odt). If omitted, prints to stdout."
)
parser.add_argument(
"--author",
default="",
help="Override author name from .env (REDMINE_AUTHOR)"
)
args = parser.parse_args(argv)
try:
@@ -58,15 +69,31 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]")
try:
if args.compact:
output = format_compact(issue_hours)
else:
output = format_table(issue_hours)
print(output)
except Exception as e:
print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1
if args.output:
if not args.output.endswith(".odt"):
print("❌ Output file must end with .odt", file=sys.stderr)
return 1
try:
author = Config.get_author(args.author)
doc = format_odt(issue_hours, author=author, from_date=from_date, to_date=to_date)
doc.save(args.output)
print(f"✅ Report saved to {args.output}")
except ImportError:
print("❌ odfpy is not installed. Install with: pip install odfpy", file=sys.stderr)
return 1
except Exception as e:
print(f"❌ ODT export error: {e}", file=sys.stderr)
return 1
else:
try:
if args.compact:
output = format_compact(issue_hours)
else:
output = format_table(issue_hours)
print(output)
except Exception as e:
print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1
return 0

View File

@@ -2,6 +2,7 @@ from typing import List, Optional, Dict, Tuple
from redminelib import Redmine
from redminelib.resources import Issue
from .config import Config
from .utils import get_version
def fetch_issues_with_spent_time(from_date: str, to_date: str) -> Optional[List[Tuple[Issue, float]]]:
@@ -51,6 +52,7 @@ def fetch_issues_with_spent_time(from_date: str, to_date: str) -> Optional[List[
total_hours = spent_time.get(issue.id, 0.0)
result.append((issue, total_hours))
# Сортируем по проекту (Redmine API уже сортирует, но для надёжности)
result.sort(key=lambda x: str(x[0].project))
# Сортируем по (проект, версия)
result.sort(key=lambda x: (str(x[0].project), get_version(x[0])))
return result

View File

@@ -9,9 +9,19 @@ class Config:
REDMINE_URL = os.getenv("REDMINE_URL", "").rstrip("/")
REDMINE_USER = os.getenv("REDMINE_USER")
REDMINE_PASSWORD = os.getenv("REDMINE_PASSWORD")
REDMINE_AUTHOR = os.getenv("REDMINE_AUTHOR")
DEFAULT_FROM_DATE = os.getenv("DEFAULT_FROM_DATE")
DEFAULT_TO_DATE = os.getenv("DEFAULT_TO_DATE")
@classmethod
def get_author(cls, cli_author: str = "") -> str:
"""Возвращает автора: из CLI если задан, иначе из .env, иначе — заглушку."""
if cli_author:
return cli_author
if cls.REDMINE_AUTHOR:
return cls.REDMINE_AUTHOR
return ""
@classmethod
def get_default_date_range(cls) -> str:
if cls.DEFAULT_FROM_DATE and cls.DEFAULT_TO_DATE:

View File

@@ -1,5 +1,6 @@
from typing import List, Tuple
from redminelib.resources import Issue
from .utils import get_version
STATUS_TRANSLATION = {
@@ -15,10 +16,6 @@ STATUS_TRANSLATION = {
}
def get_version(issue: Issue) -> str:
return str(getattr(issue, 'fixed_version', '<N/A>'))
def hours_to_human(hours: float) -> str:
if hours <= 0:
return ""

View File

@@ -0,0 +1,143 @@
import os
from typing import List, Tuple
from redminelib.resources import Issue
from odf.opendocument import load
from odf.text import P
from odf.table import Table, TableColumn, TableRow, TableCell
from odf.style import Style, TableColumnProperties, TableCellProperties
from .formatter import get_version, hours_to_human, STATUS_TRANSLATION
from .utils import get_month_name_from_range
def format_odt(
issue_hours: List[Tuple[Issue, float]],
author: str = "",
from_date: str = "",
to_date: str = ""
) -> "OpenDocument":
template_path = "template.odt"
if not os.path.exists(template_path):
raise FileNotFoundError("Шаблон template.odt не найден...")
doc = load(template_path)
para_style_name = "Standard"
# Заголовок
month_name = get_month_name_from_range(from_date, to_date)
header_text = f"{author}. Отчет за месяц {month_name}."
header_paragraph = P(stylename=para_style_name, text=header_text)
doc.text.addElement(header_paragraph)
# Добавляем пустую строку (новый параграф без текста)
empty_paragraph = P(stylename=para_style_name, text="")
doc.text.addElement(empty_paragraph)
# Группировка: project - version - [(issue, hours, status_ru)]
projects = {}
for issue, hours in issue_hours:
project = str(issue.project)
version = get_version(issue)
status_en = str(issue.status)
status_ru = STATUS_TRANSLATION.get(status_en, status_en)
if project not in projects:
projects[project] = {}
if version not in projects[project]:
projects[project][version] = []
projects[project][version].append((issue, hours, status_ru))
# Создаем стиль для ячеек таблицы
cell_style_name = "TableCellStyle"
cell_style = Style(name=cell_style_name, family="table-cell")
# Устанавливаем отступы (Padding)
cell_props = TableCellProperties(
padding="0.04in",
border="0.05pt solid #000000"
)
cell_style.addElement(cell_props)
doc.automaticstyles.addElement(cell_style)
# Создаем стиль для всей таблицы (опционально, но может понадобиться)
table_style_name = "ReportTableStyle"
table_style = Style(name=table_style_name, family="table")
# Создаем таблицу и применяем стиль
table = Table(name="Report", stylename=table_style_name)
# Добавляем стили для каждой колонки (ширины)
column_widths = ["1.56in", "1.63in", "3.93in", "1.56in", "1.43in"]
for i, width in enumerate(column_widths):
col_style_name = f"col{i+1}"
col_style = Style(name=col_style_name, family="table-column")
col_props = TableColumnProperties(columnwidth=width, breakbefore="auto")
col_style.addElement(col_props)
doc.automaticstyles.addElement(col_style)
table.addElement(TableColumn(stylename=col_style))
# Заголовки
header_row = TableRow()
headers = ["Наименование Проекта", "Номер версии*", "Задача", "Статус Готовность*", "Затрачено за отчетный период"]
for text in headers:
cell = TableCell(stylename=cell_style_name)
p = P(stylename=para_style_name, text=text)
cell.addElement(p)
header_row.addElement(cell)
table.addElement(header_row)
# Данные с двухуровневой группировкой и объединением ячеек
for project, versions in projects.items():
total_project_rows = sum(len(rows) for rows in versions.values())
first_version_in_project = True
for version, rows in versions.items():
row_span_version = len(rows)
first_row_in_version = True
for issue, hours, status_ru in rows:
row = TableRow()
# Ячейка "Проект" - только в первой строке всего проекта
if first_version_in_project and first_row_in_version:
cell_project = TableCell(stylename=cell_style_name)
cell_project.setAttribute("numberrowsspanned", str(total_project_rows))
p = P(stylename=para_style_name, text=project)
cell_project.addElement(p)
row.addElement(cell_project)
# Ячейка "Версия" - только в первой строке каждой версии
if first_row_in_version:
cell_version = TableCell(stylename=cell_style_name)
cell_version.setAttribute("numberrowsspanned", str(row_span_version))
p = P(stylename=para_style_name, text=version)
cell_version.addElement(p)
row.addElement(cell_version)
first_row_in_version = False
else:
# Пропускаем - уже объединена
pass
# Остальные колонки
task_cell = TableCell(stylename=cell_style_name)
p = P(stylename=para_style_name, text=f"{issue.id}. {issue.subject}")
task_cell.addElement(p)
row.addElement(task_cell)
status_cell = TableCell(stylename=cell_style_name)
p = P(stylename=para_style_name, text=status_ru)
status_cell.addElement(p)
row.addElement(status_cell)
time_cell = TableCell(stylename=cell_style_name)
p = P(stylename=para_style_name, text=hours_to_human(hours))
time_cell.addElement(p)
row.addElement(time_cell)
table.addElement(row)
first_version_in_project = False
doc.text.addElement(table)
return doc

23
redmine_reporter/utils.py Normal file
View File

@@ -0,0 +1,23 @@
from datetime import datetime
def get_month_name_from_range(from_date: str, to_date: str) -> str:
"""Определяет название месяца по диапазону дат.
- Если from == to - возвращает месяц этой даты.
- Если диапазон охватывает несколько месяцев - возвращает месяц из to_date.
"""
try:
end = datetime.strptime(to_date, "%Y-%m-%d")
except ValueError:
return "Январь" # fallback, хотя лучше бы не срабатывало
months = [
"", "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"
]
return months[end.month]
def get_version(issue) -> str:
return str(getattr(issue, 'fixed_version', '<N/A>'))

BIN
template.odt Normal file

Binary file not shown.

View File

@@ -0,0 +1,73 @@
import tempfile
from types import SimpleNamespace
from redmine_reporter.formatter_odt import format_odt
def make_mock_issue(id_, project, subject, status, fixed_version=None):
"""Создаёт лёгкий mock-объект, имитирующий Issue из redminelib."""
issue = SimpleNamespace()
issue.id = id_
issue.project = project
issue.subject = subject
issue.status = status
if fixed_version is not None:
issue.fixed_version = fixed_version
return issue
def test_format_odt_basic():
issues = [
(make_mock_issue(101, "Камеры", "Поддержка нового датчика", "In Progress", "v2.5.0"), 2.5),
(make_mock_issue(102, "Камеры", "Исправить утечку памяти", "Resolved", "v2.5.0"), 4.0),
(make_mock_issue(103, "ПО", "Обновить документацию", "Pending", None), 12.0),
]
doc = format_odt(issues)
# Сохраняем и проверяем содержимое
with tempfile.NamedTemporaryFile(suffix=".odt") as tmp:
doc.save(tmp.name)
# Проверяем, что файл - это ZIP (ODT основан на ZIP)
with open(tmp.name, "rb") as f:
assert f.read(2) == b"PK"
# Извлекаем content.xml
import zipfile
with zipfile.ZipFile(tmp.name) as zf:
content_xml = zf.read("content.xml").decode("utf-8")
# Проверяем заголовки
assert "Проект" in content_xml
assert "Версия" in content_xml
assert "Задача" in content_xml
assert "Статус" in content_xml
assert "Затрачено" in content_xml
# Проверяем данные задач
assert "101. Поддержка нового датчика" in content_xml
assert "102. Исправить утечку памяти" in content_xml
assert "103. Обновить документацию" in content_xml
# Проверяем проекты и версии
assert "Камеры" in content_xml
assert "ПО" in content_xml
assert "v2.5.0" in content_xml
assert "&lt;N/A&gt;" in content_xml or "<N/A>" in content_xml # зависит от экранирования
# Проверяем перевод статусов
assert "В работе" in content_xml # In Progress
assert "Решена" in content_xml # Resolved
assert "Ожидание" in content_xml # Pending
# Проверяем формат времени
assert "2ч 30м" in content_xml
assert "" in content_xml
assert "12ч" in content_xml
# Проверяем группировку: "Камеры" должен встречаться только один раз явно
# (вторая строка — пустая ячейка)
cam_occurrences = content_xml.count(">Камеры<")
assert cam_occurrences == 1