From 2db0ab1f0b371bb40a9b2ea7533737c9442030b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Fri, 22 May 2026 17:41:56 +0700 Subject: [PATCH] Tighten configuration and export handling --- README.md | 220 +++++++++++------------- pyproject.toml | 7 + redmine_reporter/cli.py | 29 +++- redmine_reporter/client.py | 22 +-- redmine_reporter/config.py | 57 ++++-- redmine_reporter/formatters/base.py | 1 + redmine_reporter/formatters/console.py | 4 +- redmine_reporter/formatters/csv.py | 7 +- redmine_reporter/formatters/factory.py | 7 +- redmine_reporter/formatters/html.py | 18 +- redmine_reporter/formatters/markdown.py | 16 +- redmine_reporter/formatters/odt.py | 30 ++-- redmine_reporter/report_builder.py | 10 +- redmine_reporter/utils.py | 5 +- tests/test_cli.py | 81 +++++---- tests/test_client.py | 110 ++++++------ tests/test_config.py | 96 +++++++---- tests/test_formatters.py | 44 +++-- tests/test_report_builder.py | 2 +- tests/test_utils.py | 7 +- 20 files changed, 423 insertions(+), 350 deletions(-) diff --git a/README.md b/README.md index 8cbd374..fcd8e2f 100644 --- a/README.md +++ b/README.md @@ -1,171 +1,151 @@ # redmine-reporter -Инструмент для генерации отчётов по задачам в Redmine на основе ваших записей о затраченном времени. -> Предназначен для внутреннего использования в Eltex. Работает с `https://red.eltex.loc/`. +CLI-инструмент для генерации отчётов по задачам Redmine на основе записей о затраченном времени. -📄 **Лицензия**: [MIT](./LICENSE) — делайте что угодно. +Проект предназначен для внутреннего использования с `https://red.eltex.loc/`. ---- +Лицензия: MIT. -## 🔧 Возможности +## Возможности -- Безопасная передача учётных данных через переменные окружения или `.env` -- Авторизация через Redmine API token или через логин/пароль для обратной совместимости -- Группировка задач по проекту и версии -- Поддержка нескольких форматов экспорта: **ODT**, **CSV**, **Markdown**, **HTML** -- Два режима вывода в консоль: табличный (красивая таблица) и компактный (для копирования) -- Перевод статусов задач на русский язык -- Автоматическое определение месяца отчёта по дате окончания периода (для **ODT**) -- Простой CLI с понятными аргументами -- Поддержка настройки диапазона дат по умолчанию через `.env` +- Получение time entries текущего пользователя из Redmine. +- Авторизация через Redmine API token. +- Резервная авторизация через логин и пароль для обратной совместимости. +- Группировка задач по проекту и версии. +- Перевод статусов задач на русский язык. +- Вывод в консоль в табличном или компактном виде. +- Экспорт в ODT, CSV, Markdown и HTML. +- Автоматическое определение месяца ODT-отчёта по конечной дате периода. +- Настройка периода отчёта по умолчанию через `.env`. ---- - -## 🚀 Установка и настройка - -### 1. Клонируйте репозиторий +## Установка ```bash git clone https://git.akokos.ru/artem.kokos/redmine-reporter.git cd redmine-reporter -``` - -### 2. Создайте виртуальное окружение и установите зависимости - -```bash python3 -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install . ``` -> 💡 Установка в виртуальное окружение — стандарт для Python-инструментов. Это безопасно и не влияет на систему. +## Настройка -### 3. Настройте доверие к корпоративному сертификату (обязательно!) +Создайте файл `.env` в корне проекта. Файл не должен попадать в git. -По умолчанию Python использует собственный набор сертификатов (`certifi`), который **не включает** внутренние CA Eltex. -Чтобы избежать ошибки `CERTIFICATE_VERIFY_FAILED`, выполните **один раз**: - -```bash -cat /etc/ssl/certs/ca-certificates.crt >> $(python -m certifi) -``` - -> ✅ Это безопасно: вы просто добавляете доверенные системные сертификаты к Python. -> ❌ Не используйте `verify=False` — это создаёт уязвимость. - -### 4. Настройте учётные данные - -Создайте файл `.env` в корне проекта (**никогда не коммитьте его!**): +Рекомендуемый вариант авторизации: ```ini REDMINE_URL=https://red.eltex.loc/ REDMINE_API_KEY=ваш_api_token REDMINE_AUTHOR=Иванов Иван Иванович -# Опционально: диапазон дат по умолчанию DEFAULT_FROM_DATE=2026-01-01 DEFAULT_TO_DATE=2026-01-31 ``` -Если `REDMINE_API_KEY` задан, он используется в первую очередь. Старый способ с логином и паролем остаётся доступен для обратной совместимости: +Если задан `REDMINE_API_KEY`, он используется в первую очередь. + +Резервный вариант авторизации: ```ini REDMINE_URL=https://red.eltex.loc/ REDMINE_USER=ваш.логин REDMINE_PASSWORD=ваш_пароль REDMINE_AUTHOR=Иванов Иван Иванович -``` - -Альтернатива — задать переменные вручную: - -```bash -export REDMINE_URL=https://red.eltex.loc/ -export REDMINE_API_KEY=... -export REDMINE_AUTHOR="Иванов Иван Иванович" -``` - -> 🔐 Рекомендуется использовать аккаунт с минимальными правами (только чтение time entries и задач). - ---- - -## ▶️ Использование - -Перед каждым запуском активируйте окружение: - -```bash -source .venv/bin/activate -``` - -Затем: - -```bash -# Отчёт за период по умолчанию (из .env или встроенный) -redmine-reporter - -# Отчёт за произвольный период -redmine-reporter --date 2026-02-01--2026-02-28 - -# Компактный вывод (удобно копировать в письмо) -redmine-reporter --compact - -# Экспорт в ODT -redmine-reporter --output report.odt - -# Экспорт в CSV -redmine-reporter --output report.csv - -# Экспорт в Markdown -redmine-reporter --output report.md - -# Экспорт в HTML -redmine-reporter --output report.html -``` - -> 💡 **Автоматика в ODT-отчёте**: -> - Месяц в заголовке определяется **автоматически** по дате окончания периода (`to_date`). -> Например: `2025-12-20--2026-01-15` → **«Январь»**. -> - Имя автора берётся из `REDMINE_AUTHOR` (в `.env`) или CLI-аргумента `--author`. - -Пример содержимого `.env`: - -```ini -REDMINE_URL=https://red.eltex.loc/ -REDMINE_API_KEY=supersecret_api_token -REDMINE_AUTHOR=Иванов Иван DEFAULT_FROM_DATE=2026-01-01 DEFAULT_TO_DATE=2026-01-31 ``` -Пример консольного вывода: +Переменные окружения: -``` -✅ Total issues: 7 [2026-01-01--2026-01-31] -╒════════════╤═══════════╤══════════════════════════════════════╤═══════════╤════════════╕ -│ Проект │ Версия │ Задача │ Статус │ Затрачено │ -╞════════════╪═══════════╪══════════════════════════════════════╪═══════════╪════════════╡ -│ Камеры │ v2.5.0 │ 12345. Поддержка нового датчика │ В работе │ 2ч 30м │ -│ │ │ 12346. Исправить утечку памяти │ Решена │ 2ч │ -│ ПО │ │ 12350. Обновить документацию │ Ожидание │ 12ч │ -╘════════════╧═══════════╧══════════════════════════════════════╧═══════════╧════════════╛ +| Переменная | Обязательность | Описание | +| --- | --- | --- | +| `REDMINE_URL` | Да | URL Redmine. | +| `REDMINE_API_KEY` | Да, если нет логина и пароля | Redmine API token. | +| `REDMINE_USER` | Да, если нет токена | Логин Redmine. | +| `REDMINE_PASSWORD` | Да, если нет токена | Пароль Redmine. | +| `REDMINE_AUTHOR` | Нет | Имя автора для ODT-отчёта. | +| `DEFAULT_FROM_DATE` | Нет | Начальная дата периода по умолчанию в формате `YYYY-MM-DD`. | +| `DEFAULT_TO_DATE` | Нет | Конечная дата периода по умолчанию в формате `YYYY-MM-DD`. | +| `REDMINE_VERIFY` | Нет | Настройка TLS-проверки для Redmine API. | + +`REDMINE_VERIFY` поддерживает значения: + +- пустое значение или отсутствие переменной: `/etc/ssl/certs/ca-certificates.crt`; +- `true`, `1`, `yes`, `on`: стандартная проверка сертификатов `requests`; +- `false`, `0`, `no`, `off`: отключить проверку сертификатов; +- любой другой текст: путь к CA bundle. + +Отключать проверку сертификатов не рекомендуется. + +## Использование + +```bash +source .venv/bin/activate ``` ---- +Отчёт за период по умолчанию: -## 🛠 Разработка +```bash +redmine-reporter +``` -Для участия в разработке: +Отчёт за произвольный период: + +```bash +redmine-reporter --date 2026-02-01--2026-02-28 +``` + +Период должен быть задан в формате `YYYY-MM-DD--YYYY-MM-DD`. Начальная дата не может быть позже конечной. + +Компактный вывод: + +```bash +redmine-reporter --compact +``` + +Экспорт: + +```bash +redmine-reporter --output report.odt +redmine-reporter --output report.csv +redmine-reporter --output report.md +redmine-reporter --output report.html +``` + +ODT-отчёт: + +- месяц в заголовке определяется по `to_date`; +- имя автора берётся из `--author`, затем из `REDMINE_AUTHOR`; +- если автор не задан, поле автора остаётся пустым. + +Вывод без затраченного времени: + +```bash +redmine-reporter --no-time +``` + +## Разработка + +Установка зависимостей для разработки: ```bash pip install -e ".[dev]" -pytest -black . -isort . ``` ---- +Проверки: -> 🔒 **Важно**: -> - Никогда не коммитьте `.env`, API token, пароли или логины. -> - Файл `.gitignore` уже исключает все чувствительные артефакты. -> - Инструмент работает только в режиме **чтения** — он не может изменять данные в Redmine. +```bash +pytest +ruff check redmine_reporter tests +black redmine_reporter tests +isort redmine_reporter tests +``` + +## Безопасность + +- Не коммитьте `.env`, API token, пароль или логин. +- Используйте аккаунт с минимальными правами, достаточными для чтения time entries и задач. +- Инструмент работает только в режиме чтения и не изменяет данные в Redmine. diff --git a/pyproject.toml b/pyproject.toml index f357915..2ab7da0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,3 +51,10 @@ target-version = ['py39'] [tool.isort] profile = "black" multi_line_output = 3 + +[tool.mypy] +warn_unused_configs = true + +[[tool.mypy.overrides]] +module = ["odf.*", "redminelib.*", "tabulate"] +ignore_missing_imports = true diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index e506444..0a3f6be 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -1,12 +1,14 @@ -import os -import sys import argparse +import os +import re +import sys +from datetime import datetime from typing import List, Optional -from .config import Config from .client import fetch_issues_with_spent_time +from .config import Config +from .formatters.factory import get_console_formatter, get_formatter_by_extension from .report_builder import build_grouped_report -from .formatters.factory import get_formatter_by_extension, get_console_formatter def parse_date_range(date_arg: str) -> tuple[str, str]: @@ -15,7 +17,22 @@ def parse_date_range(date_arg: str) -> tuple[str, str]: parts = date_arg.split("--", 1) if len(parts) != 2: raise ValueError("Invalid date range format") - return parts[0].strip(), parts[1].strip() + + from_date, to_date = parts[0].strip(), parts[1].strip() + date_pattern = r"\d{4}-\d{2}-\d{2}" + if not re.fullmatch(date_pattern, from_date) or not re.fullmatch(date_pattern, to_date): + raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD") + + try: + start = datetime.strptime(from_date, "%Y-%m-%d").date() + end = datetime.strptime(to_date, "%Y-%m-%d").date() + except ValueError as e: + raise ValueError("Date range contains invalid calendar date") from e + + if start > end: + raise ValueError("Date range start must be less than or equal to end") + + return start.isoformat(), end.isoformat() def main(argv: Optional[List[str]] = None) -> int: @@ -36,7 +53,7 @@ def main(argv: Optional[List[str]] = None) -> int: ) parser.add_argument( "--output", - help="Path to output .odt file (e.g., report.odt). If omitted, prints to stdout.", + help="Path to output file (.odt, .csv, .md, .html). If omitted, prints to stdout.", ) parser.add_argument( "--author", default="", help="Override author name from .env (REDMINE_AUTHOR)" diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py index 889965c..0ed73c5 100644 --- a/redmine_reporter/client.py +++ b/redmine_reporter/client.py @@ -1,17 +1,21 @@ from typing import Any, Dict, List, Optional, Tuple + from redminelib import Redmine from redminelib.resources import Issue + from .config import Config 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} + api_key = Config.get_redmine_api_key() + if api_key: + return {"key": api_key} + return { + "username": Config.get_redmine_user(), + "password": Config.get_redmine_password(), + } def fetch_issues_with_spent_time( @@ -24,9 +28,9 @@ def fetch_issues_with_spent_time( """ redmine = Redmine( - Config.REDMINE_URL, + Config.get_redmine_url(), **_get_redmine_auth_kwargs(), - requests=REQUESTS_OPTIONS, + requests={"verify": Config.get_redmine_verify()}, ) current_user = redmine.user.get("current") @@ -48,9 +52,7 @@ def fetch_issues_with_spent_time( # Загружаем полные объекты задач 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 = [] diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 37d1085..cada757 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -1,41 +1,68 @@ import os +from typing import Union + from dotenv import load_dotenv load_dotenv() +DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt" +FALSE_VALUES = {"0", "false", "no", "off"} +TRUE_VALUES = {"1", "true", "yes", "on"} + class Config: - REDMINE_URL = os.getenv("REDMINE_URL", "").strip().rstrip("/") - REDMINE_API_KEY = os.getenv("REDMINE_API_KEY") - 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_redmine_url(cls) -> str: + return os.getenv("REDMINE_URL", "").strip().rstrip("/") + + @classmethod + def get_redmine_api_key(cls) -> str: + return os.getenv("REDMINE_API_KEY", "").strip() + + @classmethod + def get_redmine_user(cls) -> str: + return os.getenv("REDMINE_USER", "").strip() + + @classmethod + def get_redmine_password(cls) -> str: + return os.getenv("REDMINE_PASSWORD", "") + + @classmethod + def get_redmine_verify(cls) -> Union[bool, str]: + value = os.getenv("REDMINE_VERIFY", "").strip() + if not value: + return DEFAULT_REDMINE_VERIFY + + normalized = value.lower() + if normalized in FALSE_VALUES: + return False + if normalized in TRUE_VALUES: + return True + return value @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 "" + return os.getenv("REDMINE_AUTHOR", "").strip() @classmethod def get_default_date_range(cls) -> str: - if cls.DEFAULT_FROM_DATE and cls.DEFAULT_TO_DATE: - return f"{cls.DEFAULT_FROM_DATE}--{cls.DEFAULT_TO_DATE}" + default_from_date = os.getenv("DEFAULT_FROM_DATE", "").strip() + default_to_date = os.getenv("DEFAULT_TO_DATE", "").strip() + if default_from_date and default_to_date: + return f"{default_from_date}--{default_to_date}" # fallback hardcoded return "2025-12-19--2026-01-31" @classmethod def validate(cls) -> None: - if not cls.REDMINE_URL: + if not cls.get_redmine_url(): raise ValueError("REDMINE_URL is required (set via env or .env)") - if cls.REDMINE_API_KEY: + if cls.get_redmine_api_key(): return - if not (cls.REDMINE_USER and cls.REDMINE_PASSWORD): + if not (cls.get_redmine_user() and cls.get_redmine_password()): raise ValueError( "REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD" ) diff --git a/redmine_reporter/formatters/base.py b/redmine_reporter/formatters/base.py index ce9ec8c..89ee287 100644 --- a/redmine_reporter/formatters/base.py +++ b/redmine_reporter/formatters/base.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from typing import Any, List + from ..types import ReportRow diff --git a/redmine_reporter/formatters/console.py b/redmine_reporter/formatters/console.py index 072ac3b..97b8060 100644 --- a/redmine_reporter/formatters/console.py +++ b/redmine_reporter/formatters/console.py @@ -1,7 +1,9 @@ from typing import List + from tabulate import tabulate -from .base import Formatter + from ..types import ReportRow +from .base import Formatter class TableFormatter(Formatter): diff --git a/redmine_reporter/formatters/csv.py b/redmine_reporter/formatters/csv.py index 5d3b761..a1cbd2c 100644 --- a/redmine_reporter/formatters/csv.py +++ b/redmine_reporter/formatters/csv.py @@ -1,8 +1,9 @@ import csv import io from typing import List -from .base import Formatter + from ..types import ReportRow +from .base import Formatter class CSVFormatter(Formatter): @@ -14,9 +15,7 @@ class CSVFormatter(Formatter): def format(self, rows: List[ReportRow]) -> str: output = io.StringIO() 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: writer.writerow( [ diff --git a/redmine_reporter/formatters/factory.py b/redmine_reporter/formatters/factory.py index b477a04..3d2abd1 100644 --- a/redmine_reporter/formatters/factory.py +++ b/redmine_reporter/formatters/factory.py @@ -1,10 +1,11 @@ -from typing import Dict, Type, Optional +from typing import Dict, Optional, Type + from .base import Formatter -from .console import TableFormatter, CompactFormatter +from .console import CompactFormatter, TableFormatter from .csv import CSVFormatter +from .html import HTMLFormatter from .markdown import MarkdownFormatter from .odt import ODTFormatter -from .html import HTMLFormatter # Словарь для сопоставления расширений файлов с классами форматтеров FORMATTER_MAP: Dict[str, Type[Formatter]] = { diff --git a/redmine_reporter/formatters/html.py b/redmine_reporter/formatters/html.py index 29b7765..f2459c7 100644 --- a/redmine_reporter/formatters/html.py +++ b/redmine_reporter/formatters/html.py @@ -1,6 +1,8 @@ +from html import escape from typing import Dict, List -from .base import Formatter + from ..types import ReportRow +from .base import Formatter class HTMLFormatter(Formatter): @@ -36,34 +38,38 @@ class HTMLFormatter(Formatter): ] for project, versions in projects.items(): + project_text = escape(project) total_project_rows = sum(len(tasks) for tasks in versions.values()) first_version_in_project = True for version, task_rows in versions.items(): + version_text = escape(version) row_span_version = len(task_rows) first_row_in_version = True for r in task_rows: + task_cell = escape(f"{r['issue_id']}. {r['subject']}") + status_text = escape(r["status_ru"]) + time_text = escape(r["time_text"]) lines.append(" ") # Ячейка "Проект" - только в первой строке проекта if first_version_in_project and first_row_in_version: lines.append( - f' {project}' + f' {project_text}' ) # Ячейка "Версия" - только в первой строке версии if first_row_in_version: lines.append( - f' {version}' + f' {version_text}' ) first_row_in_version = False # Остальные колонки - task_cell = f"{r['issue_id']}. {r['subject']}" lines.append(f" {task_cell}") - lines.append(f" {r['status_ru']}") - lines.append(f" {r['time_text']}") + lines.append(f" {status_text}") + lines.append(f" {time_text}") lines.append(" ") diff --git a/redmine_reporter/formatters/markdown.py b/redmine_reporter/formatters/markdown.py index fa5fc85..ca886a7 100644 --- a/redmine_reporter/formatters/markdown.py +++ b/redmine_reporter/formatters/markdown.py @@ -1,6 +1,11 @@ from typing import List -from .base import Formatter + from ..types import ReportRow +from .base import Formatter + + +def _escape_markdown_table_cell(value: object) -> str: + return str(value).replace("\\", "\\\\").replace("|", "\\|").replace("\n", "
") class MarkdownFormatter(Formatter): @@ -15,10 +20,13 @@ class MarkdownFormatter(Formatter): "|--------|--------|--------|--------|-----------|", ] for r in rows: - task_cell = f"{r['issue_id']}. {r['subject']}" + task_cell = _escape_markdown_table_cell(f"{r['issue_id']}. {r['subject']}") lines.append( - f"| {r['display_project']} | {r['display_version']} " - f"| {task_cell} | {r['status_ru']} | {r['time_text']} |" + f"| {_escape_markdown_table_cell(r['display_project'])} " + f"| {_escape_markdown_table_cell(r['display_version'])} " + f"| {task_cell} " + f"| {_escape_markdown_table_cell(r['status_ru'])} " + f"| {_escape_markdown_table_cell(r['time_text'])} |" ) return "\n".join(lines) diff --git a/redmine_reporter/formatters/odt.py b/redmine_reporter/formatters/odt.py index 6e7172e..0d71b2a 100644 --- a/redmine_reporter/formatters/odt.py +++ b/redmine_reporter/formatters/odt.py @@ -1,12 +1,14 @@ from importlib import resources -from typing import List +from typing import Dict, List + from odf.opendocument import OpenDocument, load +from odf.style import Style, TableCellProperties, TableColumnProperties +from odf.table import Table, TableCell, TableColumn, TableRow from odf.text import P -from odf.table import Table, TableColumn, TableRow, TableCell -from odf.style import Style, TableColumnProperties, TableCellProperties -from .base import Formatter + from ..types import ReportRow from ..utils import get_month_name_from_range +from .base import Formatter class ODTFormatter(Formatter): @@ -25,11 +27,7 @@ class ODTFormatter(Formatter): Форматирует данные в объект 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) para_style_name = "Standard" @@ -43,9 +41,7 @@ class ODTFormatter(Formatter): # Стиль ячеек cell_style_name = "TableCellStyle" 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) doc.automaticstyles.addElement(cell_style) @@ -73,7 +69,7 @@ class ODTFormatter(Formatter): header_row.addElement(cell) table.addElement(header_row) - projects = {} + projects: Dict[str, Dict[str, List[ReportRow]]] = {} for r in rows: project = r["project"] version = r["version"] @@ -103,9 +99,7 @@ class ODTFormatter(Formatter): # Ячейка "Проект" - только в первой строке всего проекта 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) - ) + cell_project.setAttribute("numberrowsspanned", str(total_project_rows)) p = P(stylename=para_style_name, text=project) cell_project.addElement(p) row.addElement(cell_project) @@ -113,9 +107,7 @@ class ODTFormatter(Formatter): # Ячейка "Версия" - только в первой строке каждой версии if first_row_in_version: 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) cell_version.addElement(p) row.addElement(cell_version) diff --git a/redmine_reporter/report_builder.py b/redmine_reporter/report_builder.py index 2a703c8..e3189e1 100644 --- a/redmine_reporter/report_builder.py +++ b/redmine_reporter/report_builder.py @@ -1,5 +1,7 @@ from typing import List, Tuple, cast + from redminelib.resources import Issue + from .types import ReportRow from .utils import get_version, hours_to_human @@ -33,9 +35,7 @@ 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] = [] prev_project: str = "" @@ -49,9 +49,7 @@ def build_grouped_report( time_text = hours_to_human(hours) if fill_time 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( cast( diff --git a/redmine_reporter/utils.py b/redmine_reporter/utils.py index d3e9b36..5011fd4 100644 --- a/redmine_reporter/utils.py +++ b/redmine_reporter/utils.py @@ -30,7 +30,10 @@ def get_month_name_from_range(from_date: str, to_date: str) -> str: def get_version(issue) -> str: """Возвращает версию задачи или '', если не задана.""" - return str(getattr(issue, "fixed_version", "")) + version = getattr(issue, "fixed_version", None) + if version is None: + return "" + return str(version) def hours_to_human(hours: float) -> str: diff --git a/tests/test_cli.py b/tests/test_cli.py index 9bdeb88..faad999 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,22 +1,44 @@ +import os import sys from io import StringIO from unittest import mock -from redmine_reporter.cli import main -from redmine_reporter.config import Config + +import pytest + +from redmine_reporter.cli import main, parse_date_range + +VALID_ENV = { + "REDMINE_URL": "https://red.eltex.loc", + "REDMINE_API_KEY": "token", +} -# Config читает env при импорте -- патчим класс напрямую. -# fetch_issues_with_spent_time импортируется в cli.py через "from .client import ...", -# поэтому мокать нужно имя в модуле cli, а не в client. - - -@mock.patch.multiple( - Config, - REDMINE_URL="https://red.eltex.loc", - REDMINE_API_KEY=None, - REDMINE_USER="x", - REDMINE_PASSWORD="y", +@pytest.mark.parametrize( + "date_arg, expected", + [ + ("2026-01-01--2026-01-31", ("2026-01-01", "2026-01-31")), + (" 2026-01-01 -- 2026-01-31 ", ("2026-01-01", "2026-01-31")), + ], ) +def test_parse_date_range_valid(date_arg, expected): + assert parse_date_range(date_arg) == expected + + +@pytest.mark.parametrize( + "date_arg", + [ + "20260101-20260131", + "2026-1-01--2026-01-31", + "2026-02-30--2026-03-01", + "2026-02-01--2026-01-31", + ], +) +def test_parse_date_range_invalid(date_arg): + with pytest.raises(ValueError): + parse_date_range(date_arg) + + +@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") def test_cli_smoke_empty(mock_fetch): """Пустой список задач -- выход 0, сообщение о 0 задачах.""" @@ -31,13 +53,7 @@ def test_cli_smoke_empty(mock_fetch): assert "Total issues: 0" in captured.getvalue() -@mock.patch.multiple( - Config, - REDMINE_URL="https://red.eltex.loc", - REDMINE_API_KEY=None, - REDMINE_USER="x", - REDMINE_PASSWORD="y", -) +@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") def test_cli_returns_zero_on_no_entries(mock_fetch): """None от fetch (нет time entries) -- выход 0.""" @@ -46,32 +62,21 @@ def test_cli_returns_zero_on_no_entries(mock_fetch): assert code == 0 -@mock.patch.multiple( - Config, - REDMINE_URL="", - REDMINE_API_KEY=None, - REDMINE_USER=None, - REDMINE_PASSWORD=None, -) +@mock.patch.dict(os.environ, {}, clear=True) def test_cli_config_error(): """Невалидный конфиг -- выход 1.""" code = main(["--date", "2026-01-01--2026-01-31"]) assert code == 1 +@mock.patch.dict(os.environ, VALID_ENV, clear=True) def test_cli_invalid_date_format(): """Неверный формат даты -- выход 1.""" code = main(["--date", "20260101-20260131"]) assert code == 1 -@mock.patch.multiple( - Config, - REDMINE_URL="https://red.eltex.loc", - REDMINE_API_KEY=None, - REDMINE_USER="x", - REDMINE_PASSWORD="y", -) +@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") def test_cli_unknown_output_extension(mock_fetch, tmp_path): """Неизвестное расширение файла -- выход 1.""" @@ -81,13 +86,7 @@ def test_cli_unknown_output_extension(mock_fetch, tmp_path): assert code == 1 -@mock.patch.multiple( - Config, - REDMINE_URL="https://red.eltex.loc", - REDMINE_API_KEY=None, - REDMINE_USER="x", - REDMINE_PASSWORD="y", -) +@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") def test_cli_output_without_extension(mock_fetch, tmp_path): """Файл без расширения -- выход 1 с подсказкой.""" diff --git a/tests/test_client.py b/tests/test_client.py index 4f6b8d3..ed3f5b5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,22 +1,28 @@ +import os from unittest import mock + from redmine_reporter.client import fetch_issues_with_spent_time -from redmine_reporter.config import Config +from redmine_reporter.config import DEFAULT_REDMINE_VERIFY + +PASSWORD_ENV = { + "REDMINE_URL": "https://red.eltex.loc", + "REDMINE_USER": "user", + "REDMINE_PASSWORD": "password", +} -@mock.patch.multiple( - Config, - REDMINE_URL="https://red.eltex.loc", - REDMINE_API_KEY=None, - REDMINE_USER="user", - REDMINE_PASSWORD="password", -) +def _configure_current_user(mock_redmine, user_id=1): + mock_user = mock.MagicMock() + mock_user.id = user_id + mock_redmine.user.get.return_value = mock_user + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch("redmine_reporter.client.Redmine") def test_fetch_aggregates_hours_per_issue(mock_redmine_class): """Два time entry на одну задачу -- часы суммируются.""" mock_redmine = mock_redmine_class.return_value - mock_user = mock.MagicMock() - mock_user.id = 123 - mock_redmine.user.get.return_value = mock_user + _configure_current_user(mock_redmine, user_id=123) mock_entry1 = mock.MagicMock() mock_entry1.issue.id = 101 @@ -41,40 +47,24 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class): 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.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch("redmine_reporter.client.Redmine") def test_fetch_returns_none_when_no_entries(mock_redmine_class): """Нет time entries -- возвращается None.""" mock_redmine = mock_redmine_class.return_value - mock_user = mock.MagicMock() - mock_user.id = 1 - mock_redmine.user.get.return_value = mock_user + _configure_current_user(mock_redmine) mock_redmine.time_entry.filter.return_value = [] result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31") 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.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch("redmine_reporter.client.Redmine") def test_fetch_skips_entries_without_issue(mock_redmine_class): """Time entry без привязки к задаче игнорируется.""" mock_redmine = mock_redmine_class.return_value - mock_user = mock.MagicMock() - mock_user.id = 1 - mock_redmine.user.get.return_value = mock_user + _configure_current_user(mock_redmine) # entry без issue атрибута entry_no_issue = mock.MagicMock(spec=["hours"]) # нет .issue @@ -86,20 +76,12 @@ def test_fetch_skips_entries_without_issue(mock_redmine_class): 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.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch("redmine_reporter.client.Redmine") def test_fetch_multiple_issues(mock_redmine_class): """Несколько задач -- каждая с правильным суммарным временем.""" mock_redmine = mock_redmine_class.return_value - mock_user = mock.MagicMock() - mock_user.id = 1 - mock_redmine.user.get.return_value = mock_user + _configure_current_user(mock_redmine) def make_entry(issue_id, hours): e = mock.MagicMock() @@ -130,44 +112,38 @@ def test_fetch_multiple_issues(mock_redmine_class): 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.dict( + os.environ, + { + "REDMINE_URL": "https://red.eltex.loc", + "REDMINE_API_KEY": "api-token", + "REDMINE_USER": "user", + "REDMINE_PASSWORD": "password", + }, + clear=True, ) @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 + _configure_current_user(mock_redmine) 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 kwargs["requests"] == {"verify": DEFAULT_REDMINE_VERIFY} 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.dict(os.environ, PASSWORD_ENV, clear=True) @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 + _configure_current_user(mock_redmine) mock_redmine.time_entry.filter.return_value = [] fetch_issues_with_spent_time("2026-01-01", "2026-01-31") @@ -175,4 +151,18 @@ def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class): _, kwargs = mock_redmine_class.call_args assert kwargs["username"] == "user" assert kwargs["password"] == "password" + assert kwargs["requests"] == {"verify": DEFAULT_REDMINE_VERIFY} assert "key" not in kwargs + + +@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_fetch_uses_custom_verify_path(mock_redmine_class): + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine) + 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["requests"] == {"verify": "/tmp/redmine-ca.pem"} diff --git a/tests/test_config.py b/tests/test_config.py index 6ea1c88..46b767f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,81 +1,101 @@ -import pytest +import os from unittest import mock -from redmine_reporter.config import Config -# Config читает os.getenv() в момент определения класса (class-level атрибуты), -# поэтому mock.patch.dict(os.environ) не помогает -- класс уже загружен. -# Правильный способ -- патчить атрибуты самого класса. +import pytest + +from redmine_reporter.config import DEFAULT_REDMINE_VERIFY, Config -@mock.patch.multiple( - Config, - REDMINE_URL="https://red.eltex.loc", - REDMINE_API_KEY=None, - REDMINE_USER="test", - REDMINE_PASSWORD="secret", +@mock.patch.dict( + os.environ, + { + "REDMINE_URL": "https://red.eltex.loc/", + "REDMINE_USER": "test", + "REDMINE_PASSWORD": "secret", + }, + clear=True, ) 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, +@mock.patch.dict( + os.environ, + { + "REDMINE_URL": "https://red.eltex.loc/", + "REDMINE_API_KEY": "token", + }, + clear=True, ) def test_config_valid_with_api_key(): Config.validate() # не должно быть исключения -@mock.patch.multiple( - Config, - REDMINE_URL="", - REDMINE_API_KEY=None, - REDMINE_USER=None, - REDMINE_PASSWORD=None, -) +@mock.patch.dict(os.environ, {}, clear=True) def test_config_missing_url(): with pytest.raises(ValueError, match="REDMINE_URL"): Config.validate() -@mock.patch.multiple( - Config, - REDMINE_URL="https://red.eltex.loc", - REDMINE_API_KEY=None, - REDMINE_USER=None, - REDMINE_PASSWORD=None, -) +@mock.patch.dict(os.environ, {"REDMINE_URL": "https://red.eltex.loc/"}, clear=True) def test_config_missing_auth(): with pytest.raises(ValueError, match="REDMINE_API_KEY"): Config.validate() -@mock.patch.multiple(Config, REDMINE_AUTHOR="Иванов И.И.") +@mock.patch.dict(os.environ, {"REDMINE_URL": " https://red.eltex.loc/ "}, clear=True) +def test_get_redmine_url_strips_spaces_and_trailing_slash(): + assert Config.get_redmine_url() == "https://red.eltex.loc" + + +@mock.patch.dict(os.environ, {"REDMINE_AUTHOR": " Иванов И.И. "}, clear=True) def test_get_author(): assert Config.get_author("") == "Иванов И.И." assert Config.get_author("Петров П.П.") == "Петров П.П." -@mock.patch.multiple(Config, REDMINE_AUTHOR=None) +@mock.patch.dict(os.environ, {}, clear=True) def test_get_author_fallback(): """Если ни CLI, ни .env не задали автора -- возвращается пустая строка.""" assert Config.get_author("") == "" -@mock.patch.multiple( - Config, - DEFAULT_FROM_DATE="2026-01-01", - DEFAULT_TO_DATE="2026-01-31", +@mock.patch.dict( + os.environ, + { + "DEFAULT_FROM_DATE": "2026-01-01", + "DEFAULT_TO_DATE": "2026-01-31", + }, + clear=True, ) def test_get_default_date_range_from_env(): assert Config.get_default_date_range() == "2026-01-01--2026-01-31" -@mock.patch.multiple(Config, DEFAULT_FROM_DATE=None, DEFAULT_TO_DATE=None) +@mock.patch.dict(os.environ, {}, clear=True) def test_get_default_date_range_fallback(): """Если даты не заданы -- используется хардкод-заглушка.""" result = Config.get_default_date_range() assert "--" in result # формат YYYY-MM-DD--YYYY-MM-DD + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_get_redmine_verify_default(): + assert Config.get_redmine_verify() == DEFAULT_REDMINE_VERIFY + + +@pytest.mark.parametrize("value", ["0", "false", "False", "no", "off"]) +def test_get_redmine_verify_false_values(value): + with mock.patch.dict(os.environ, {"REDMINE_VERIFY": value}, clear=True): + assert Config.get_redmine_verify() is False + + +@pytest.mark.parametrize("value", ["1", "true", "True", "yes", "on"]) +def test_get_redmine_verify_true_values(value): + with mock.patch.dict(os.environ, {"REDMINE_VERIFY": value}, clear=True): + assert Config.get_redmine_verify() is True + + +@mock.patch.dict(os.environ, {"REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True) +def test_get_redmine_verify_custom_path(): + assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem" diff --git a/tests/test_formatters.py b/tests/test_formatters.py index e20ba13..d1e8902 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -1,13 +1,16 @@ -import pytest import io from typing import List from unittest import mock -from redmine_reporter.types import ReportRow -from redmine_reporter.formatters.console import TableFormatter, CompactFormatter + +import pytest +from odf.opendocument import OpenDocument, OpenDocumentText + +from redmine_reporter.formatters.console import CompactFormatter, TableFormatter from redmine_reporter.formatters.csv import CSVFormatter +from redmine_reporter.formatters.html import HTMLFormatter from redmine_reporter.formatters.markdown import MarkdownFormatter from redmine_reporter.formatters.odt import ODTFormatter -from odf.opendocument import OpenDocument, OpenDocumentText +from redmine_reporter.types import ReportRow def _make_empty_odt_bytes() -> bytes: @@ -122,9 +125,7 @@ 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") # -- Параметризованные тесты текстовых форматтеров -- @@ -184,6 +185,31 @@ def test_compact_formatter_save_raises(fake_rows): CompactFormatter().save(fake_rows, "/dev/null") +def test_markdown_formatter_escapes_table_cells(): + rows = make_fake_report_rows() + rows[0]["project"] = "A|B" + rows[0]["display_project"] = "A|B" + rows[0]["subject"] = "Fix | split\nline" + + output = MarkdownFormatter().format(rows) + + assert "A\\|B" in output + assert "101. Fix \\| split
line" in output + + +def test_html_formatter_escapes_cells(): + rows = make_fake_report_rows() + rows[0]["project"] = 'A&B ""' + rows[0]["display_project"] = rows[0]["project"] + rows[0]["subject"] = "Fix & attrs" + + output = HTMLFormatter().format(rows) + + assert "A&B "<Project>"" in output + assert "101. Fix <tag> & attrs" in output + assert "Fix " not in output + + # -- Тесты ODT форматтера -- @@ -208,9 +234,7 @@ 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" formatter.save(fake_rows, str(output_file)) diff --git a/tests/test_report_builder.py b/tests/test_report_builder.py index 30bbf6e..b3965ee 100644 --- a/tests/test_report_builder.py +++ b/tests/test_report_builder.py @@ -1,4 +1,4 @@ -from redmine_reporter.report_builder import build_grouped_report, STATUS_TRANSLATION +from redmine_reporter.report_builder import STATUS_TRANSLATION, build_grouped_report class MockIssue: diff --git a/tests/test_utils.py b/tests/test_utils.py index 6d516b3..36bff34 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,7 @@ from redmine_reporter.utils import ( - hours_to_human, get_month_name_from_range, get_version, + hours_to_human, ) @@ -74,10 +74,7 @@ def test_get_version_without_attribute(): def test_get_version_none_attribute(): - """fixed_version = None -- str(None) == 'None', не ''.""" - class MockIssue: fixed_version = None - # get_version возвращает str(getattr(...)), None задан явно -> "None" - assert get_version(MockIssue()) == "None" + assert get_version(MockIssue()) == ""