Clean arch
This commit is contained in:
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