Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59af7ce464 |
@@ -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" }]
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.7.0"
|
||||
__version__ = "1.8.0"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>")
|
||||
|
||||
# Ячейка "Проект" - только в первой строке проекта
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user