from datetime import date from redminelib.resources import Issue from .types import TaskRow, TaskSummary from .utils import get_version # В этом Redmine ровно пять приоритетов P1..P5; меньший номер -- срочнее. PRIORITY_RANK: dict[str, int] = {"P1": 1, "P2": 2, "P3": 3, "P4": 4, "P5": 5} # Ранг для неизвестного имени приоритета: такие задачи уходят в самый конец. LOWEST_PRIORITY_RANK = len(PRIORITY_RANK) + 1 def priority_rank(name: str) -> int: """Возвращает ранг приоритета; неизвестное имя получает низший ранг.""" return PRIORITY_RANK.get(name, LOWEST_PRIORITY_RANK) def _issue_version_name(issue: Issue) -> str: """Имя версии задачи или пустая строка, если версия не назначена.""" return get_version(issue, default="") # "Wishlist" -- постоянная бакет-версия без срока (личный бакет Wendy, факт # из трекера): задачи в ней -- отложенные идеи, создающие мусорный фон в # персональной выборке, поэтому режим --my-tasks исключает их целиком -- # из таблицы и из саммари. Сравнение имени точное, без нормализации регистра. EXCLUDED_VERSION_NAMES: frozenset[str] = frozenset({"Wishlist"}) def filter_excluded_versions( items: list[tuple[Issue, date | None]], ) -> list[tuple[Issue, date | None]]: """Убирает задачи версий-исключений из выборки --my-tasks. Доступ к fixed_version.name getattr-безопасный: задача без версии или с версией без имени остаётся в выборке. Даты в парах сохраняются как есть. """ kept: list[tuple[Issue, date | None]] = [] for item in items: version = getattr(item[0], "fixed_version", None) name = getattr(version, "name", None) if version is not None else None if name and str(name) in EXCLUDED_VERSION_NAMES: continue kept.append(item) return kept def _sort_key(item: tuple[Issue, date | None]) -> tuple[int, bool, date, int]: """Ключ сортировки: приоритет, затем срок по возрастанию, затем id. Задачи без срока уходят в конец своей приоритетной группы: флаг (due is None) сортирует False раньше True, а date.max служит нейтральным значением, чтобы сравнение дат осталось корректным. """ issue, due = item rank = priority_rank(str(issue.priority.name)) return (rank, due is None, due if due is not None else date.max, issue.id) def build_task_rows( items: list[tuple[Issue, date | None]], status_translation: dict[str, str], today: date | None = None, ) -> list[TaskRow]: """Строит отсортированный список строк режима --my-tasks. items -- пары (задача, срок версии или None). today параметризован для тестируемости; по умолчанию берётся текущая дата. """ if today is None: today = date.today() rows: list[TaskRow] = [] for issue, due in sorted(items, key=_sort_key): status_name = str(issue.status) rows.append( { "issue_id": issue.id, "subject": str(issue.subject), "status_ru": status_translation.get(status_name, status_name), "priority": str(issue.priority.name), "version_name": _issue_version_name(issue), "due_text": due.isoformat() if due is not None else "", "overdue": due is not None and due < today, } ) return rows def calculate_task_summary( items: list[tuple[Issue, date | None]], today: date | None = None, ) -> TaskSummary: """Считает саммари по списку пар (задача, срок версии или None).""" if today is None: today = date.today() overdue = 0 no_version = 0 no_due = 0 spent_hours = 0.0 estimated_hours = 0.0 estimated_count = 0 for issue, due in items: if due is not None and due < today: overdue += 1 version = getattr(issue, "fixed_version", None) if version is None: no_version += 1 elif due is None: no_due += 1 raw_spent = getattr(issue, "spent_hours", None) if raw_spent is not None: spent_hours += float(raw_spent) raw_estimated = getattr(issue, "estimated_hours", None) if raw_estimated is not None: estimated_hours += float(raw_estimated) estimated_count += 1 return { "total": len(items), "overdue": overdue, "no_version": no_version, "no_due": no_due, "spent_hours": spent_hours, "estimated_hours": estimated_hours, "estimated_count": estimated_count, } def format_summary_line(s: TaskSummary) -> str: """Форматирует однострочное саммари; блок оценки выводится при наличии данных.""" line = ( f"Открытых: {s['total']} | просроченных: {s['overdue']}" f" | без версии: {s['no_version']} | без срока: {s['no_due']}" f" | потрачено: {s['spent_hours']:g}h" ) if s["estimated_count"] > 0: line += ( f" | оценка: {s['estimated_hours']:g}h" f" (по {s['estimated_count']} из {s['total']})" ) return line