feat: add --my-tasks mode for open assigned issues (#71)

List open Redmine issues assigned to the current (or overridden) user,
sorted by priority rank (P1..P5) then version due date with a stable id
tie-break. Renders an OSC 8 hyperlinked console table on stdout and a
counts/hours summary line on stderr; version due dates are fetched once
per unique version, unavailable versions degrade to an empty due date.

Also migrates the codebase to ruff 0.17.3 defaults (PEP 585/604
annotations, targeted noqa for intentional blind excepts and CLI root
logging). Behavior-neutral: 408 tests pass with -W error.
This commit is contained in:
Кокос Артем Николаевич
2026-10-28 15:26:41 +07:00
parent 3ce4f408ed
commit 5d1cf4ab74
27 changed files with 1255 additions and 137 deletions

View File

@@ -5,17 +5,18 @@ import re
import sys import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Optional
import yaml import yaml
from dotenv import find_dotenv, load_dotenv from dotenv import find_dotenv, load_dotenv
from . import __version__ from . import __version__
from .client import RedmineAPIError, fetch_issues_with_spent_time from .client import RedmineAPIError, fetch_issues_with_spent_time, fetch_open_issues
from .config import Config from .config import Config
from .formatters.factory import get_console_formatter, get_formatter_by_extension from .formatters.factory import get_console_formatter, get_formatter_by_extension
from .formatters.task_table import TaskTableFormatter
from .mailer import send_report from .mailer import send_report
from .report_builder import build_grouped_report, calculate_summary from .report_builder import build_grouped_report, calculate_summary
from .task_list import build_task_rows, calculate_task_summary, format_summary_line
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
# Маскируем API-ключ Redmine в тексте ошибок (#55): # Маскируем API-ключ Redmine в тексте ошибок (#55):
@@ -208,10 +209,9 @@ def _save_and_maybe_send(
file=sys.stderr, file=sys.stderr,
) )
else: else:
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
print( print(
f"❌ Неизвестный формат файла: {output_ext!r}. " f"❌ Неизвестный формат файла: {output_ext!r}. "
f"Поддерживаются: {known_exts}", "Поддерживаются: .odt, .csv, .md, .html, .json, .xlsx",
file=sys.stderr, file=sys.stderr,
) )
return 1, False return 1, False
@@ -219,7 +219,7 @@ def _save_and_maybe_send(
try: try:
formatter.save(rows, output_arg) formatter.save(rows, output_arg)
print(f"✅ Report saved to {output_arg}") print(f"✅ Report saved to {output_arg}")
except Exception as e: except Exception as e: # noqa: BLE001 -- форматёры (odfpy/openpyxl/csv) бросают произвольные типы, CLI обязан вернуть код 1 на любой сбой
fmt = output_ext.lstrip(".").upper() fmt = output_ext.lstrip(".").upper()
print(f"❌ {fmt} export error: {e}", file=sys.stderr) print(f"❌ {fmt} export error: {e}", file=sys.stderr)
return 1, False return 1, False
@@ -264,7 +264,47 @@ def _save_and_maybe_send(
return 0, False return 0, False
def main(argv: Optional[List[str]] = None) -> int: def _run_my_tasks(args: argparse.Namespace) -> int:
"""Режим --my-tasks: таблица открытых задач пользователя в stdout (#71).
Саммари печатается в stderr, а не в stdout: при перенаправлении вывода
в файл остаётся только таблица без служебных строк.
"""
try:
items = fetch_open_issues(
user_id=args.user_id or args.user_login or args.user_name
)
except RedmineAPIError as e:
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
if (args.verbose or args.debug) and e.original is not None:
# Точка входа CLI: root-логгер осознанно настроен через basicConfig
# в main(), module-логгер здесь дал бы второй набор хендлеров.
logging.exception("Original Redmine API error") # noqa: LOG015
return 1
except Exception as e:
print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr)
if args.verbose or args.debug:
logging.exception("Unexpected error") # noqa: LOG015
return 1
if not items:
print("ℹ️ Открытых задач нет.", file=sys.stderr)
return 0
rows = build_task_rows(items, Config.get_status_translation())
summary = calculate_task_summary(items)
print(format_summary_line(summary), file=sys.stderr)
print(file=sys.stderr)
try:
table = TaskTableFormatter(base_url=Config.get_redmine_url()).format(rows)
except Exception as e: # noqa: BLE001 -- сбой рендера таблицы не должен ронять CLI трейсбеком: печатаем ошибку и возвращаем код 1
print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1
print(table)
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="redmine-reporter", prog="redmine-reporter",
description="Generate Redmine issue report based on your time entries.", description="Generate Redmine issue report based on your time entries.",
@@ -349,6 +389,11 @@ def main(argv: Optional[List[str]] = None) -> int:
action="store_true", action="store_true",
help="Send generated report via email after saving (requires email section in config); on success advance period start to its end date", help="Send generated report via email after saving (requires email section in config); on success advance period start to its end date",
) )
parser.add_argument(
"--my-tasks",
action="store_true",
help="List open issues assigned to you, sorted by urgency, and exit",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
# --init-config: обработка до всего остального # --init-config: обработка до всего остального
@@ -374,6 +419,25 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1 return 1
return _run_init_config(args.config_path, args.force) return _run_init_config(args.config_path, args.force)
# --my-tasks: отдельный режим, отчётное окно и файлы к нему неприменимы
if args.my_tasks:
report_flags = [
args.date is not None,
args.output,
args.compact,
args.summary,
args.no_time,
args.by_activity,
args.commit,
args.send,
]
if any(report_flags):
print(
"❌ --my-tasks cannot be used with report-generating flags.",
file=sys.stderr,
)
return 1
# Валидация взаимоисключающих флагов пользователя # Валидация взаимоисключающих флагов пользователя
user_args = [args.user_id, args.user_login, args.user_name] user_args = [args.user_id, args.user_login, args.user_name]
if sum(bool(a) for a in user_args) > 1: if sum(bool(a) for a in user_args) > 1:
@@ -407,6 +471,10 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"❌ Configuration error: {e}", file=sys.stderr) print(f"❌ Configuration error: {e}", file=sys.stderr)
return 1 return 1
# Ветка --my-tasks: даты отчётного окна режиму не нужны.
if args.my_tasks:
return _run_my_tasks(args)
# Если --date не указан, используем дефолтный диапазон # Если --date не указан, используем дефолтный диапазон
date_arg = args.date if args.date is not None else Config.get_default_date_range() date_arg = args.date if args.date is not None else Config.get_default_date_range()
try: try:
@@ -425,12 +493,14 @@ def main(argv: Optional[List[str]] = None) -> int:
except RedmineAPIError as e: except RedmineAPIError as e:
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr) print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
if (args.verbose or args.debug) and e.original is not None: if (args.verbose or args.debug) and e.original is not None:
logging.exception("Original Redmine API error") # Точка входа CLI: root-логгер осознанно настроен через basicConfig
# в main(), module-логгер здесь дал бы второй набор хендлеров.
logging.exception("Original Redmine API error") # noqa: LOG015
return 1 return 1
except Exception as e: except Exception as e:
print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr) print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr)
if args.verbose or args.debug: if args.verbose or args.debug:
logging.exception("Unexpected error") logging.exception("Unexpected error") # noqa: LOG015
return 1 return 1
if not issue_hours: if not issue_hours:
@@ -554,7 +624,7 @@ def main(argv: Optional[List[str]] = None) -> int:
try: try:
output = formatter.format(rows) output = formatter.format(rows)
print(output) print(output)
except Exception as e: except Exception as e: # noqa: BLE001 -- консольный форматтер не должен ронять CLI: печатаем ошибку и возвращаем код 1
print(f"❌ Formatting error: {e}", file=sys.stderr) print(f"❌ Formatting error: {e}", file=sys.stderr)
return 1 return 1
@@ -569,7 +639,7 @@ def main(argv: Optional[List[str]] = None) -> int:
try: try:
save_period_to_config(args.config_path, from_str=next_from) save_period_to_config(args.config_path, from_str=next_from)
except Exception as e: except Exception as e: # noqa: BLE001 -- сбой записи конфига не должен ронять CLI трейсбеком: сообщаем и возвращаем код 1
print( print(
f"❌ Не удалось сохранить период в конфиг: {e}", f"❌ Не удалось сохранить период в конфиг: {e}",
file=sys.stderr, file=sys.stderr,

View File

@@ -1,5 +1,6 @@
import sys import sys
from typing import Any, Dict, List, Optional, Tuple, Union from datetime import date
from typing import Any
import requests import requests
import truststore import truststore
@@ -20,13 +21,13 @@ ISSUE_ID_CHUNK_SIZE = 100
class RedmineAPIError(Exception): class RedmineAPIError(Exception):
"""Пользовательское исключение с понятным сообщением об ошибке Redmine API.""" """Пользовательское исключение с понятным сообщением об ошибке Redmine API."""
def __init__(self, message: str, original: Optional[Exception] = None): def __init__(self, message: str, original: Exception | None = None):
super().__init__(message) super().__init__(message)
self.message = message self.message = message
self.original = original self.original = original
def _get_redmine_auth_kwargs() -> Dict[str, Any]: def _get_redmine_auth_kwargs() -> dict[str, Any]:
"""Return Redmine auth kwargs. API key has priority over legacy password auth.""" """Return Redmine auth kwargs. API key has priority over legacy password auth."""
api_key = Config.get_redmine_api_key() api_key = Config.get_redmine_api_key()
if api_key: if api_key:
@@ -122,7 +123,7 @@ def _format_redmine_error(exc: Exception) -> str:
return str(exc) return str(exc)
def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]: def _load_time_entry_activities(redmine: Redmine) -> dict[int, str]:
"""Загружает справочник типов активности time entries. """Загружает справочник типов активности time entries.
Возвращает словарь id -> name. Если справочник недоступен, Возвращает словарь id -> name. Если справочник недоступен,
@@ -131,7 +132,7 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
try: try:
activities = redmine.enumeration.filter(resource="time_entry_activities") activities = redmine.enumeration.filter(resource="time_entry_activities")
return {int(a.id): str(a.name) for a in activities} return {int(a.id): str(a.name) for a in activities}
except Exception as exc: except Exception as exc: # noqa: BLE001 -- python-redmine бросает разные типы; справочник не критичен, деградируем к данным из entries
# #61: не глотаем сбой молча — предупреждаем, что разбивка по # #61: не глотаем сбой молча — предупреждаем, что разбивка по
# активностям будет построена по сырым данным из самих entries. # активностям будет построена по сырым данным из самих entries.
print( print(
@@ -142,7 +143,7 @@ def _load_time_entry_activities(redmine: Redmine) -> Dict[int, str]:
return {} return {}
def _get_activity_name(entry, activities: Dict[int, str]) -> str: def _get_activity_name(entry, activities: dict[int, str]) -> str:
"""Определяет название активности для time entry.""" """Определяет название активности для time entry."""
activity = getattr(entry, "activity", None) activity = getattr(entry, "activity", None)
if activity is None: if activity is None:
@@ -162,9 +163,9 @@ def _get_activity_name(entry, activities: Dict[int, str]) -> str:
return str(activity) return str(activity)
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]: def _fetch_issues_chunked(redmine: Redmine, issue_ids: list[int]) -> list[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = [] all_issues: list[Issue] = []
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE): for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE] chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
issue_list_str = ",".join(str(x) for x in chunk) issue_list_str = ",".join(str(x) for x in chunk)
@@ -175,7 +176,7 @@ def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]
return all_issues return all_issues
def _resolve_user_id(redmine: Redmine, user_arg: Union[int, str]) -> int: def _resolve_user_id(redmine: Redmine, user_arg: int | str) -> int:
"""Преобразует строковый идентификатор пользователя в числовой ID. """Преобразует строковый идентификатор пользователя в числовой ID.
Если аргумент — число, возвращает его как есть. Если аргумент — число, возвращает его как есть.
@@ -248,9 +249,9 @@ def _get_current_user_id(redmine: Redmine) -> int:
def fetch_issues_with_spent_time( def fetch_issues_with_spent_time(
from_date: str, from_date: str,
to_date: str, to_date: str,
user_id: Optional[Union[int, str]] = None, user_id: int | str | None = None,
by_activity: bool = False, by_activity: bool = False,
) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]: ) -> list[tuple[Issue, float, dict[str, float] | None]] | None:
""" """
Fetch unique issues linked to time entries of the given user in date range, Fetch unique issues linked to time entries of the given user in date range,
along with total spent hours per issue. along with total spent hours per issue.
@@ -281,8 +282,8 @@ def fetch_issues_with_spent_time(
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# Агрегируем часы по issue.id (и активности, если требуется) # Агрегируем часы по issue.id (и активности, если требуется)
spent_time: Dict[int, float] = {} spent_time: dict[int, float] = {}
spent_by_activity: Dict[int, Dict[str, float]] = {} spent_by_activity: dict[int, dict[str, float]] = {}
issue_ids = set() issue_ids = set()
for entry in time_entries: for entry in time_entries:
if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"): if hasattr(entry, "issue") and entry.issue and hasattr(entry, "hours"):
@@ -333,3 +334,66 @@ def fetch_issues_with_spent_time(
result.append((issue, total_hours, activity_breakdown)) result.append((issue, total_hours, activity_breakdown))
return result return result
def fetch_open_issues(
user_id: int | str | None = None,
) -> list[tuple[Issue, date | None]]:
"""
Fetch open issues assigned to the given user across all projects,
paired with the due date of each issue's fixed version.
If user_id is None, uses current user.
Returns list of (issue, due_date) tuples in API order; sorting is done
in the builder layer. Raises RedmineAPIError on API/auth/network failures.
"""
try:
redmine = _create_redmine()
target_user_id = (
_resolve_user_id(redmine, user_id)
if user_id is not None
else _get_current_user_id(redmine)
)
# Без limit: python-redmine автопагинацией вычитывает все страницы,
# как в time_entry.filter выше.
issues = list(
redmine.issue.filter(assigned_to_id=target_user_id, status_id="open")
)
except RedmineAPIError:
raise
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# fixed_version внутри задачи содержит только id/name, поэтому due_date
# приходится добирать отдельным запросом на каждую уникальную версию (#71).
# Отказ загрузки одной версии не валидует выгрузку: её задачи получат None.
due_by_version: dict[int, date | None] = {}
for issue in issues:
version_ref = getattr(issue, "fixed_version", None)
if version_ref is None:
continue
version_id = getattr(version_ref, "id", None)
if version_id is None or version_id in due_by_version:
continue
try:
version = redmine.version.get(version_id)
except Exception as exc: # noqa: BLE001 -- отказ загрузки одной версии не валидует выгрузку, деградируем к due_date=None
print(
f"⚠️ Could not load version {version_id}: {exc}. "
"Due date will be empty for its issues.",
file=sys.stderr,
)
due_by_version[version_id] = None
else:
due_by_version[version_id] = getattr(version, "due_date", None)
# Порядок задач сохраняем как в ответе API.
result: list[tuple[Issue, date | None]] = []
for issue in issues:
version_ref = getattr(issue, "fixed_version", None)
version_id = (
getattr(version_ref, "id", None) if version_ref is not None else None
)
due = due_by_version.get(version_id) if version_id is not None else None
result.append((issue, due))
return result

View File

@@ -4,7 +4,6 @@ import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from typing import Dict, Union
import yaml import yaml
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -13,7 +12,7 @@ from .yaml_config import check_file_permissions, resolve_env_vars
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_REDMINE_VERIFY: Union[bool, str] = True DEFAULT_REDMINE_VERIFY: bool | str = True
FALSE_VALUES = {"0", "false", "no", "off"} FALSE_VALUES = {"0", "false", "no", "off"}
TRUE_VALUES = {"1", "true", "yes", "on"} TRUE_VALUES = {"1", "true", "yes", "on"}
@@ -45,17 +44,17 @@ class AppConfig:
redmine_url: str = "" redmine_url: str = ""
redmine_api_key: str = "" redmine_api_key: str = ""
redmine_author: str = "" redmine_author: str = ""
redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY redmine_verify: bool | str = DEFAULT_REDMINE_VERIFY
period_from: str = "" period_from: str = ""
output_dir: str = "" output_dir: str = ""
output_filename: str = "{author}_{from}_{to}.{ext}" output_filename: str = "{author}_{from}_{to}.{ext}"
output_default_format: str = "xlsx" output_default_format: str = "xlsx"
report_no_time: bool = False report_no_time: bool = False
report_status_translation: Dict[str, str] = field(default_factory=dict) report_status_translation: dict[str, str] = field(default_factory=dict)
email: EmailConfig = field(default_factory=EmailConfig) email: EmailConfig = field(default_factory=EmailConfig)
@classmethod @classmethod
def from_yaml(cls, path: Union[str, Path]) -> "AppConfig": def from_yaml(cls, path: str | Path) -> "AppConfig":
"""Загружает настройки из YAML-файла. """Загружает настройки из YAML-файла.
Если файл не существует — возвращает конфиг со значениями по умолчанию. Если файл не существует — возвращает конфиг со значениями по умолчанию.
@@ -118,7 +117,7 @@ class AppConfig:
return str(value) return str(value)
@staticmethod @staticmethod
def _resolve_str_dict(raw: dict, section: str, key: str) -> Dict[str, str]: def _resolve_str_dict(raw: dict, section: str, key: str) -> dict[str, str]:
"""Читает вложенный mapping строк из YAML-секции. """Читает вложенный mapping строк из YAML-секции.
Не-mapping значение логируется с warning и игнорируется. Не-mapping значение логируется с warning и игнорируется.
@@ -140,7 +139,7 @@ class AppConfig:
type(value).__name__, type(value).__name__,
) )
return {} return {}
result: Dict[str, str] = {} result: dict[str, str] = {}
for k, v in value.items(): for k, v in value.items():
if v is None or isinstance(v, (dict, list)): if v is None or isinstance(v, (dict, list)):
logger.warning( logger.warning(
@@ -168,7 +167,7 @@ class AppConfig:
return False return False
@classmethod @classmethod
def _resolve_verify(cls, raw: dict) -> Union[bool, str]: def _resolve_verify(cls, raw: dict) -> bool | str:
"""Семантика verify_ssl, единая с REDMINE_VERIFY из env: """Семантика verify_ssl, единая с REDMINE_VERIFY из env:
- true (bool или строка) → True — стандартная проверка TLS (requests); - true (bool или строка) → True — стандартная проверка TLS (requests);
@@ -314,7 +313,7 @@ class Config:
return os.getenv("REDMINE_PASSWORD", "").strip() return os.getenv("REDMINE_PASSWORD", "").strip()
@classmethod @classmethod
def get_redmine_verify(cls) -> Union[bool, str]: def get_redmine_verify(cls) -> bool | str:
value = os.getenv("REDMINE_VERIFY", "").strip() value = os.getenv("REDMINE_VERIFY", "").strip()
if value: if value:
normalized = value.lower() normalized = value.lower()
@@ -364,7 +363,7 @@ class Config:
return False return False
@classmethod @classmethod
def get_status_translation(cls) -> Dict[str, str]: def get_status_translation(cls) -> dict[str, str]:
"""Полный словарь перевода статусов: встроенный + переопределения из YAML. """Полный словарь перевода статусов: встроенный + переопределения из YAML.
Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется. Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется.
@@ -413,8 +412,7 @@ class Config:
# Будущая start инвертирует диапазон и ломает фильтр Redmine, # Будущая start инвертирует диапазон и ломает фильтр Redmine,
# поэтому сжимаем окно до сегодняшнего дня. # поэтому сжимаем окно до сегодняшнего дня.
if start > today: start = min(start, today)
start = today
return f"{start.isoformat()}--{today.isoformat()}" return f"{start.isoformat()}--{today.isoformat()}"
@classmethod @classmethod

View File

@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, List from typing import Any
from ..types import ReportRow from ..types import ReportRow
@@ -16,19 +16,17 @@ class Formatter(ABC):
""" """
@abstractmethod @abstractmethod
def format(self, rows: List[ReportRow]) -> Any: def format(self, rows: list[ReportRow]) -> Any:
""" """
Форматирует список строк отчета в нужный формат. Форматирует список строк отчета в нужный формат.
Для текстовых форматтеров возвращает str. Для текстовых форматтеров возвращает str.
Для ODTFormatter возвращает объект OpenDocument. Для ODTFormatter возвращает объект OpenDocument.
""" """
pass
@abstractmethod @abstractmethod
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
""" """
Сохраняет отформатированный отчет в файл по указанному пути. Сохраняет отформатированный отчет в файл по указанному пути.
Для форматтеров, которые не поддерживают сохранение (например, консольные), Для форматтеров, которые не поддерживают сохранение (например, консольные),
бросает NotImplementedError. бросает NotImplementedError.
""" """
pass

View File

@@ -1,5 +1,3 @@
from typing import List
from tabulate import tabulate from tabulate import tabulate
from ..types import ReportRow from ..types import ReportRow
@@ -9,7 +7,7 @@ from .base import Formatter
class TableFormatter(Formatter): class TableFormatter(Formatter):
"""Форматтер для вывода красивой таблицы в консоль.""" """Форматтер для вывода красивой таблицы в консоль."""
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: list[ReportRow]) -> str:
table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]] table_rows = [["Проект", "Версия", "Задача", "Статус", "Затрачено"]]
for r in rows: for r in rows:
time_text = r["time_text"].replace("\n", " / ") time_text = r["time_text"].replace("\n", " / ")
@@ -24,7 +22,7 @@ class TableFormatter(Formatter):
) )
return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid") return tabulate(table_rows, headers="firstrow", tablefmt="fancy_grid")
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
# Консольные форматтеры не умеют сохранять в файл напрямую. # Консольные форматтеры не умеют сохранять в файл напрямую.
# Это делается в CLI. # Это делается в CLI.
raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.") raise NotImplementedError("TableFormatter не поддерживает сохранение в файл.")
@@ -33,7 +31,7 @@ class TableFormatter(Formatter):
class CompactFormatter(Formatter): class CompactFormatter(Formatter):
"""Форматтер для компактного вывода в консоль.""" """Форматтер для компактного вывода в консоль."""
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: list[ReportRow]) -> str:
lines = [] lines = []
for r in rows: for r in rows:
time_text = r["time_text"].replace("\n", " / ") time_text = r["time_text"].replace("\n", " / ")
@@ -43,7 +41,7 @@ class CompactFormatter(Formatter):
) )
return "\n".join(lines) return "\n".join(lines)
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
# Консольные форматтеры не умеют сохранять в файл напрямую. # Консольные форматтеры не умеют сохранять в файл напрямую.
# Это делается в CLI. # Это делается в CLI.
raise NotImplementedError("CompactFormatter не поддерживает сохранение в файл.") raise NotImplementedError("CompactFormatter не поддерживает сохранение в файл.")

View File

@@ -1,6 +1,5 @@
import csv import csv
import io import io
from typing import List
from ..types import ReportRow from ..types import ReportRow
from .base import Formatter from .base import Formatter
@@ -19,7 +18,7 @@ class CSVFormatter(Formatter):
super().__init__() super().__init__()
self.no_time = no_time self.no_time = no_time
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: list[ReportRow]) -> str:
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output, dialect="excel") writer = csv.writer(output, dialect="excel")
headers = ["Project", "Version", "Issue ID", "Subject", "Status"] headers = ["Project", "Version", "Issue ID", "Subject", "Status"]
@@ -40,7 +39,7 @@ class CSVFormatter(Formatter):
writer.writerow(data) writer.writerow(data)
return output.getvalue() return output.getvalue()
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
content = self.format(rows) content = self.format(rows)
with open(output_path, "w", encoding="utf-8-sig", newline="") as f: with open(output_path, "w", encoding="utf-8-sig", newline="") as f:
f.write(content) f.write(content)

View File

@@ -1,5 +1,3 @@
from typing import Dict, Optional, Type
from .base import Formatter from .base import Formatter
from .console import CompactFormatter, TableFormatter from .console import CompactFormatter, TableFormatter
from .csv import CSVFormatter from .csv import CSVFormatter
@@ -11,7 +9,7 @@ from .xlsx import XLSXFormatter
# Словарь для сопоставления расширений файлов с классами форматтеров. # Словарь для сопоставления расширений файлов с классами форматтеров.
# ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как # ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как
# odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension. # odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension.
FORMATTER_MAP: Dict[str, Type[Formatter]] = { FORMATTER_MAP: dict[str, type[Formatter]] = {
".csv": CSVFormatter, ".csv": CSVFormatter,
".json": JSONFormatter, ".json": JSONFormatter,
".md": MarkdownFormatter, ".md": MarkdownFormatter,
@@ -21,13 +19,13 @@ FORMATTER_MAP: Dict[str, Type[Formatter]] = {
# Словарь для сопоставления типа вывода (консоль) с классами форматтеров # Словарь для сопоставления типа вывода (консоль) с классами форматтеров
CONSOLE_FORMATTER_MAP: Dict[str, Type[Formatter]] = { CONSOLE_FORMATTER_MAP: dict[str, type[Formatter]] = {
"table": TableFormatter, "table": TableFormatter,
"compact": CompactFormatter, "compact": CompactFormatter,
} }
def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]: def get_formatter_by_extension(extension: str, **kwargs) -> Formatter | None:
""" """
Возвращает экземпляр форматтера по расширению файла. Возвращает экземпляр форматтера по расширению файла.
Ключевые аргументы (**kwargs) передаются в конструктор форматтера. Ключевые аргументы (**kwargs) передаются в конструктор форматтера.
@@ -51,7 +49,7 @@ def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]:
return None return None
def get_console_formatter(formatter_type: str) -> Optional[Formatter]: def get_console_formatter(formatter_type: str) -> Formatter | None:
""" """
Возвращает экземпляр консольного форматтера по его типу. Возвращает экземпляр консольного форматтера по его типу.
""" """

View File

@@ -1,5 +1,4 @@
from html import escape from html import escape
from typing import List
from ..report_builder import group_rows_by_project_and_version from ..report_builder import group_rows_by_project_and_version
from ..types import ReportRow from ..types import ReportRow
@@ -12,7 +11,7 @@ class HTMLFormatter(Formatter):
def __init__(self, **_kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: list[ReportRow]) -> str:
projects = group_rows_by_project_and_version(rows) projects = group_rows_by_project_and_version(rows)
lines = [ lines = [
@@ -79,7 +78,7 @@ class HTMLFormatter(Formatter):
lines.append("</html>") lines.append("</html>")
return "\n".join(lines) return "\n".join(lines)
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
content = self.format(rows) content = self.format(rows)
with open(output_path, "w", encoding="utf-8") as f: with open(output_path, "w", encoding="utf-8") as f:
f.write(content) f.write(content)

View File

@@ -1,5 +1,4 @@
import json import json
from typing import List
from ..types import ReportRow from ..types import ReportRow
from .base import Formatter from .base import Formatter
@@ -11,7 +10,7 @@ class JSONFormatter(Formatter):
def __init__(self, **_kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: list[ReportRow]) -> str:
data = [] data = []
for r in rows: for r in rows:
item = { item = {
@@ -28,7 +27,7 @@ class JSONFormatter(Formatter):
data.append(item) data.append(item)
return json.dumps(data, ensure_ascii=False, indent=2) return json.dumps(data, ensure_ascii=False, indent=2)
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
content = self.format(rows) content = self.format(rows)
with open(output_path, "w", encoding="utf-8") as f: with open(output_path, "w", encoding="utf-8") as f:
f.write(content) f.write(content)

View File

@@ -1,5 +1,3 @@
from typing import List
from ..types import ReportRow from ..types import ReportRow
from .base import Formatter from .base import Formatter
@@ -14,7 +12,7 @@ class MarkdownFormatter(Formatter):
def __init__(self, **_kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: list[ReportRow]) -> str:
lines = [ lines = [
"| Проект | Версия | Задача | Статус | Затрачено |", "| Проект | Версия | Задача | Статус | Затрачено |",
"|--------|--------|--------|--------|-----------|", "|--------|--------|--------|--------|-----------|",
@@ -30,7 +28,7 @@ class MarkdownFormatter(Formatter):
) )
return "\n".join(lines) return "\n".join(lines)
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
content = self.format(rows) content = self.format(rows)
with open(output_path, "w", encoding="utf-8") as f: with open(output_path, "w", encoding="utf-8") as f:
f.write(content) f.write(content)

View File

@@ -1,5 +1,4 @@
from importlib import resources from importlib import resources
from typing import List
from odf.opendocument import OpenDocument, load from odf.opendocument import OpenDocument, load
from odf.style import Style, TableCellProperties, TableColumnProperties from odf.style import Style, TableCellProperties, TableColumnProperties
@@ -25,7 +24,7 @@ class ODTFormatter(Formatter):
self.from_date = from_date self.from_date = from_date
self.to_date = to_date self.to_date = to_date
def format(self, rows: List[ReportRow]) -> OpenDocument: def format(self, rows: list[ReportRow]) -> OpenDocument:
""" """
Форматирует данные в объект OpenDocument. Форматирует данные в объект OpenDocument.
""" """
@@ -172,7 +171,7 @@ class ODTFormatter(Formatter):
return doc return doc
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
""" """
Сохраняет сформированный документ в файл. Сохраняет сформированный документ в файл.
""" """

View File

@@ -0,0 +1,44 @@
from tabulate import tabulate
from ..types import TaskRow
class TaskTableFormatter:
"""Консольный форматтер таблицы открытых задач пользователя (режим --my-tasks).
Не входит в фабрику файловых форматтеров: таблица всегда печатается
в stdout, поэтому сохранение в файл не поддержано.
"""
def __init__(self, base_url: str) -> None:
self.base_url = base_url
def _hyperlink(self, issue_id: int) -> str:
# OSC 8-гиперссылка: номер задачи остаётся видимым терминалом без
# поддержки ссылок, а поддерживающие делают его кликабельным.
url = f"{self.base_url.rstrip('/')}/issues/{issue_id}"
return f"\x1b]8;;{url}\x1b\\{issue_id}\x1b]8;;\x1b\\"
def format(self, rows: list[TaskRow]) -> str:
table_rows = [["ID", "Тема", "Статус", "Приор.", "Версия", "Срок"]]
for row in rows:
due_text = row["due_text"]
if row["overdue"]:
due_text = f"{due_text} (!)"
table_rows.append(
[
self._hyperlink(row["issue_id"]),
row["subject"],
row["status_ru"],
row["priority"],
row["version_name"] or "-",
due_text or "-",
]
)
return tabulate(table_rows, headers="firstrow", tablefmt="simple")
def save(self, rows: list[TaskRow], output_path: str) -> None:
# Консольные форматтеры не умеют сохранять в файл напрямую.
raise NotImplementedError(
"TaskTableFormatter не поддерживает сохранение в файл."
)

View File

@@ -1,5 +1,3 @@
from typing import Dict, List, Tuple
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
@@ -36,7 +34,7 @@ class XLSXFormatter(Formatter):
super().__init__() super().__init__()
self.no_time = no_time self.no_time = no_time
def format(self, rows: List[ReportRow]) -> Workbook: def format(self, rows: list[ReportRow]) -> Workbook:
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
ws.title = "Report" ws.title = "Report"
@@ -56,9 +54,9 @@ class XLSXFormatter(Formatter):
grouped = group_rows_by_project_and_version(rows) grouped = group_rows_by_project_and_version(rows)
current_row = 2 current_row = 2
project_ranges: List[Tuple[int, int]] = [] project_ranges: list[tuple[int, int]] = []
version_ranges: List[Tuple[int, int]] = [] version_ranges: list[tuple[int, int]] = []
project_totals: Dict[str, float] = {} project_totals: dict[str, float] = {}
for project, versions in grouped.items(): for project, versions in grouped.items():
project_start_row = current_row project_start_row = current_row
@@ -175,11 +173,11 @@ class XLSXFormatter(Formatter):
return wb return wb
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: list[ReportRow], output_path: str) -> None:
wb = self.format(rows) wb = self.format(rows)
wb.save(output_path) wb.save(output_path)
def _style_header_row(self, ws: Worksheet, headers: List[str]) -> None: def _style_header_row(self, ws: Worksheet, headers: list[str]) -> None:
for col_idx, _ in enumerate(headers, start=1): for col_idx, _ in enumerate(headers, start=1):
cell = ws.cell(row=1, column=col_idx) cell = ws.cell(row=1, column=col_idx)
cell.font = Font(bold=True) cell.font = Font(bold=True)
@@ -193,9 +191,7 @@ class XLSXFormatter(Formatter):
for col_idx in range(1, 8): for col_idx in range(1, 8):
cell = ws.cell(row=row, column=col_idx) cell = ws.cell(row=row, column=col_idx)
cell.border = self._BORDER cell.border = self._BORDER
if col_idx in (1, 2): if col_idx in (1, 2) or col_idx == 4:
cell.alignment = Alignment(vertical="top", wrap_text=True)
elif col_idx == 4:
cell.alignment = Alignment(vertical="top", wrap_text=True) cell.alignment = Alignment(vertical="top", wrap_text=True)
else: else:
cell.alignment = Alignment(vertical="top") cell.alignment = Alignment(vertical="top")
@@ -210,7 +206,7 @@ class XLSXFormatter(Formatter):
def _apply_column_widths(self, ws: Worksheet) -> None: def _apply_column_widths(self, ws: Worksheet) -> None:
# Минимальные ширины по умолчанию # Минимальные ширины по умолчанию
widths: Dict[int, float] = { widths: dict[int, float] = {
1: 18.0, 1: 18.0,
2: 16.0, 2: 16.0,
3: 12.0, 3: 12.0,

View File

@@ -5,7 +5,6 @@ import smtplib
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from typing import Dict, List
from .client import RedmineAPIError from .client import RedmineAPIError
from .config import EmailConfig from .config import EmailConfig
@@ -13,7 +12,7 @@ from .types import ReportRow
SMTP_TIMEOUT = 30 SMTP_TIMEOUT = 30
MIME_TYPES: Dict[str, str] = { MIME_TYPES: dict[str, str] = {
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".odt": "application/vnd.oasis.opendocument.text", ".odt": "application/vnd.oasis.opendocument.text",
".csv": "text/csv", ".csv": "text/csv",
@@ -43,7 +42,7 @@ def _utf8_text_part(text: str, subtype: str = "plain") -> MIMEText:
return part return part
def _build_html_body(rows: List[ReportRow]) -> str: def _build_html_body(rows: list[ReportRow]) -> str:
"""Генерирует HTML-версию тела письма через HTMLFormatter.""" """Генерирует HTML-версию тела письма через HTMLFormatter."""
from .formatters.html import HTMLFormatter from .formatters.html import HTMLFormatter
@@ -56,7 +55,7 @@ def build_message(
file_path: str, file_path: str,
author: str, author: str,
period: str, period: str,
rows: List[ReportRow], rows: list[ReportRow],
) -> MIMEMultipart: ) -> MIMEMultipart:
"""Формирует MIME-письмо с подстановками, телами и вложением.""" """Формирует MIME-письмо с подстановками, телами и вложением."""
subject = email_config.subject.replace("{author}", author).replace( subject = email_config.subject.replace("{author}", author).replace(
@@ -108,7 +107,7 @@ def send_report(
file_path: str, file_path: str,
author: str, author: str,
period: str, period: str,
rows: List[ReportRow], rows: list[ReportRow],
) -> None: ) -> None:
"""Отправляет сгенерированный отчёт по email через SMTP. """Отправляет сгенерированный отчёт по email через SMTP.

View File

@@ -1,4 +1,4 @@
from typing import Dict, List, Optional, Tuple, cast from typing import cast
from redminelib.resources import Issue from redminelib.resources import Issue
@@ -22,7 +22,7 @@ STATUS_TRANSLATION = {
} }
def _format_activities(activities: Dict[str, float]) -> str: def _format_activities(activities: dict[str, float]) -> str:
"""Форматирует разбивку по активностям в многострочный текст.""" """Форматирует разбивку по активностям в многострочный текст."""
if not activities: if not activities:
return "" return ""
@@ -32,11 +32,11 @@ def _format_activities(activities: Dict[str, float]) -> str:
def build_grouped_report( def build_grouped_report(
issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]], issue_hours: list[tuple[Issue, float, dict[str, float] | None]],
fill_time: bool = True, fill_time: bool = True,
by_activity: bool = False, by_activity: bool = False,
status_translation: Optional[Dict[str, str]] = None, status_translation: dict[str, str] | None = None,
) -> List[ReportRow]: ) -> list[ReportRow]:
""" """
Преобразует список задач с затраченным временем в плоский список строк отчёта, Преобразует список задач с затраченным временем в плоский список строк отчёта,
с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений). с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений).
@@ -59,12 +59,12 @@ def build_grouped_report(
status_translation if status_translation is not None else STATUS_TRANSLATION status_translation if status_translation is not None else STATUS_TRANSLATION
) )
rows: List[ReportRow] = [] rows: list[ReportRow] = []
prev_project: str = "" prev_project: str = ""
prev_version: str = "" prev_version: str = ""
for issue, hours, *rest in issue_hours: for issue, hours, *rest in issue_hours:
activities: Optional[Dict[str, float]] = rest[0] if rest else None activities: dict[str, float] | None = rest[0] if rest else None
project = str(issue.project) project = str(issue.project)
version = get_version(issue) version = get_version(issue)
status_en = str(issue.status) status_en = str(issue.status)
@@ -108,14 +108,14 @@ def build_grouped_report(
def calculate_summary( def calculate_summary(
rows: List[ReportRow], rows: list[ReportRow],
by_activity: bool = False, by_activity: bool = False,
) -> Dict[str, float]: ) -> dict[str, float]:
"""Возвращает сводку: общее время, время по проектам, версиям и активностям.""" """Возвращает сводку: общее время, время по проектам, версиям и активностям."""
total = 0.0 total = 0.0
by_project: Dict[str, float] = {} by_project: dict[str, float] = {}
by_project_version: Dict[str, float] = {} by_project_version: dict[str, float] = {}
by_activity_name: Dict[str, float] = {} by_activity_name: dict[str, float] = {}
for r in rows: for r in rows:
hours = r.get("hours", 0.0) hours = r.get("hours", 0.0)
@@ -130,7 +130,7 @@ def calculate_summary(
for name, act_hours in activities.items(): for name, act_hours in activities.items():
by_activity_name[name] = by_activity_name.get(name, 0.0) + act_hours by_activity_name[name] = by_activity_name.get(name, 0.0) + act_hours
result: Dict[str, float] = { result: dict[str, float] = {
"total": round(total, 2), "total": round(total, 2),
**{f"project:{k}": round(v, 2) for k, v in by_project.items()}, **{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()}, **{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
@@ -144,8 +144,8 @@ def calculate_summary(
def group_rows_by_project_and_version( def group_rows_by_project_and_version(
rows: List[ReportRow], rows: list[ReportRow],
) -> Dict[str, Dict[str, List[ReportRow]]]: ) -> dict[str, dict[str, list[ReportRow]]]:
""" """
Группирует плоский список строк отчёта в иерархию project → version → [rows]. Группирует плоский список строк отчёта в иерархию project → version → [rows].
@@ -153,7 +153,7 @@ def group_rows_by_project_and_version(
Возвращает обычный dict, сохраняющий порядок вставки (Python 3.7+). Возвращает обычный dict, сохраняющий порядок вставки (Python 3.7+).
Используется форматтерами HTML и ODT для объединения ячеек. Используется форматтерами HTML и ODT для объединения ячеек.
""" """
projects: Dict[str, Dict[str, List[ReportRow]]] = {} projects: dict[str, dict[str, list[ReportRow]]] = {}
for r in rows: for r in rows:
project = r["project"] project = r["project"]
version = r["version"] version = r["version"]

View File

@@ -0,0 +1,124 @@
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="")
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

View File

@@ -1,4 +1,4 @@
from typing import Dict, Optional, TypedDict from typing import TypedDict
class ReportRowBase(TypedDict): class ReportRowBase(TypedDict):
@@ -18,4 +18,28 @@ class ReportRowBase(TypedDict):
class ReportRow(ReportRowBase, total=False): class ReportRow(ReportRowBase, total=False):
"""Строка итогового отчёта с опциональной разбивкой по активностям.""" """Строка итогового отчёта с опциональной разбивкой по активностям."""
activities: Optional[Dict[str, float]] activities: dict[str, float] | None
class TaskRow(TypedDict):
"""Строка списка открытых задач пользователя (режим --my-tasks)."""
issue_id: int
subject: str
status_ru: str
priority: str
version_name: str
due_text: str
overdue: bool
class TaskSummary(TypedDict):
"""Саммари списка открытых задач пользователя."""
total: int
overdue: int
no_version: int
no_due: int
spent_hours: float
estimated_hours: float
estimated_count: int

View File

@@ -28,11 +28,11 @@ def get_month_name_from_range(from_date: str, to_date: str) -> str:
return months[end.month] return months[end.month]
def get_version(issue) -> str: def get_version(issue, default: str = "<N/A>") -> str:
"""Возвращает версию задачи или '<N/A>', если не задана.""" """Возвращает версию задачи или default, если не задана."""
version = getattr(issue, "fixed_version", None) version = getattr(issue, "fixed_version", None)
if version is None: if version is None:
return "<N/A>" return default
name = getattr(version, "name", None) name = getattr(version, "name", None)
return str(name) if name else str(version) return str(name) if name else str(version)

View File

@@ -1,9 +1,10 @@
import os import os
from datetime import date
from unittest import mock from unittest import mock
import pytest import pytest
from redmine_reporter.cli import main, parse_date_range from redmine_reporter.cli import main, parse_date_range
from redmine_reporter.client import RedmineAPIError
VALID_ENV = { VALID_ENV = {
"REDMINE_URL": "https://red.eltex.loc", "REDMINE_URL": "https://red.eltex.loc",
@@ -1997,3 +1998,153 @@ class TestSendConfirmation:
assert "bcc@example.com" in captured.out assert "bcc@example.com" in captured.out
assert output in captured.out assert output in captured.out
mock_input.assert_called_once_with("Отправить? [y/N]: ") mock_input.assert_called_once_with("Отправить? [y/N]: ")
# ---------------------------------------------------------------------------
# #71: --my-tasks tests
# ---------------------------------------------------------------------------
def _make_task_issue(issue_id, subject="Задача", status="New", priority="P3"):
"""Mock-issue с явно заданными атрибутами: MagicMock-автогенерация недопустима.
build_task_rows читает nested-атрибуты (priority.name); unset-атрибут
MagicMock был бы автосконструирован и str() дал бы нестабильный текст.
"""
issue = mock.MagicMock()
issue.id = issue_id
issue.subject = subject
issue.status = status
issue.priority.name = priority
issue.fixed_version = None
issue.spent_hours = 0.0
issue.estimated_hours = None
return issue
def _expected_link(issue_id):
"""Точная OSC 8-последовательность для ссылки на задачу в stdout."""
return (
f"\x1b]8;;https://red.eltex.loc/issues/{issue_id}\x1b\\{issue_id}\x1b]8;;\x1b\\"
)
class TestMyTasksFlag:
"""Tests for --my-tasks flag (#71)."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@pytest.mark.parametrize(
"flag_args",
[
["--date", "2026-01-01--2026-01-31"],
["--output", "/tmp/report.xlsx"],
["--compact"],
["--summary"],
["--by-activity"],
["--no-time"],
["--commit"],
["--send"],
],
)
def test_my_tasks_rejects_report_flags(self, flag_args, capsys):
"""--my-tasks несовместим с каждым отчётным флагом: выход 1 и сообщение."""
code = main(["--my-tasks"] + flag_args)
captured = capsys.readouterr()
assert code == 1
assert "--my-tasks cannot be used with report-generating flags" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_empty_list_exits_zero(self, mock_fetch, capsys):
"""Пустой список открытых задач -- выход 0 и сообщение в stderr."""
mock_fetch.return_value = []
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 0
assert "Открытых задач нет" in captured.err
assert captured.out == ""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_passes_user_id(self, mock_fetch):
"""--user-id передаётся в fetch_open_issues."""
mock_fetch.return_value = []
code = main(["--my-tasks", "--user-id", "42"])
assert code == 0
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] == "42"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_no_user_flag_passes_none(self, mock_fetch):
"""Без флагов пользователя в fetch_open_issues уходит user_id=None."""
mock_fetch.return_value = []
code = main(["--my-tasks"])
assert code == 0
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] is None
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_happy_path(self, mock_fetch, capsys):
"""Таблица с OSC 8-ссылкой и переведённым статусом в stdout; саммари в stderr."""
issue_overdue = _make_task_issue(42, subject="Починить баг")
issue_fresh = _make_task_issue(43, subject="Добавить фичу", status="Pending")
# Срок глубоко в прошлом: overdue не зависит от даты прогона тестов.
mock_fetch.return_value = [
(issue_fresh, None),
(issue_overdue, date(2000, 1, 1)),
]
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 0
# Ссылка на base_url из env теста с точной escape-последовательностью.
assert _expected_link(42) in captured.out
assert _expected_link(43) in captured.out
# Переведённые статусы попадают в таблицу.
assert "В работе" in captured.out
assert "Ожидание" in captured.out
assert "Починить баг" in captured.out
# Саммари -- в stderr, таблица -- не там.
assert "Открытых: 2" in captured.err
assert "просроченных: 1" in captured.err
assert "потрачено:" in captured.err
assert "Открытых:" not in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_api_error_exits_one(self, mock_fetch, capsys):
"""RedmineAPIError -- понятное сообщение и выход 1."""
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 1
assert "Authentication failed" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_unexpected_error_exits_one(self, mock_fetch, capsys):
"""Непредвиденное исключение -- Unexpected error и выход 1."""
mock_fetch.side_effect = RuntimeError("boom")
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 1
assert "Unexpected error" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.TaskTableFormatter")
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_format_error_exits_one(
self, mock_fetch, mock_formatter_cls, capsys
):
"""Сбой рендера таблицы -- Formatting error и выход 1, без трейсбека."""
mock_fetch.return_value = [(_make_task_issue(7), None)]
mock_formatter_cls.return_value.format.side_effect = ValueError("boom")
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 1
assert "❌ Formatting error: boom" in captured.err

View File

@@ -1,9 +1,9 @@
import os import os
from datetime import date
from unittest import mock from unittest import mock
import pytest import pytest
from redmine_reporter.client import fetch_issues_with_spent_time, fetch_open_issues
from redmine_reporter.client import fetch_issues_with_spent_time
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY from redmine_reporter.config import DEFAULT_REDMINE_VERIFY
PASSWORD_ENV = { PASSWORD_ENV = {
@@ -57,7 +57,7 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
assert result is not None assert result is not None
assert len(result) == 1 assert len(result) == 1
issue, total_hours, _activities = result[0] _issue, total_hours, _activities = result[0]
assert total_hours == 3.5 assert total_hours == 3.5
@@ -805,3 +805,200 @@ def test_fetch_works_without_dedup_argument(mock_redmine_class):
assert result is not None assert result is not None
assert [r[0].id for r in result] == [1] assert [r[0].id for r in result] == [1]
assert result[0][1] == 2.0 assert result[0][1] == 2.0
# -- #71: fetch_open_issues --
def _make_open_issue(issue_id, version_id=None):
"""Создаёт mock открытой задачи; без version_id эмулирует отсутствие fixed_version.
Спека без fixed_version нужна, чтобы getattr(issue, "fixed_version", None)
вернул None, а не auto-атрибут MagicMock.
"""
if version_id is None:
issue = mock.MagicMock(spec=["id", "subject", "project"])
issue.id = issue_id
return issue
issue = mock.MagicMock()
issue.id = issue_id
issue.fixed_version.id = version_id
return issue
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_filters_by_user_and_open_status(mock_redmine_class):
"""issue.filter вызван ровно с assigned_to_id и status_id="open" (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = []
fetch_open_issues()
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=123, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_default_user_is_current(mock_redmine_class):
"""Без user_id резолвится текущий пользователь через user.get("current") (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=77)
mock_redmine.issue.filter.return_value = []
fetch_open_issues()
mock_redmine.user.get.assert_called_once_with("current")
_, kwargs = mock_redmine.issue.filter.call_args
assert kwargs["assigned_to_id"] == 77
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_numeric_user_id_skips_user_lookup(mock_redmine_class):
"""Числовой user_id: user.get не вызывается, фильтр по переданному ID (#71)."""
mock_redmine = mock_redmine_class.return_value
mock_redmine.issue.filter.return_value = []
fetch_open_issues(user_id=42)
mock_redmine.user.get.assert_not_called()
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=42, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_resolves_login_to_id(mock_redmine_class):
"""Строковый логин разрешается в числовой ID как в fetch_issues_with_spent_time (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_user = mock.MagicMock()
mock_user.id = 7
mock_user.login = "ivanov"
mock_redmine.user.filter.return_value = [mock_user]
mock_redmine.issue.filter.return_value = []
fetch_open_issues(user_id="ivanov")
login_filter_call = mock_redmine.user.filter.call_args_list[0]
assert login_filter_call.kwargs == {"login": "ivanov"}
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=7, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_fetches_each_unique_version_once(mock_redmine_class):
"""Две задачи с одной версией -> один version.get на версию (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2, version_id=5),
_make_open_issue(3, version_id=6),
]
result = fetch_open_issues()
assert mock_redmine.version.get.call_count == 2
version_ids = [call.args[0] for call in mock_redmine.version.get.call_args_list]
assert version_ids == [5, 6]
assert len(result) == 3
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_attaches_version_due_date(mock_redmine_class):
"""due_date из version.get попадает в кортеж задачи (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
result = fetch_open_issues()
assert result is not None
issue, due = result[0]
assert issue.id == 1
assert due == date(2026, 11, 1)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_issue_without_version_has_empty_due(mock_redmine_class):
"""Задача без fixed_version: version.get для неё не вызывается, due None (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2),
]
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
result = fetch_open_issues()
mock_redmine.version.get.assert_called_once_with(5)
assert result is not None
assert result[1][0].id == 2
assert result[1][1] is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_version_failure_warns_and_continues(mock_redmine_class, capsys):
"""Сбой version.get: warning в stderr, due None, общий результат не валится (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
mock_redmine.version.get.side_effect = Exception("version boom")
result = fetch_open_issues()
assert result is not None
assert result[0][1] is None
captured = capsys.readouterr()
assert "Could not load version 5" in captured.err
assert "version boom" in captured.err
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_result_length_matches_issues(mock_redmine_class):
"""Результат по длине и порядку соответствует списку задач из API (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2),
_make_open_issue(3, version_id=6),
]
result = fetch_open_issues()
assert result is not None
assert len(result) == 3
assert [issue.id for issue, _ in result] == [1, 2, 3]
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_wraps_api_errors(mock_redmine_class):
"""Сбой issue.filter оборачивается в RedmineAPIError с понятным сообщением (#71)."""
from redminelib.exceptions import AuthError
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.side_effect = AuthError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Authentication failed"):
fetch_open_issues()

View File

@@ -2,10 +2,10 @@ import contextlib
import os import os
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import ClassVar
from unittest import mock from unittest import mock
import pytest import pytest
from redmine_reporter.config import AppConfig, Config, EmailConfig from redmine_reporter.config import AppConfig, Config, EmailConfig
@@ -194,9 +194,8 @@ class TestDefaultDateRangePeriodFrom:
yaml_path.write_text("period:\n from: 'not-a-date'\n") yaml_path.write_text("period:\n from: 'not-a-date'\n")
Config._app = AppConfig.from_yaml(yaml_path) Config._app = AppConfig.from_yaml(yaml_path)
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING), mock_today(date(2026, 9, 15)):
with mock_today(date(2026, 9, 15)): rng = Config.get_default_date_range()
rng = Config.get_default_date_range()
assert rng == "2026-09-01--2026-09-15" assert rng == "2026-09-01--2026-09-15"
assert "not-a-date" in caplog.text assert "not-a-date" in caplog.text
@@ -208,9 +207,8 @@ class TestDefaultDateRangePeriodFrom:
from datetime import date from datetime import date
Config._app = None Config._app = None
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING), mock_today(date(2026, 9, 15)):
with mock_today(date(2026, 9, 15)): rng = Config.get_default_date_range()
rng = Config.get_default_date_range()
assert rng == "2026-09-01--2026-09-15" assert rng == "2026-09-01--2026-09-15"
assert "garbage" in caplog.text assert "garbage" in caplog.text
@@ -296,7 +294,10 @@ class TestConfigRequiresHttpsUrl:
class TestWarnWhenTlsVerificationDisabled: class TestWarnWhenTlsVerificationDisabled:
"""verify_ssl: false должен давать видимый warning в stderr (риск MITM).""" """verify_ssl: false должен давать видимый warning в stderr (риск MITM)."""
_ENV = {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"} _ENV: ClassVar[dict[str, str]] = {
"REDMINE_URL": "https://red.eltex.loc/",
"REDMINE_API_KEY": "token",
}
def _validate_env(self, verify: str): def _validate_env(self, verify: str):
return {**self._ENV, "REDMINE_VERIFY": verify} return {**self._ENV, "REDMINE_VERIFY": verify}
@@ -661,9 +662,11 @@ class TestConfigYamlFallback:
Config._app = AppConfig.from_yaml(yaml_path) Config._app = AppConfig.from_yaml(yaml_path)
with mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True): with (
with mock_today(date(2026, 7, 15)): mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True),
assert Config.get_default_date_range() == "2026-07-01--2026-07-15" mock_today(date(2026, 7, 15)),
):
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
class TestGetEmailConfig: class TestGetEmailConfig:

View File

@@ -1,11 +1,9 @@
import io import io
import json import json
from typing import List
from unittest import mock from unittest import mock
import pytest import pytest
from odf.opendocument import OpenDocument, OpenDocumentText from odf.opendocument import OpenDocument, OpenDocumentText
from redmine_reporter.formatters.console import CompactFormatter, TableFormatter from redmine_reporter.formatters.console import CompactFormatter, TableFormatter
from redmine_reporter.formatters.csv import CSVFormatter from redmine_reporter.formatters.csv import CSVFormatter
from redmine_reporter.formatters.factory import get_formatter_by_extension from redmine_reporter.formatters.factory import get_formatter_by_extension
@@ -25,7 +23,7 @@ def _make_empty_odt_bytes() -> bytes:
return buf.getvalue() return buf.getvalue()
def make_fake_report_rows() -> List[ReportRow]: def make_fake_report_rows() -> list[ReportRow]:
""" """
Генерирует фейковый отчёт с полным покрытием логики группировки: Генерирует фейковый отчёт с полным покрытием логики группировки:
- Проект A: версия v1.0 (2 задачи), версия v2.0 (1 задача) - Проект A: версия v1.0 (2 задачи), версия v2.0 (1 задача)

View File

@@ -2,7 +2,6 @@ import smtplib
from unittest import mock from unittest import mock
import pytest import pytest
from redmine_reporter.client import RedmineAPIError from redmine_reporter.client import RedmineAPIError
from redmine_reporter.config import EmailConfig, SmtpConfig from redmine_reporter.config import EmailConfig, SmtpConfig
from redmine_reporter.mailer import build_message, send_report from redmine_reporter.mailer import build_message, send_report
@@ -65,7 +64,7 @@ class TestBuildMessage:
data = buf.getvalue() data = buf.getvalue()
assert b"Content-Transfer-Encoding: 8bit" in data assert b"Content-Transfer-Encoding: 8bit" in data
assert "Автор: Иванов".encode("utf-8") in data assert "Автор: Иванов".encode() in data
def test_plain_part_uses_8bit_utf8(self): def test_plain_part_uses_8bit_utf8(self):
"""Тела письма кодируются 8bit UTF-8 (не base64), точечно на часть.""" """Тела письма кодируются 8bit UTF-8 (не base64), точечно на часть."""
@@ -74,7 +73,7 @@ class TestBuildMessage:
) )
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", []) msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
plain = [p for p in msg.walk() if p.get_content_type() == "text/plain"][0] plain = next(p for p in msg.walk() if p.get_content_type() == "text/plain")
assert plain["Content-Transfer-Encoding"] == "8bit" assert plain["Content-Transfer-Encoding"] == "8bit"
assert ( assert (
plain.get_payload(decode=True).decode("utf-8") plain.get_payload(decode=True).decode("utf-8")
@@ -205,7 +204,7 @@ class TestBuildMessage:
] ]
msg = build_message(cfg, str(report), "A", "P", rows) msg = build_message(cfg, str(report), "A", "P", rows)
html_part = [p for p in msg.walk() if p.get_content_type() == "text/html"][0] html_part = next(p for p in msg.walk() if p.get_content_type() == "text/html")
payload = html_part.get_payload(decode=True).decode("utf-8") payload = html_part.get_payload(decode=True).decode("utf-8")
assert "Project X" in payload assert "Project X" in payload
assert "Important task" in payload assert "Important task" in payload
@@ -228,9 +227,9 @@ class TestBuildMessage:
] ]
msg = build_message(cfg, str(report), "A", "P", rows) msg = build_message(cfg, str(report), "A", "P", rows)
alternative = [ alternative = next(
p for p in msg.walk() if p.get_content_type() == "multipart/alternative" p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
][0] )
payloads = alternative.get_payload() payloads = alternative.get_payload()
assert payloads[0].get_content_type() == "text/plain" assert payloads[0].get_content_type() == "text/plain"
assert payloads[1].get_content_type() == "text/html" assert payloads[1].get_content_type() == "text/html"
@@ -273,9 +272,11 @@ class TestSendReport:
report.write_text("fake content") report.write_text("fake content")
cfg = _make_email_config() cfg = _make_email_config()
with mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")): with (
with pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"): mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")),
send_report(cfg, str(report), "A", "P", []) pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"),
):
send_report(cfg, str(report), "A", "P", [])
def test_send_auth_error(self, tmp_path): def test_send_auth_error(self, tmp_path):
report = tmp_path / "report.xlsx" report = tmp_path / "report.xlsx"
@@ -297,9 +298,11 @@ class TestSendReport:
report.write_text("fake content") report.write_text("fake content")
cfg = _make_email_config() cfg = _make_email_config()
with mock.patch("smtplib.SMTP", side_effect=TimeoutError()): with (
with pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"): mock.patch("smtplib.SMTP", side_effect=TimeoutError()),
send_report(cfg, str(report), "A", "P", []) pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"),
):
send_report(cfg, str(report), "A", "P", [])
def test_send_generic_smtp_error(self, tmp_path): def test_send_generic_smtp_error(self, tmp_path):
report = tmp_path / "report.xlsx" report = tmp_path / "report.xlsx"

282
tests/test_task_list.py Normal file
View File

@@ -0,0 +1,282 @@
"""Тесты доменного ядра режима --my-tasks (#71): строки, сортировка, саммари."""
from datetime import date
from unittest import mock
from redmine_reporter.task_list import (
PRIORITY_RANK,
build_task_rows,
calculate_task_summary,
format_summary_line,
priority_rank,
)
TODAY = date(2026, 10, 28)
def make_issue(
issue_id,
priority="P3",
status="New",
fixed_version=None,
subject="Задача",
due=None,
spent_hours=0.0,
estimated_hours=None,
):
"""Собирает mock-issue с nested-атрибутами priority.name / fixed_version.name.
fixed_version=None присваивается явно: unset-атрибут MagicMock был бы
автосконструирован, и getattr() вернул бы его вместо безопасного дефолта.
"""
issue = mock.MagicMock()
issue.id = issue_id
issue.subject = subject
issue.status = status
issue.priority.name = priority
if fixed_version is None:
issue.fixed_version = None
else:
issue.fixed_version.name = fixed_version
issue.spent_hours = spent_hours
# None присваивается явно, чтобы getattr() в реализации не наткнулся
# на автосконструированный MagicMock-атрибут.
issue.estimated_hours = estimated_hours
return issue
# -- Приоритеты --
def test_priority_rank_table():
assert PRIORITY_RANK == {"P1": 1, "P2": 2, "P3": 3, "P4": 4, "P5": 5}
def test_priority_rank_known():
assert priority_rank("P1") == 1
assert priority_rank("P5") == 5
def test_priority_rank_unknown_is_lowest():
"""Неизвестное имя приоритета получает ранг ниже P5."""
assert priority_rank("P9") == 6
assert priority_rank("") == 6
def test_rows_sorted_by_priority():
"""P1 раньше P2 раньше P4; неизвестный P9 -- после P5."""
items = [
(make_issue(4, priority="P4"), None),
(make_issue(9, priority="P9"), None),
(make_issue(2, priority="P2"), None),
(make_issue(5, priority="P5"), None),
(make_issue(1, priority="P1"), None),
]
rows = build_task_rows(items, {}, today=TODAY)
assert [r["issue_id"] for r in rows] == [1, 2, 4, 5, 9]
# -- Сортировка внутри приоритетной группы --
def test_rows_due_ascending_within_priority():
items = [
(make_issue(2, priority="P2"), date(2026, 3, 10)),
(make_issue(1, priority="P2"), date(2026, 1, 1)),
(make_issue(3, priority="P2"), date(2026, 2, 15)),
]
rows = build_task_rows(items, {}, today=TODAY)
assert [r["issue_id"] for r in rows] == [1, 3, 2]
def test_rows_without_due_go_last_and_tiebreak_by_id():
"""Задачи без срока -- в конец своей группы; между ними tie-break по id."""
items = [
(make_issue(25, fixed_version="v2"), None), # версия есть, срока нет
(make_issue(30, fixed_version="v1"), date(2026, 2, 1)),
(make_issue(12), None), # версии нет, срока нет
]
rows = build_task_rows(items, {}, today=TODAY)
assert [r["issue_id"] for r in rows] == [30, 12, 25]
# -- Просроченность --
def test_overdue_yesterday():
rows = build_task_rows([(make_issue(1), date(2026, 10, 27))], {}, today=TODAY)
assert rows[0]["overdue"] is True
def test_not_overdue_today():
"""Срок сегодня -- НЕ просрочен (строгое сравнение)."""
rows = build_task_rows([(make_issue(1), date(2026, 10, 28))], {}, today=TODAY)
assert rows[0]["overdue"] is False
def test_not_overdue_tomorrow():
rows = build_task_rows([(make_issue(1), date(2026, 10, 29))], {}, today=TODAY)
assert rows[0]["overdue"] is False
def test_not_overdue_without_due():
rows = build_task_rows([(make_issue(1), None)], {}, today=TODAY)
assert rows[0]["overdue"] is False
# -- Поля строки --
def test_row_fields():
issue = make_issue(
42,
priority="P1",
status="New",
fixed_version="v2.5.0",
subject="Починить баг",
)
rows = build_task_rows([(issue, date(2026, 10, 27))], {}, today=TODAY)
row = rows[0]
assert row["issue_id"] == 42
assert row["subject"] == "Починить баг"
assert row["priority"] == "P1"
assert row["version_name"] == "v2.5.0"
assert row["due_text"] == "2026-10-27"
assert row["overdue"] is True
def test_row_empty_version_and_due_text():
"""Без версии и без срока в строке пустые строки, а не заглушки."""
rows = build_task_rows([(make_issue(7), None)], {}, today=TODAY)
assert rows[0]["version_name"] == ""
assert rows[0]["due_text"] == ""
# -- Перевод статусов --
def test_status_translated_by_dict():
issue = make_issue(1, status="New")
rows = build_task_rows([(issue, None)], {"New": "Новая"}, today=TODAY)
assert rows[0]["status_ru"] == "Новая"
def test_status_unknown_passthrough():
issue = make_issue(1, status="SomeNewStatus")
rows = build_task_rows([(issue, None)], {"New": "Новая"}, today=TODAY)
assert rows[0]["status_ru"] == "SomeNewStatus"
# -- Саммари --
def test_summary_mixed_items():
"""Смесь: без версии / версия без срока / просроченная / с оценками и без."""
items = [
# без версии, без срока, без оценки
(make_issue(1, spent_hours=1.5), None),
# версия есть, срока нет, оценка есть
(make_issue(2, fixed_version="v1", spent_hours=2.0, estimated_hours=4.0), None),
# просроченная, оценка есть
(
make_issue(3, fixed_version="v2", spent_hours=0.5, estimated_hours=3.0),
date(2026, 10, 27),
),
# не просроченная, оценка None
(
make_issue(4, fixed_version="v3", spent_hours=0.0),
date(2026, 10, 29),
),
]
summary = calculate_task_summary(items, today=TODAY)
assert summary["total"] == 4
assert summary["overdue"] == 1
assert summary["no_version"] == 1
assert summary["no_due"] == 1
assert summary["spent_hours"] == 4.0
assert summary["estimated_hours"] == 7.0
assert summary["estimated_count"] == 2
def test_summary_empty():
summary = calculate_task_summary([], today=TODAY)
assert summary == {
"total": 0,
"overdue": 0,
"no_version": 0,
"no_due": 0,
"spent_hours": 0.0,
"estimated_hours": 0.0,
"estimated_count": 0,
}
def test_summary_missing_attributes_default_to_zero():
"""У issue без атрибутов spent_hours/estimated_hours безопасные нули."""
issue = mock.MagicMock(spec=["id", "subject", "status", "priority"])
issue.id = 5
issue.status = "New"
issue.priority.name = "P3"
summary = calculate_task_summary([(issue, None)], today=TODAY)
assert summary["spent_hours"] == 0.0
assert summary["estimated_hours"] == 0.0
assert summary["estimated_count"] == 0
assert summary["no_version"] == 1
assert summary["no_due"] == 0
# -- Строка саммари --
def test_format_summary_line_with_estimates():
summary = {
"total": 4,
"overdue": 1,
"no_version": 1,
"no_due": 1,
"spent_hours": 4.0,
"estimated_hours": 7.0,
"estimated_count": 2,
}
line = format_summary_line(summary)
assert line == (
"Открытых: 4 | просроченных: 1 | без версии: 1 | без срока: 1 "
"| потрачено: 4h | оценка: 7h (по 2 из 4)"
)
def test_format_summary_line_without_estimates():
summary = {
"total": 3,
"overdue": 0,
"no_version": 2,
"no_due": 1,
"spent_hours": 2.5,
"estimated_hours": 0.0,
"estimated_count": 0,
}
line = format_summary_line(summary)
assert "оценка" not in line
assert line == (
"Открытых: 3 | просроченных: 0 | без версии: 2 | без срока: 1 | потрачено: 2.5h"
)
def test_format_summary_line_fractional_hours_use_g_format():
"""Часы форматируются :g -- без хвостовых нулей."""
summary = {
"total": 1,
"overdue": 0,
"no_version": 0,
"no_due": 0,
"spent_hours": 21.5,
"estimated_hours": 7.25,
"estimated_count": 1,
}
line = format_summary_line(summary)
assert "21.5h" in line
assert "7.25h" in line
assert "21.500000" not in line

160
tests/test_task_table.py Normal file
View File

@@ -0,0 +1,160 @@
"""Тесты консольного форматтера таблицы режима --my-tasks (#71)."""
import re
from typing import cast
import pytest
from redmine_reporter.formatters.task_table import TaskTableFormatter
from redmine_reporter.types import TaskRow
BASE_URL = "https://red.eltex.loc"
# OSC 8-гиперссылка: esc-последовательность до текста, текст, закрытие ссылки.
OSC8_RE = re.compile(r"\x1b\]8;;[^\x1b]*\x1b\\")
def make_row(**overrides) -> TaskRow:
row: TaskRow = {
"issue_id": 42,
"subject": "Починить баг",
"status_ru": "Новая",
"priority": "P2",
"version_name": "v2.5.0",
"due_text": "2026-10-15",
"overdue": False,
}
# **-распаковка dict[str, Any] в TypedDict-литерале mypy не поддерживает,
# а kwargs-словарь типизируется только как dict[str, Any], поэтому приведение.
row.update(cast(TaskRow, overrides))
return row
def expected_hyperlink(issue_id, base_url=BASE_URL):
return f"\x1b]8;;{base_url}/issues/{issue_id}\x1b\\{issue_id}\x1b]8;;\x1b\\"
# -- OSC 8-гиперссылка на ID --
def test_id_wrapped_in_exact_osc8_sequence():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row()])
assert expected_hyperlink(42) in output
def test_base_url_trailing_slash_not_duplicated():
"""base_url с завершающим слэшем не даёт двойного слэша перед /issues."""
output = TaskTableFormatter(base_url=BASE_URL + "/").format([make_row()])
assert expected_hyperlink(42) in output
assert "//issues/" not in output
def test_hyperlink_text_is_issue_number_only():
"""Текст ссылки -- только номер, тема не попадает внутрь OSC 8."""
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(issue_id=7, subject="Тема задачи")]
)
assert expected_hyperlink(7) in output
assert "Тема задачи" in output
start = output.index("Тема задачи")
assert "\x1b" not in output[:start].replace(expected_hyperlink(7), "")
def test_hyperlink_uses_row_issue_id():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row(issue_id=1234)])
assert expected_hyperlink(1234) in output
# -- Заполнение пустых полей --
def test_empty_version_rendered_as_dash():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row(version_name="")])
assert "-" in output
def test_empty_due_rendered_as_dash():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row(due_text="")])
assert "-" in output
def test_nonempty_fields_not_replaced_by_dash():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row()])
assert "v2.5.0" in output
assert "2026-10-15" in output
# -- Маркер просрочки --
def test_overdue_due_appends_marker():
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(due_text="2026-10-15", overdue=True)]
)
assert "2026-10-15 (!)" in output
def test_not_overdue_due_without_marker():
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(due_text="2026-10-15", overdue=False)]
)
assert "2026-10-15" in output
assert "(!)" not in output
# -- Заголовки и содержимое --
def test_column_headers_present():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row()])
for header in ("ID", "Тема", "Статус", "Приор.", "Версия", "Срок"):
assert header in output
def test_status_passed_through_as_is():
"""status_ru из TaskRow попадает в таблицу без изменений."""
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(status_ru="В работе")]
)
assert "В работе" in output
def test_subject_and_priority_present():
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(subject="Обновить драйвер", priority="P1")]
)
assert "Обновить драйвер" in output
assert "P1" in output
def test_multiple_rows_rendered():
rows = [make_row(issue_id=1), make_row(issue_id=2)]
output = TaskTableFormatter(base_url=BASE_URL).format(rows)
assert expected_hyperlink(1) in output
assert expected_hyperlink(2) in output
def test_empty_rows_renders_headers_only():
output = TaskTableFormatter(base_url=BASE_URL).format([])
assert "ID" in output
assert expected_hyperlink(42) not in output
# -- ESC только внутри ссылок --
def test_esc_only_inside_hyperlinks():
"""После вырезания OSC 8-ссылок в строке не остаётся ESC-символов."""
rows = [
make_row(issue_id=1),
make_row(issue_id=2, version_name=""),
make_row(issue_id=3, due_text="", overdue=True),
]
output = TaskTableFormatter(base_url=BASE_URL).format(rows)
cleaned = OSC8_RE.sub("", output)
assert "\x1b" not in cleaned
def test_save_raises_not_implemented():
formatter = TaskTableFormatter(base_url=BASE_URL)
with pytest.raises(NotImplementedError):
formatter.save([], "unused.xlsx")

View File

@@ -110,3 +110,21 @@ def test_get_version_falls_back_to_str_when_no_name():
fixed_version = MockVersionNoName() fixed_version = MockVersionNoName()
assert get_version(MockIssue()) == "fallback-id" assert get_version(MockIssue()) == "fallback-id"
def test_get_version_custom_default():
"""Явный default заменяет <N/A>, когда версия не назначена."""
class MockIssue:
pass
assert get_version(MockIssue(), default="") == ""
def test_get_version_custom_default_not_used_when_version_present():
"""При наличии версии default не применяется."""
class MockIssue:
fixed_version = "v1.0"
assert get_version(MockIssue(), default="") == "v1.0"

View File

@@ -5,7 +5,6 @@ from pathlib import Path
from unittest import mock from unittest import mock
import yaml import yaml
from redmine_reporter.yaml_config import ( from redmine_reporter.yaml_config import (
check_file_permissions, check_file_permissions,
ensure_config_dir, ensure_config_dir,