feat: auto-email sending via SMTP (--send flag)
Closes #45 - New redmine_reporter/mailer.py: SMTP email sending with {author}/{period} template substitution, MIME attachment with correct content-type per file extension - Config.get_email_config(): returns EmailConfig from YAML or None when not configured - CLI --send flag: sends report after generation, works with --output, --commit, or standalone (saves to template path) - 31 new tests (22 mailer + 6 CLI + 3 config) - 249/249 tests passing, ruff clean, mypy clean
This commit is contained in:
@@ -13,6 +13,7 @@ from . import __version__
|
||||
from .client import RedmineAPIError, fetch_issues_with_spent_time
|
||||
from .config import Config
|
||||
from .formatters.factory import get_console_formatter, get_formatter_by_extension
|
||||
from .mailer import send_report
|
||||
from .report_builder import build_grouped_report, calculate_summary
|
||||
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
|
||||
|
||||
@@ -122,6 +123,79 @@ def _compute_dedup_cutoff() -> Optional[datetime]:
|
||||
return None
|
||||
|
||||
|
||||
def _save_and_maybe_send(
|
||||
rows,
|
||||
output_arg: str,
|
||||
author: str,
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
no_time: bool,
|
||||
do_send: bool,
|
||||
) -> int:
|
||||
"""Сохраняет отчёт в файл и опционально отправляет по email.
|
||||
|
||||
Returns 0 on success, 1 on error.
|
||||
"""
|
||||
output_ext = os.path.splitext(output_arg)[1].lower()
|
||||
|
||||
if not output_ext:
|
||||
print(
|
||||
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
formatter = get_formatter_by_extension(
|
||||
output_ext,
|
||||
author=author,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
no_time=no_time,
|
||||
)
|
||||
|
||||
if not formatter:
|
||||
if output_ext == ".odt":
|
||||
print(
|
||||
"❌ odfpy is not installed. Install with: pip install odfpy",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
|
||||
print(
|
||||
f"❌ Неизвестный формат файла: {output_ext!r}. "
|
||||
f"Поддерживаются: {known_exts}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
formatter.save(rows, output_arg)
|
||||
print(f"✅ Report saved to {output_arg}")
|
||||
except Exception as e:
|
||||
fmt = output_ext.lstrip(".").upper()
|
||||
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if do_send:
|
||||
email_config = Config.get_email_config()
|
||||
if email_config is None:
|
||||
print(
|
||||
"❌ Email не настроен. Добавьте секцию 'email' в конфиг "
|
||||
"(~/.config/redmine-reporter/config.yml).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
send_report(email_config, output_arg, author, f"{from_date}--{to_date}")
|
||||
print(f"📧 Report sent to {', '.join(email_config.to)}")
|
||||
except RedmineAPIError as e:
|
||||
print(f"❌ {e.message}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="redmine-reporter",
|
||||
@@ -200,6 +274,11 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
action="store_true",
|
||||
help="Save used period as last_used in YAML config and auto-commit to file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--send",
|
||||
action="store_true",
|
||||
help="Send generated report via email after saving (requires email section in config)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# --init-config: обработка до всего остального
|
||||
@@ -215,6 +294,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
args.user_name,
|
||||
args.no_time,
|
||||
args.by_activity,
|
||||
args.send,
|
||||
]
|
||||
if any(report_flags):
|
||||
print(
|
||||
@@ -312,65 +392,48 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
activity = key.split(":", 1)[1]
|
||||
print(f" [{activity}]: {value}h", file=sys.stderr)
|
||||
|
||||
if args.output:
|
||||
output_arg = resolve_output_path(
|
||||
args.output,
|
||||
output_dir=Config.get_output_dir(),
|
||||
filename_template=Config.get_output_filename(),
|
||||
default_format=Config.get_output_default_format(),
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
if args.output or args.commit:
|
||||
if args.output:
|
||||
output_arg = resolve_output_path(
|
||||
args.output,
|
||||
output_dir=Config.get_output_dir(),
|
||||
filename_template=Config.get_output_filename(),
|
||||
default_format=Config.get_output_default_format(),
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
else:
|
||||
# --commit без --output: используем default_format
|
||||
default_format = Config.get_output_default_format()
|
||||
output_arg = resolve_output_path(
|
||||
default_format,
|
||||
output_dir=Config.get_output_dir(),
|
||||
filename_template=Config.get_output_filename(),
|
||||
default_format=default_format,
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
|
||||
if output_arg is None:
|
||||
print(
|
||||
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
output_ext = os.path.splitext(output_arg)[1].lower()
|
||||
|
||||
if not output_ext:
|
||||
print(
|
||||
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
formatter = get_formatter_by_extension(
|
||||
output_ext,
|
||||
ret = _save_and_maybe_send(
|
||||
rows,
|
||||
output_arg,
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
no_time=args.no_time,
|
||||
do_send=args.send,
|
||||
)
|
||||
if ret != 0:
|
||||
return ret
|
||||
|
||||
if not formatter:
|
||||
if output_ext == ".odt":
|
||||
print(
|
||||
"❌ odfpy is not installed. Install with: pip install odfpy",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
|
||||
print(
|
||||
f"❌ Неизвестный формат файла: {output_ext!r}. "
|
||||
f"Поддерживаются: {known_exts}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
formatter.save(rows, output_arg)
|
||||
print(f"✅ Report saved to {output_arg}")
|
||||
except Exception as e:
|
||||
fmt = output_ext.lstrip(".").upper()
|
||||
print(f"❌ {fmt} export error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
elif args.commit:
|
||||
elif args.send:
|
||||
# --send без --output и --commit: сохраняем по шаблону и отправляем
|
||||
default_format = Config.get_output_default_format()
|
||||
output_arg = resolve_output_path(
|
||||
default_format,
|
||||
@@ -386,25 +449,17 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
print("❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
output_ext = os.path.splitext(output_arg)[1].lower()
|
||||
formatter = get_formatter_by_extension(
|
||||
output_ext,
|
||||
ret = _save_and_maybe_send(
|
||||
rows,
|
||||
output_arg,
|
||||
author=Config.get_author(args.author),
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
no_time=args.no_time,
|
||||
do_send=True,
|
||||
)
|
||||
|
||||
if not formatter:
|
||||
print(f"❌ Не удалось создать форматтер для {output_ext}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
formatter.save(rows, output_arg)
|
||||
print(f"✅ Report saved to {output_arg}")
|
||||
except Exception as e:
|
||||
print(f"❌ Export error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if ret != 0:
|
||||
return ret
|
||||
|
||||
else:
|
||||
if args.compact:
|
||||
|
||||
Reference in New Issue
Block a user