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
This commit is contained in:
Кокос Артем Николаевич
2026-06-29 16:44:52 +07:00
parent f6861382e6
commit 59af7ce464
12 changed files with 142 additions and 32 deletions

View File

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

View File

@@ -1 +1 @@
__version__ = "1.7.0"
__version__ = "1.8.0"

View File

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

View File

@@ -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 "<N/A>"
# 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

View File

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

View File

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

View File

@@ -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", "<br>")
lines.append(" <tr>")
# Ячейка "Проект" - только в первой строке проекта

View File

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

View File

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

View File

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

View File

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

View File

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