Clean arch

This commit is contained in:
Artem Kokos
2026-01-19 23:13:02 +07:00
parent a191ba3a8f
commit d1b1648940
9 changed files with 217 additions and 152 deletions

9
config.example.yaml Normal file
View File

@@ -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"

View File

@@ -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', '<N/A>')))
# 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', '<N/A>')
# 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", "<N/A>")}'
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", "<N/A>")}'
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", "<N/A>")}'
# 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'))

18
pyproject.toml Normal file
View File

@@ -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"

View File

@@ -0,0 +1 @@
# Пустой файл — делает пакет импортируемым

62
redmine_reporter/cli.py Normal file
View File

@@ -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))

View File

@@ -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='*'))

34
redmine_reporter/core.py Normal file
View File

@@ -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', '<N/A>'))
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

View File

@@ -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")

View File

@@ -0,0 +1,10 @@
from dataclasses import dataclass
@dataclass
class IssueRecord:
project: str
version: str
issue_id: int
subject: str
status: str