diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 939f5f7..a81a1b4 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -1,3 +1,4 @@ +import os import sys import argparse from typing import List, Optional @@ -6,10 +7,7 @@ from redminelib.resources import Issue from .config import Config from .client import fetch_issues_with_spent_time from .report_builder import build_grouped_report -from .formatter import format_compact, format_table -from .formatter_odt import format_odt -from .formatter_csv import format_csv -from .formatter_md import format_md +from .formatters.factory import get_formatter_by_extension, get_console_formatter def parse_date_range(date_arg: str) -> tuple[str, str]: @@ -80,45 +78,44 @@ def main(argv: Optional[List[str]] = None) -> int: rows = build_grouped_report(issue_hours, fill_time=not args.no_time) if args.output: - if not (args.output.endswith(".odt") or args.output.endswith(".csv") or args.output.endswith(".md")): - print("❌ Output file must end with .odt, .csv or .md", file=sys.stderr) + output_ext = os.path.splitext(args.output)[1].lower() + + formatter = get_formatter_by_extension(output_ext, + author=Config.get_author(args.author), + from_date=from_date, + to_date=to_date + ) + + if not formatter: + print(f"❌ Неизвестный формат файла: {output_ext}", file=sys.stderr) return 1 try: - if args.output.endswith(".odt"): - doc = format_odt( - rows, - author=Config.get_author(args.author), - from_date=from_date, - to_date=to_date, - ) - doc.save(args.output) - elif args.output.endswith(".csv"): - content = format_csv(rows) - with open(args.output, "w", encoding="utf-8", newline="") as f: - f.write(content) - elif args.output.endswith(".md"): - content = format_md(rows) - with open(args.output, "w", encoding="utf-8") as f: - f.write(content) - + formatter.save(rows, args.output) print(f"✅ Report saved to {args.output}") except ImportError as e: - if args.output.endswith(".odt"): + if output_ext == ".odt": print("❌ odfpy is not installed. Install with: pip install odfpy", file=sys.stderr) else: print(f"❌ Import error: {e}", file=sys.stderr) return 1 except Exception as e: - fmt = "ODT" if args.output.endswith(".odt") else ("CSV" if args.output.endswith(".csv") else "Markdown") + fmt = "ODT" if output_ext == ".odt" else ("CSV" if output_ext == ".csv" else "Markdown") print(f"❌ {fmt} export error: {e}", file=sys.stderr) return 1 + else: + if args.compact: + formatter = get_console_formatter("compact") + else: + formatter = get_console_formatter("table") + + if not formatter: + print("❌ Неизвестный тип консольного форматтера.", file=sys.stderr) + return 1 + try: - if args.compact: - output = format_compact(rows) - else: - output = format_table(rows) + output = formatter.format(rows) print(output) except Exception as e: print(f"❌ Formatting error: {e}", file=sys.stderr) diff --git a/redmine_reporter/formatter.py b/redmine_reporter/formatter.py deleted file mode 100644 index acb2efd..0000000 --- a/redmine_reporter/formatter.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import List -from tabulate import tabulate -from .types import ReportRow - - -def format_compact(rows: List[ReportRow]) -> str: - lines = [] - - for r in rows: - lines.append( - f"{r['display_project']} | {r['display_version']} | " - f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {r['time_text']}" - ) - - return "\n".join(lines) - - -def format_table(rows: List[ReportRow]) -> str: - table_rows = [['Проект', 'Версия', 'Задача', 'Статус', 'Затрачено']] - - for r in rows: - table_rows.append([ - r['display_project'], - r['display_version'], - f"{r['issue_id']}. {r['subject']}", - r['status_ru'], - r['time_text'] - ]) - - return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid") diff --git a/redmine_reporter/formatter_csv.py b/redmine_reporter/formatter_csv.py deleted file mode 100644 index fe9c3d7..0000000 --- a/redmine_reporter/formatter_csv.py +++ /dev/null @@ -1,22 +0,0 @@ -import csv -import io -from typing import List -from .types import ReportRow - - -def format_csv(rows: List[ReportRow]) -> str: - output = io.StringIO() - writer = csv.writer(output, dialect="excel") - writer.writerow(["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]) - - for r in rows: - writer.writerow([ - r["project"], - r["version"], - r["issue_id"], - r["subject"], - r["status_ru"], - r["time_text"] - ]) - - return output.getvalue() diff --git a/redmine_reporter/formatter_md.py b/redmine_reporter/formatter_md.py deleted file mode 100644 index b9bf454..0000000 --- a/redmine_reporter/formatter_md.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import List -from .types import ReportRow - - -def format_md(rows: List[ReportRow]) -> str: - lines = [ - "| Проект | Версия | Задача | Статус | Затрачено |", - "|--------|--------|--------|--------|-----------|" - ] - - for r in rows: - task_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']} |" - ) - - return "\n".join(lines) diff --git a/redmine_reporter/formatter_odt.py b/redmine_reporter/formatter_odt.py deleted file mode 100644 index acecd69..0000000 --- a/redmine_reporter/formatter_odt.py +++ /dev/null @@ -1,131 +0,0 @@ -import os -from typing import List -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 .types import ReportRow -from .utils import get_month_name_from_range - - -def format_odt( - rows: List[ReportRow], - 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}." - doc.text.addElement(P(stylename=para_style_name, text=header_text)) - doc.text.addElement(P(stylename=para_style_name, text="")) - - # Стиль ячеек - 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_style.addElement(cell_props) - doc.automaticstyles.addElement(cell_style) - - # Таблица - table = Table(name="Report") - column_widths = ["1.56in", "1.63in", "3.93in", "1.56in", "1.43in"] - for width in column_widths: - col_style = Style(name=f"col_{width}", family="table-column") - col_props = TableColumnProperties(columnwidth=width) - col_style.addElement(col_props) - doc.automaticstyles.addElement(col_style) - table.addElement(TableColumn(stylename=col_style)) - - # Заголовки - header_row = TableRow() - for text in ["Наименование Проекта", "Номер версии*", "Задача", "Статус Готовность*", "Затрачено за отчетный период"]: - cell = TableCell(stylename=cell_style_name) - cell.addElement(P(stylename=para_style_name, text=text)) - header_row.addElement(cell) - table.addElement(header_row) - - projects = {} - for r in rows: - project = r["project"] - version = r["version"] - if project not in projects: - projects[project] = {} - if version not in projects[project]: - projects[project][version] = [] - projects[project][version].append(r) - - # Данные с двухуровневой группировкой и объединением ячеек - for project, versions in projects.items(): - total_project_rows = sum(len(rows_for_version) for rows_for_version in versions.values()) - first_version_in_project = True - - for version, rows_for_version in versions.items(): - row_span_version = len(rows_for_version) - first_row_in_version = True - - for r in rows_for_version: - 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) - task_text = f"{r['issue_id']}. {r['subject']}" - p = P(stylename=para_style_name, text=task_text) - task_cell.addElement(p) - row.addElement(task_cell) - - status_cell = TableCell(stylename=cell_style_name) - p = P(stylename=para_style_name, text=r["status_ru"]) - status_cell.addElement(p) - row.addElement(status_cell) - - time_cell = TableCell(stylename=cell_style_name) - p = P(stylename=para_style_name, text=r["time_text"]) - time_cell.addElement(p) - row.addElement(time_cell) - - table.addElement(row) - first_version_in_project = False - - doc.text.addElement(table) - doc.text.addElement(P(stylename=para_style_name, text="")) - - # Справка - for line in [ - "«Наименование Проекта» - Имя собственное устройства или программного обеспечения.", - "«Номер версии» - Версия в проекте. Опциональное поле.", - "«Задача» - Номер по Redmine и формулировка.", - "«Статус» - Актуальное состояние задачи на момент отчета. Статусы: закрыто, в работе, ожидание, решена.", - "«Готовность» – Опциональное поле в процентах.", - "«Затрачено за отчетный период» - в днях или часах." - ]: - doc.text.addElement(P(stylename=para_style_name, text=line)) - - return doc diff --git a/redmine_reporter/formatters/base.py b/redmine_reporter/formatters/base.py new file mode 100644 index 0000000..a7c57b0 --- /dev/null +++ b/redmine_reporter/formatters/base.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from typing import List +from ..types import ReportRow + + +class Formatter(ABC): + """ + Абстрактный базовый класс для всех форматтеров. + Определяет общий интерфейс для форматирования отчета. + """ + + @abstractmethod + def format(self, rows: List[ReportRow]) -> str: + """ + Форматирует список строк отчета в нужный формат. + Возвращает строковое представление отчета. + """ + pass + + @abstractmethod + def save(self, rows: List[ReportRow], output_path: str) -> None: + """ + Сохраняет отформатированный отчет в файл по указанному пути. + Для форматтеров, которые не поддерживают сохранение (например, консольные), + можно вызывать `format` и записывать результат вручную. + """ + pass diff --git a/redmine_reporter/formatters/console.py b/redmine_reporter/formatters/console.py new file mode 100644 index 0000000..649c1da --- /dev/null +++ b/redmine_reporter/formatters/console.py @@ -0,0 +1,42 @@ +from typing import List +from tabulate import tabulate +from .base import Formatter +from ..types import ReportRow + + +class TableFormatter(Formatter): + """Форматтер для вывода красивой таблицы в консоль.""" + + def format(self, rows: List[ReportRow]) -> str: + table_rows = [['Проект', 'Версия', 'Задача', 'Статус', 'Затрачено']] + for r in rows: + table_rows.append([ + r['display_project'], + r['display_version'], + f"{r['issue_id']}. {r['subject']}", + r['status_ru'], + r['time_text'] + ]) + return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid") + + def save(self, rows: List[ReportRow], output_path: str) -> None: + # Консольные форматтеры не умеют сохранять в файл напрямую. + # Это делается в CLI. + raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.") + +class CompactFormatter(Formatter): + """Форматтер для компактного вывода в консоль.""" + + def format(self, rows: List[ReportRow]) -> str: + lines = [] + for r in rows: + lines.append( + f"{r['display_project']} | {r['display_version']} | " + f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {r['time_text']}" + ) + return "\n".join(lines) + + def save(self, rows: List[ReportRow], output_path: str) -> None: + # Консольные форматтеры не умеют сохранять в файл напрямую. + # Это делается в CLI. + raise NotImplementedError("CompactFormatter не поддерживает сохранение в файл.") diff --git a/redmine_reporter/formatters/csv.py b/redmine_reporter/formatters/csv.py new file mode 100644 index 0000000..f785d9d --- /dev/null +++ b/redmine_reporter/formatters/csv.py @@ -0,0 +1,29 @@ +import csv +import io +from typing import List +from .base import Formatter +from ..types import ReportRow + + +class CSVFormatter(Formatter): + """Форматтер для экспорта в CSV.""" + + 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"]) + for r in rows: + writer.writerow([ + r["project"], + r["version"], + r["issue_id"], + r["subject"], + r["status_ru"], + r["time_text"] + ]) + return output.getvalue() + + def save(self, rows: List[ReportRow], output_path: str) -> None: + content = self.format(rows) + with open(output_path, "w", encoding="utf-8", newline="") as f: + f.write(content) diff --git a/redmine_reporter/formatters/factory.py b/redmine_reporter/formatters/factory.py new file mode 100644 index 0000000..cf8aaa1 --- /dev/null +++ b/redmine_reporter/formatters/factory.py @@ -0,0 +1,42 @@ +from typing import Dict, Type, Optional +from .base import Formatter +from .console import TableFormatter, CompactFormatter +from .csv import CSVFormatter +from .markdown import MarkdownFormatter +from .odt import ODTFormatter + + +# Словарь для сопоставления расширений файлов с классами форматтеров +FORMATTER_MAP: Dict[str, Type[Formatter]] = { + ".odt": ODTFormatter, + ".csv": CSVFormatter, + ".md": MarkdownFormatter, +} + + +# Словарь для сопоставления типа вывода (консоль) с классами форматтеров +CONSOLE_FORMATTER_MAP: Dict[str, Type[Formatter]] = { + "table": TableFormatter, + "compact": CompactFormatter, +} + + +def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]: + """ + Возвращает экземпляр форматтера по расширению файла. + Ключевые аргументы (**kwargs) передаются в конструктор форматтера. + """ + formatter_class = FORMATTER_MAP.get(extension.lower()) + if formatter_class: + return formatter_class(**kwargs) + return None + + +def get_console_formatter(formatter_type: str) -> Optional[Formatter]: + """ + Возвращает экземпляр консольного форматтера по его типу. + """ + formatter_class = CONSOLE_FORMATTER_MAP.get(formatter_type.lower()) + if formatter_class: + return formatter_class() + return None diff --git a/redmine_reporter/formatters/markdown.py b/redmine_reporter/formatters/markdown.py new file mode 100644 index 0000000..8512d13 --- /dev/null +++ b/redmine_reporter/formatters/markdown.py @@ -0,0 +1,25 @@ +from typing import List +from .base import Formatter +from ..types import ReportRow + + +class MarkdownFormatter(Formatter): + """Форматтер для экспорта в Markdown.""" + + def format(self, rows: List[ReportRow]) -> str: + lines = [ + "| Проект | Версия | Задача | Статус | Затрачено |", + "|--------|--------|--------|--------|-----------|" + ] + for r in rows: + task_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']} |" + ) + return "\n".join(lines) + + def save(self, rows: List[ReportRow], output_path: str) -> None: + content = self.format(rows) + with open(output_path, "w", encoding="utf-8") as f: + f.write(content) diff --git a/redmine_reporter/formatters/odt.py b/redmine_reporter/formatters/odt.py new file mode 100644 index 0000000..548aeff --- /dev/null +++ b/redmine_reporter/formatters/odt.py @@ -0,0 +1,151 @@ +import os +from typing import List +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 .base import Formatter +from ..types import ReportRow +from ..utils import get_month_name_from_range + + +class ODTFormatter(Formatter): + """Форматтер для экспорта в ODT.""" + + def __init__(self, author: str = "", from_date: str = "", to_date: str = ""): + """ + Инициализирует форматтер с параметрами для шапки отчета. + """ + self.author = author + self.from_date = from_date + self.to_date = to_date + + def format(self, rows: List[ReportRow]) -> "OpenDocument": + """ + Форматирует данные в объект 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(self.from_date, self.to_date) + header_text = f"{self.author}. Отчет за месяц {month_name}." + doc.text.addElement(P(stylename=para_style_name, text=header_text)) + doc.text.addElement(P(stylename=para_style_name, text="")) + + # Стиль ячеек + 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_style.addElement(cell_props) + doc.automaticstyles.addElement(cell_style) + + # Таблица + table = Table(name="Report") + column_widths = ["1.56in", "1.63in", "3.93in", "1.56in", "1.43in"] + for width in column_widths: + col_style = Style(name=f"col_{width}", family="table-column") + col_props = TableColumnProperties(columnwidth=width) + col_style.addElement(col_props) + doc.automaticstyles.addElement(col_style) + table.addElement(TableColumn(stylename=col_style)) + + # Заголовки + header_row = TableRow() + for text in ["Наименование Проекта", "Номер версии*", "Задача", "Статус Готовность*", "Затрачено за отчетный период"]: + cell = TableCell(stylename=cell_style_name) + cell.addElement(P(stylename=para_style_name, text=text)) + header_row.addElement(cell) + table.addElement(header_row) + + projects = {} + for r in rows: + project = r["project"] + version = r["version"] + + if project not in projects: + projects[project] = {} + + if version not in projects[project]: + projects[project][version] = [] + + projects[project][version].append(r) + + # Данные с двухуровневой группировкой и объединением ячеек + for project, versions in projects.items(): + total_project_rows = sum(len(rows_for_version) for rows_for_version in versions.values()) + first_version_in_project = True + + for version, rows_for_version in versions.items(): + row_span_version = len(rows_for_version) + first_row_in_version = True + + for r in rows_for_version: + 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) + task_text = f"{r['issue_id']}. {r['subject']}" + p = P(stylename=para_style_name, text=task_text) + task_cell.addElement(p) + row.addElement(task_cell) + + status_cell = TableCell(stylename=cell_style_name) + p = P(stylename=para_style_name, text=r["status_ru"]) + status_cell.addElement(p) + row.addElement(status_cell) + + time_cell = TableCell(stylename=cell_style_name) + p = P(stylename=para_style_name, text=r["time_text"]) + time_cell.addElement(p) + row.addElement(time_cell) + + table.addElement(row) + first_version_in_project = False + + doc.text.addElement(table) + doc.text.addElement(P(stylename=para_style_name, text="")) + + # Справка + for line in [ + "«Наименование Проекта» - Имя собственное устройства или программного обеспечения.", + "«Номер версии» - Версия в проекте. Опциональное поле.", + "«Задача» - Номер по Redmine и формулировка.", + "«Статус» - Актуальное состояние задачи на момент отчета. Статусы: закрыто, в работе, ожидание, решена.", + "«Готовность» – Опциональное поле в процентах.", + "«Затрачено за отчетный период» - в днях или часах." + ]: + doc.text.addElement(P(stylename=para_style_name, text=line)) + + return doc + + def save(self, rows: List[ReportRow], output_path: str) -> None: + """ + Сохраняет сформированный документ в файл. + """ + doc = self.format(rows) + doc.save(output_path)