From d1b1648940abf66b89af3a044fe39128fe16bba6 Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Mon, 19 Jan 2026 23:13:02 +0700 Subject: [PATCH] Clean arch --- config.example.yaml | 9 ++ elt-report | 152 ---------------------------------- pyproject.toml | 18 ++++ redmine_reporter/__init__.py | 1 + redmine_reporter/cli.py | 62 ++++++++++++++ redmine_reporter/client.py | 22 +++++ redmine_reporter/core.py | 34 ++++++++ redmine_reporter/formatter.py | 61 ++++++++++++++ redmine_reporter/models.py | 10 +++ 9 files changed, 217 insertions(+), 152 deletions(-) create mode 100644 config.example.yaml delete mode 100644 elt-report create mode 100644 pyproject.toml create mode 100644 redmine_reporter/__init__.py create mode 100644 redmine_reporter/cli.py create mode 100644 redmine_reporter/client.py create mode 100644 redmine_reporter/core.py create mode 100644 redmine_reporter/formatter.py create mode 100644 redmine_reporter/models.py diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..688ecd3 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,9 @@ +redmine: + url: "https://red.eltex.loc/" + username: "your_login" + password_env: "REDMINE_PASSWORD" + +report: + date_from: "2025-12-19" + date_to: "2026-01-31" + output_format: "tabulate" # or "simple" diff --git a/elt-report b/elt-report deleted file mode 100644 index 9b03ed9..0000000 --- a/elt-report +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env python3 - -from redminelib import Redmine - - -TABULATE = True -REDMINE_URL = 'https://red.eltex.loc/' -USER = '' -PASSWORD = '' -DATE = '2025-12-19--2026-01-31' - -def get_issue_list(username, password, from_date, to_date): - redmine = Redmine(REDMINE_URL, username=username, password=password) - - current_user_id = redmine.user.get('current') - time_entries = redmine.time_entry.filter(user_id=current_user_id, from_date=from_date, to_date=to_date) - - issues = [] - - for e in time_entries: - issues.append(f"{e['issue']}") - - if 0 == len(issues): - return None - - # One issue may contain many time entries, filtering needed - issues = set(issues) - issue_list = ','.join(issues) - - return redmine.issue.filter(issue_id=issue_list, status_id='*', sort='project:asc') - -issue_objs = get_issue_list(USER, PASSWORD, DATE.split('--')[0], DATE.split('--')[1]) -if issue_objs is None: - exit(-1) - -print(f"Total issues: {len(issue_objs)} [{DATE}]") - -# Sort by project -sorted_by_project = sorted(issue_objs, key=lambda x: str(x.project)) -dict_by_project = {} -for i in sorted_by_project: - try: - # Add into existing list - dict_by_project[f'{i.project}'].append(i) - except: - # No list, create new one - dict_by_project[f'{i.project}'] = [i] - -# Sort by version -for project in dict_by_project: - dict_by_project[project] = sorted(dict_by_project[project], key=lambda x: str(getattr(x, 'version', ''))) - -# Rebuild list sorted by project and version -issues_l = [] -for project in dict_by_project: - for item in dict_by_project[project]: - issues_l.append(item) - -status_map = { - 'Closed': 'Закрыто', - 'Re-opened': 'В работе', - 'New': 'В работе', - 'Resolved': 'Решена', - 'Pending': 'Ожидание', - 'Feedback': 'В работе', - 'In Progress': 'В работе', - 'Rejected': 'Закрыто', - 'Confirming': 'Ожидание', -} - -# Show list -# if not TABULATE: -# for i in issues_l: -# data = f'{i.project} | {i.version} | {i.id}. {i.subject} | {i.status}' -# print(data) -# else: -# from tabulate import tabulate - -# rows = [['Проект', 'Версия', 'Задача', 'Статус', 'Затрачено']] - -# for i in issues_l: -# status = f'{i.status}' -# try: -# status = status_map[status] -# except: -# pass - -# version = getattr(i, 'version', '') -# row = [ -# f'{i.project}', -# f'{version}', -# f'{i.id}. {i.subject}', -# f'{status}', -# '' -# ] - -# rows.append(row) - -# print(tabulate(rows, headers='firstrow', tablefmt='fancy_grid')) - # print(tabulate(rows, headers='firstrow', tablefmt='html')) - -# Show list -if not TABULATE: - prev_project = None - prev_version = None - for i in issues_l: - project = f'{i.project}' - version = f'{getattr(i, "version", "")}' - if project == prev_project: - project = "" - if version == prev_version: - version = "" - print(f'{project} | {version} | {i.id}. {i.subject} | {i.status}') - prev_project = f'{i.project}' - prev_version = f'{getattr(i, "version", "")}' -else: - from tabulate import tabulate - - rows = [['Проект', 'Версия', 'Задача', 'Статус', 'Затрачено']] - - prev_project = None - prev_version = None - - for i in issues_l: - status = f'{i.status}' - try: - status = status_map[status] - except KeyError: - pass - - project = f'{i.project}' - version = f'{getattr(i, "version", "")}' - - # Suppress repeating project and version - display_project = project if project != prev_project else "" - display_version = version if (project != prev_project or version != prev_version) else "" - - row = [ - display_project, - display_version, - f'{i.id}. {i.subject}', - status, - '' - ] - - rows.append(row) - - # Update previous values - prev_project = project - prev_version = version - - print(tabulate(rows, headers='firstrow', tablefmt='fancy_grid')) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b2ca171 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "redmine-reporter" +version = "0.1.0" +description = "Redmine time-based issue reporter" +authors = [{name = "Artem Kokos"}] +requires-python = ">=3.8" +dependencies = [ + "python-redmine>=2.4.0", + "tabulate>=0.9.0", + "PyYAML>=6.0" +] + +[project.scripts] +redmine-report = "redmine_reporter.cli:main" diff --git a/redmine_reporter/__init__.py b/redmine_reporter/__init__.py new file mode 100644 index 0000000..56836d9 --- /dev/null +++ b/redmine_reporter/__init__.py @@ -0,0 +1 @@ +# Пустой файл — делает пакет импортируемым diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py new file mode 100644 index 0000000..dcc7a5f --- /dev/null +++ b/redmine_reporter/cli.py @@ -0,0 +1,62 @@ +import os +import sys +import argparse +import yaml +from pathlib import Path +from .client import RedmineClient +from .core import fetch_issues_for_period +from .formatter import SimpleFormatter, TabulateFormatter + + +def load_config(config_path: str): + with open(config_path, encoding='utf-8') as f: + return yaml.safe_load(f) + + +def main(): + parser = argparse.ArgumentParser(description="Redmine Reporter") + parser.add_argument("--config", default="config.yaml", help="Path to config file") + parser.add_argument("--from", dest="from_date", help="Start date (YYYY-MM-DD)") + parser.add_argument("--to", dest="to_date", help="End date (YYYY-MM-DD)") + parser.add_argument("--format", choices=["simple", "tabulate"], help="Output format") + + args = parser.parse_args() + + config_path = Path(args.config) + if not config_path.exists(): + print(f"Config file not found: {config_path}", file=sys.stderr) + sys.exit(1) + + cfg = load_config(config_path) + redmine_cfg = cfg["redmine"] + report_cfg = cfg.get("report", {}) + + # Определяем параметры: CLI имеет приоритет над конфигом + from_date = args.from_date or report_cfg.get("date_from") + to_date = args.to_date or report_cfg.get("date_to") + output_format = args.format or report_cfg.get("output_format", "simple") + + if not from_date or not to_date: + print("Error: both --from and --to dates must be specified (via CLI or config).", file=sys.stderr) + sys.exit(1) + + password_env_var = redmine_cfg.get("password_env", "REDMINE_PASSWORD") + password = os.getenv(password_env_var) + if not password: + print(f"Error: environment variable '{password_env_var}' is not set", file=sys.stderr) + sys.exit(1) + + client = RedmineClient( + url=redmine_cfg["url"], + username=redmine_cfg["username"], + password=password + ) + + records = fetch_issues_for_period(client, from_date, to_date) + + if not records: + print("No issues found for the given period.") + sys.exit(0) + + formatter = TabulateFormatter() if output_format == "tabulate" else SimpleFormatter() + print(formatter.format(records)) diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py new file mode 100644 index 0000000..31b9c1f --- /dev/null +++ b/redmine_reporter/client.py @@ -0,0 +1,22 @@ +from redminelib import Redmine +from typing import List, Any + + +class RedmineClient: + def __init__(self, url: str, username: str, password: str): + self._redmine = Redmine(url.strip(), username=username, password=password) + + def get_current_user_id(self) -> int: + return self._redmine.user.get('current').id + + def get_time_entries(self, user_id: int, from_date: str, to_date: str) -> List[Any]: + return list(self._redmine.time_entry.filter( + user_id=user_id, + from_date=from_date, + to_date=to_date + )) + + def get_issues_by_ids(self, issue_ids: List[str]) -> List[Any]: + if not issue_ids: + return [] + return list(self._redmine.issue.filter(issue_id=",".join(issue_ids), status_id='*')) diff --git a/redmine_reporter/core.py b/redmine_reporter/core.py new file mode 100644 index 0000000..56f2f4d --- /dev/null +++ b/redmine_reporter/core.py @@ -0,0 +1,34 @@ +from typing import List +from .client import RedmineClient +from .models import IssueRecord + + +def fetch_issues_for_period( + client: RedmineClient, + from_date: str, + to_date: str +) -> List[IssueRecord]: + user_id = client.get_current_user_id() + time_entries = client.get_time_entries(user_id, from_date, to_date) + + if not time_entries: + return [] + + issue_ids = list({str(e.issue.id) for e in time_entries if hasattr(e, 'issue') and e.issue}) + issues = client.get_issues_by_ids(issue_ids) + + records = [] + for issue in issues: + project = str(getattr(issue, 'project', 'Unknown')) + version = str(getattr(issue, 'version', '')) + status = str(getattr(issue, 'status', 'Unknown')) + records.append(IssueRecord( + project=project, + version=version, + issue_id=issue.id, + subject=issue.subject, + status=status + )) + + records.sort(key=lambda r: (r.project, r.version)) + return records diff --git a/redmine_reporter/formatter.py b/redmine_reporter/formatter.py new file mode 100644 index 0000000..e0dfbf4 --- /dev/null +++ b/redmine_reporter/formatter.py @@ -0,0 +1,61 @@ +from abc import ABC, abstractmethod +from typing import List +from .models import IssueRecord +from tabulate import tabulate + + +STATUS_MAP = { + 'Closed': 'Закрыто', + 'Re-opened': 'В работе', + 'New': 'В работе', + 'Resolved': 'Решена', + 'Pending': 'Ожидание', + 'Feedback': 'В работе', + 'In Progress': 'В работе', + 'Rejected': 'Закрыто', + 'Confirming': 'Ожидание', +} + + +def localize_status(status: str) -> str: + return STATUS_MAP.get(status, status) + + +class ReportFormatter(ABC): + @abstractmethod + def format(self, records: List[IssueRecord]) -> str: + pass + + +class SimpleFormatter(ReportFormatter): + def format(self, records: List[IssueRecord]) -> str: + if not records: + return "No issues found." + lines = [] + prev_project = prev_version = None + for r in records: + proj = r.project if r.project != prev_project else "" + ver = r.version if (r.project != prev_project or r.version != prev_version) else "" + lines.append(f"{proj} | {ver} | {r.issue_id}. {r.subject} | {r.status}") + prev_project, prev_version = r.project, r.version + return "\n".join(lines) + + +class TabulateFormatter(ReportFormatter): + def format(self, records: List[IssueRecord]) -> str: + if not records: + return "No issues found." + rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]] + prev_project = prev_version = None + for r in records: + proj = r.project if r.project != prev_project else "" + ver = r.version if (r.project != prev_project or r.version != prev_version) else "" + rows.append([ + proj, + ver, + f"{r.issue_id}. {r.subject}", + localize_status(r.status), + "" # placeholder + ]) + prev_project, prev_version = r.project, r.version + return tabulate(rows, headers="firstrow", tablefmt="fancy_grid") diff --git a/redmine_reporter/models.py b/redmine_reporter/models.py new file mode 100644 index 0000000..1b87b8d --- /dev/null +++ b/redmine_reporter/models.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass +class IssueRecord: + project: str + version: str + issue_id: int + subject: str + status: str