Clean arch
This commit is contained in:
9
config.example.yaml
Normal file
9
config.example.yaml
Normal 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"
|
||||
152
elt-report
152
elt-report
@@ -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
18
pyproject.toml
Normal 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"
|
||||
1
redmine_reporter/__init__.py
Normal file
1
redmine_reporter/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Пустой файл — делает пакет импортируемым
|
||||
62
redmine_reporter/cli.py
Normal file
62
redmine_reporter/cli.py
Normal 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))
|
||||
22
redmine_reporter/client.py
Normal file
22
redmine_reporter/client.py
Normal 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
34
redmine_reporter/core.py
Normal 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
|
||||
61
redmine_reporter/formatter.py
Normal file
61
redmine_reporter/formatter.py
Normal 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")
|
||||
10
redmine_reporter/models.py
Normal file
10
redmine_reporter/models.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class IssueRecord:
|
||||
project: str
|
||||
version: str
|
||||
issue_id: int
|
||||
subject: str
|
||||
status: str
|
||||
Reference in New Issue
Block a user