From 59af7ce4649101f17ad8751c7f10c892be40459d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Mon, 29 Jun 2026 16:44:52 +0700 Subject: [PATCH] feat(activity): add --by-activity flag to break down spent time by activity type - Load time entry activity names from Redmine enumeration. - Aggregate hours per issue and per activity in fetch_issues_with_spent_time. - Add --by-activity CLI flag and propagate it through report builder, summary, and all formatters (console, CSV, HTML, JSON, ODT). - Keep backward-compatible 2-tuple input in build_grouped_report. - Bump version to 1.8.0. Closes #41 --- pyproject.toml | 2 +- redmine_reporter/__init__.py | 2 +- redmine_reporter/cli.py | 22 ++++++++-- redmine_reporter/client.py | 57 +++++++++++++++++++++++--- redmine_reporter/formatters/console.py | 6 ++- redmine_reporter/formatters/csv.py | 3 +- redmine_reporter/formatters/html.py | 2 +- redmine_reporter/formatters/json.py | 11 +++-- redmine_reporter/formatters/odt.py | 8 +++- redmine_reporter/report_builder.py | 47 +++++++++++++++++---- redmine_reporter/types.py | 10 ++++- tests/test_client.py | 4 +- 12 files changed, 142 insertions(+), 32 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9e485b3..32b9c62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "redmine-reporter" -version = "1.7.0" +version = "1.8.0" description = "Redmine time-entry based issue reporter for internal use" readme = "README.md" authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] diff --git a/redmine_reporter/__init__.py b/redmine_reporter/__init__.py index 14d9d2f..29654ee 100644 --- a/redmine_reporter/__init__.py +++ b/redmine_reporter/__init__.py @@ -1 +1 @@ -__version__ = "1.7.0" +__version__ = "1.8.0" diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 256dff5..21f8248 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -88,6 +88,11 @@ def main(argv: Optional[List[str]] = None) -> int: "--user-name", help="Redmine user full name for the report (alternative to --user-id; ambiguous names are rejected)", ) + parser.add_argument( + "--by-activity", + action="store_true", + help="Break down spent time by activity type", + ) args = parser.parse_args(argv) # Валидация взаимоисключающих флагов пользователя @@ -130,6 +135,7 @@ def main(argv: Optional[List[str]] = None) -> int: from_date, to_date, user_id=args.user_id or args.user_login or args.user_name, + by_activity=args.by_activity, ) except RedmineAPIError as e: print(f"❌ {e.message}", file=sys.stderr) @@ -150,15 +156,25 @@ def main(argv: Optional[List[str]] = None) -> int: print(f"✅ Total issues: {len(issue_hours)} [{args.date}]", file=sys.stderr) - rows = build_grouped_report(issue_hours, fill_time=not args.no_time) + rows = build_grouped_report( + issue_hours, + fill_time=not args.no_time, + by_activity=args.by_activity, + ) if args.summary: - summary = calculate_summary(rows) + summary = calculate_summary(rows, by_activity=args.by_activity) print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr) - for key, value in summary.items(): + project_keys = [k for k in sorted(summary) if k.startswith("project:")] + activity_keys = [k for k in sorted(summary) if k.startswith("activity:")] + for key in project_keys + activity_keys: + value = summary[key] if key.startswith("project:"): project = key.split(":", 1)[1] print(f" {project}: {value}h", file=sys.stderr) + elif key.startswith("activity:"): + activity = key.split(":", 1)[1] + print(f" [{activity}]: {value}h", file=sys.stderr) if args.output: output_ext = os.path.splitext(args.output)[1].lower() diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py index 2bff626..fcb5cb4 100644 --- a/redmine_reporter/client.py +++ b/redmine_reporter/client.py @@ -111,6 +111,39 @@ def _format_redmine_error(exc: Exception) -> str: return str(exc) +def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]: + """Загружает справочник типов активности time entries. + + Возвращает словарь id -> name. Если справочник недоступен, + возвращает пустой словарь — тогда будем использовать данные из самих entries. + """ + try: + activities = redmine.enumeration.filter(resource="time_entry_activities") + return {int(a.id): str(a.name) for a in activities} + except Exception: + return {} + + +def _get_activity_name(entry, activities: Dict[int, str]) -> str: + """Определяет название активности для time entry.""" + activity = getattr(entry, "activity", None) + if activity is None: + return "" + + # activity может быть объектом с id/name или просто значением + activity_id = getattr(activity, "id", None) + if activity_id is not None: + name = activities.get(int(activity_id)) + if name: + return name + activity_name = getattr(activity, "name", None) + if activity_name: + return str(activity_name) + return str(activity_id) + + return str(activity) + + def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]: """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" all_issues: List[Issue] = [] @@ -186,12 +219,14 @@ def fetch_issues_with_spent_time( from_date: str, to_date: str, user_id: Optional[Union[int, str]] = None, -) -> Optional[List[Tuple[Issue, float]]]: + by_activity: bool = False, +) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]: """ Fetch unique issues linked to time entries of the given user in date range, along with total spent hours per issue. If user_id is None, uses current user. - Returns list of (issue, total_hours) tuples. + If by_activity is True, returns per-activity breakdown as third tuple element. + Returns list of (issue, total_hours, activities) tuples. Raises RedmineAPIError on API/auth/network failures. """ @@ -202,6 +237,7 @@ def fetch_issues_with_spent_time( if user_id is not None else _get_current_user_id(redmine) ) + activities_lookup = _load_time_entry_activities(redmine) if by_activity else {} time_entries = redmine.time_entry.filter( user_id=target_user_id, from_date=from_date, to_date=to_date ) @@ -210,14 +246,21 @@ def fetch_issues_with_spent_time( except Exception as exc: raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc - # Агрегируем часы по issue.id + # Агрегируем часы по issue.id (и активности, если требуется) spent_time: Dict[int, float] = {} + spent_by_activity: Dict[int, Dict[str, float]] = {} issue_ids = set() for entry in time_entries: if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"): iid = entry.issue.id + hours = float(entry.hours) issue_ids.add(iid) - spent_time[iid] = spent_time.get(iid, 0.0) + float(entry.hours) + spent_time[iid] = spent_time.get(iid, 0.0) + hours + + if by_activity: + activity_name = _get_activity_name(entry, activities_lookup) + by_act = spent_by_activity.setdefault(iid, {}) + by_act[activity_name] = by_act.get(activity_name, 0.0) + hours if not issue_ids: return None @@ -234,7 +277,9 @@ def fetch_issues_with_spent_time( # здесь оставляем порядок API как есть. result = [] for issue in issues: - total_hours = spent_time.get(issue.id, 0.0) - result.append((issue, total_hours)) + iid = issue.id + total_hours = spent_time.get(iid, 0.0) + activity_breakdown = spent_by_activity.get(iid) if by_activity else None + result.append((issue, total_hours, activity_breakdown)) return result diff --git a/redmine_reporter/formatters/console.py b/redmine_reporter/formatters/console.py index 97b8060..a009530 100644 --- a/redmine_reporter/formatters/console.py +++ b/redmine_reporter/formatters/console.py @@ -12,13 +12,14 @@ class TableFormatter(Formatter): def format(self, rows: List[ReportRow]) -> str: table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]] for r in rows: + time_text = r["time_text"].replace("\n", " / ") table_rows.append( [ r["display_project"], r["display_version"], f"{r['issue_id']}. {r['subject']}", r["status_ru"], - r["time_text"], + time_text, ] ) return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid") @@ -35,9 +36,10 @@ class CompactFormatter(Formatter): def format(self, rows: List[ReportRow]) -> str: lines = [] for r in rows: + time_text = r["time_text"].replace("\n", " / ") lines.append( f"{r['display_project']} | {r['display_version']} | " - f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {r['time_text']}" + f"{r['issue_id']}. {r['subject']} | {r['status_ru']} | {time_text}" ) return "\n".join(lines) diff --git a/redmine_reporter/formatters/csv.py b/redmine_reporter/formatters/csv.py index 1a0317b..5f24ca6 100644 --- a/redmine_reporter/formatters/csv.py +++ b/redmine_reporter/formatters/csv.py @@ -27,6 +27,7 @@ class CSVFormatter(Formatter): headers.append("Spent Time") writer.writerow(headers) for r in rows: + time_text = r["time_text"].replace("\n", " / ") data = [ r["project"], r["version"], @@ -35,7 +36,7 @@ class CSVFormatter(Formatter): r["status_ru"], ] if not self.no_time: - data.append(r["time_text"]) + data.append(time_text) writer.writerow(data) return output.getvalue() diff --git a/redmine_reporter/formatters/html.py b/redmine_reporter/formatters/html.py index 349dca6..448462b 100644 --- a/redmine_reporter/formatters/html.py +++ b/redmine_reporter/formatters/html.py @@ -48,7 +48,7 @@ class HTMLFormatter(Formatter): for r in task_rows: task_cell = escape(f"{r['issue_id']}. {r['subject']}") status_text = escape(r["status_ru"]) - time_text = escape(r["time_text"]) + time_text = escape(r["time_text"]).replace("\n", "
") lines.append(" ") # Ячейка "Проект" - только в первой строке проекта diff --git a/redmine_reporter/formatters/json.py b/redmine_reporter/formatters/json.py index c2bb353..f1049b5 100644 --- a/redmine_reporter/formatters/json.py +++ b/redmine_reporter/formatters/json.py @@ -12,8 +12,9 @@ class JSONFormatter(Formatter): super().__init__() def format(self, rows: List[ReportRow]) -> str: - data = [ - { + data = [] + for r in rows: + item = { "project": r["project"], "version": r["version"], "issue_id": r["issue_id"], @@ -21,8 +22,10 @@ class JSONFormatter(Formatter): "status": r["status_ru"], "time": r["time_text"], } - for r in rows - ] + activities = r.get("activities") + if activities: + item["activities"] = activities + data.append(item) return json.dumps(data, ensure_ascii=False, indent=2) def save(self, rows: List[ReportRow], output_path: str) -> None: diff --git a/redmine_reporter/formatters/odt.py b/redmine_reporter/formatters/odt.py index ddbf5e4..cced24c 100644 --- a/redmine_reporter/formatters/odt.py +++ b/redmine_reporter/formatters/odt.py @@ -125,8 +125,12 @@ class ODTFormatter(Formatter): row.addElement(status_cell) time_cell = TableCell(stylename=cell_style_name) - p = P(stylename=para_style_name, text=r["time_text"]) - time_cell.addElement(p) + time_lines = r["time_text"].split("\n") + for i, line in enumerate(time_lines): + p = P(stylename=para_style_name, text=line) + time_cell.addElement(p) + if i < len(time_lines) - 1: + time_cell.addElement(P(stylename=para_style_name, text="")) row.addElement(time_cell) table.addElement(row) diff --git a/redmine_reporter/report_builder.py b/redmine_reporter/report_builder.py index 67b592b..22d6273 100644 --- a/redmine_reporter/report_builder.py +++ b/redmine_reporter/report_builder.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Tuple, cast +from typing import Dict, List, Optional, Tuple, cast from redminelib.resources import Issue @@ -22,9 +22,19 @@ STATUS_TRANSLATION = { } +def _format_activities(activities: Dict[str, float]) -> str: + """Форматирует разбивку по активностям в многострочный текст.""" + if not activities: + return "" + # Сортируем по убыванию часов, затем по алфавиту для стабильности + items = sorted(activities.items(), key=lambda x: (-x[1], x[0])) + return "\n".join(f"{hours_to_human(hours)} {name}" for name, hours in items) + + def build_grouped_report( - issue_hours: List[Tuple[Issue, float]], + issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]], fill_time: bool = True, + by_activity: bool = False, ) -> List[ReportRow]: """ Преобразует список задач с затраченным временем в плоский список строк отчёта, @@ -41,12 +51,20 @@ def build_grouped_report( prev_project: str = "" prev_version: str = "" - for issue, hours in issue_hours: + for issue, hours, *rest in issue_hours: + activities: Optional[Dict[str, float]] = rest[0] if rest else None project = str(issue.project) version = get_version(issue) status_en = str(issue.status) status_ru = STATUS_TRANSLATION.get(status_en, status_en) - time_text = hours_to_human(hours) if fill_time else "" + + if fill_time: + if by_activity and activities: + time_text = _format_activities(activities) + else: + time_text = hours_to_human(hours) + else: + time_text = "" display_project = project if project != prev_project else "" display_version = version if (project != prev_project or version != prev_version) else "" @@ -64,6 +82,7 @@ def build_grouped_report( "status_ru": status_ru, "time_text": time_text, "hours": round(hours, 2), + "activities": activities, }, ) ) @@ -74,11 +93,15 @@ def build_grouped_report( return rows -def calculate_summary(rows: List[ReportRow]) -> Dict[str, float]: - """Возвращает сводку: общее время, время по проектам и версиям.""" +def calculate_summary( + rows: List[ReportRow], + by_activity: bool = False, +) -> Dict[str, float]: + """Возвращает сводку: общее время, время по проектам, версиям и активностям.""" total = 0.0 by_project: Dict[str, float] = {} by_project_version: Dict[str, float] = {} + by_activity_name: Dict[str, float] = {} for r in rows: hours = r.get("hours", 0.0) @@ -87,11 +110,21 @@ def calculate_summary(rows: List[ReportRow]) -> Dict[str, float]: key = f"{r['project']}::{r['version']}" by_project_version[key] = by_project_version.get(key, 0.0) + hours - return { + if by_activity: + activities = r.get("activities") + if activities: + for name, act_hours in activities.items(): + by_activity_name[name] = by_activity_name.get(name, 0.0) + act_hours + + result: Dict[str, float] = { "total": round(total, 2), **{f"project:{k}": round(v, 2) for k, v in by_project.items()}, **{f"version:{k}": round(v, 2) for k, v in by_project_version.items()}, } + if by_activity: + result.update({f"activity:{k}": round(v, 2) for k, v in by_activity_name.items()}) + + return result def group_rows_by_project_and_version( diff --git a/redmine_reporter/types.py b/redmine_reporter/types.py index c60a4f5..99e9132 100644 --- a/redmine_reporter/types.py +++ b/redmine_reporter/types.py @@ -1,7 +1,7 @@ -from typing import TypedDict +from typing import Dict, Optional, TypedDict -class ReportRow(TypedDict): +class ReportRowBase(TypedDict): """Строка итогового отчёта.""" project: str @@ -13,3 +13,9 @@ class ReportRow(TypedDict): status_ru: str time_text: str hours: float + + +class ReportRow(ReportRowBase, total=False): + """Строка итогового отчёта с опциональной разбивкой по активностям.""" + + activities: Optional[Dict[str, float]] diff --git a/tests/test_client.py b/tests/test_client.py index 1089aec..034b5dc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -45,7 +45,7 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class): assert result is not None assert len(result) == 1 - issue, total_hours = result[0] + issue, total_hours, _activities = result[0] assert total_hours == 3.5 @@ -109,7 +109,7 @@ def test_fetch_multiple_issues(mock_redmine_class): assert result is not None assert len(result) == 2 - hours_by_id = {issue.id: hours for issue, hours in result} + hours_by_id = {issue.id: hours for issue, hours, _ in result} assert hours_by_id[1] == 1.5 assert hours_by_id[2] == 2.0