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

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