diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 660883f..a65cdd3 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -14,7 +14,7 @@ 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 .report_builder import build_grouped_report, calculate_summary -from .yaml_config import ensure_config_dir, resolve_output_path +from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config def parse_date_range(date_arg: str) -> tuple[str, str]: @@ -195,6 +195,11 @@ def main(argv: Optional[List[str]] = None) -> int: default=str(Path.home() / ".config" / "redmine-reporter" / "config.yml"), help="Path for --init-config output (default: ~/.config/redmine-reporter/config.yml)", ) + parser.add_argument( + "--commit", + action="store_true", + help="Save used period as last_used in YAML config and auto-commit to file", + ) args = parser.parse_args(argv) # --init-config: обработка до всего остального @@ -365,6 +370,42 @@ def main(argv: Optional[List[str]] = None) -> int: print(f"❌ {fmt} export error: {e}", file=sys.stderr) return 1 + elif args.commit: + 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("❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr) + return 1 + + output_ext = os.path.splitext(output_arg)[1].lower() + formatter = get_formatter_by_extension( + output_ext, + author=Config.get_author(args.author), + from_date=from_date, + to_date=to_date, + no_time=args.no_time, + ) + + 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 + else: if args.compact: formatter = get_console_formatter("compact") @@ -382,6 +423,26 @@ def main(argv: Optional[List[str]] = None) -> int: print(f"❌ Formatting error: {e}", file=sys.stderr) return 1 + if args.commit: + precision = Config.get_period_precision() + dynamic = Config._app.period_dynamic if Config._app else False + + if precision == "datetime": + from datetime import datetime as dt_mod + + now = dt_mod.now().isoformat(timespec="seconds") + from_str = now + to_str = now + else: + from_str = from_date + to_str = to_date + + save_period_to_config(args.config_path, from_str, to_str, precision, dynamic) + print( + f"📌 Period committed [{from_str} -- {to_str}] → {args.config_path}", + file=sys.stderr, + ) + return 0 diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 1f66f32..a334cbd 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -350,10 +350,22 @@ class Config: if from_env and to_env: return f"{from_env}--{to_env}" + if ( + cls._app + and cls._app.period_dynamic + and cls._app.period_last_used_from + and cls._app.period_last_used_to + ): + nf, nt = compute_next_period( + cls._app.period_last_used_from, + cls._app.period_last_used_to, + cls._app.period_precision or "date", + ) + return f"{nf}--{nt}" + if cls._app and cls._app.period_default_from and cls._app.period_default_to: return f"{cls._app.period_default_from}--{cls._app.period_default_to}" - # fallback: текущий месяц today = date.today() start = today.replace(day=1) if today.month == 12: @@ -373,3 +385,50 @@ class Config: raise ValueError( "REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD" ) + + +def compute_next_period(last_from: str, last_to: str, precision: str) -> tuple[str, str]: + """Compute the next report period based on the last committed period. + + - For a full calendar month → next full calendar month. + - For an arbitrary range → same duration, starting the day after last_to. + - For datetime precision → same duration, starting 1 second after last_to. + """ + from datetime import datetime as dt_mod + + if precision == "datetime": + fmt = "%Y-%m-%dT%H:%M:%S" + from_dt = dt_mod.fromisoformat(last_from.replace("Z", "+00:00")) + to_dt = dt_mod.fromisoformat(last_to.replace("Z", "+00:00")) + duration = to_dt - from_dt + next_from_dt = to_dt + timedelta(seconds=1) + next_to_dt = next_from_dt + duration + return next_from_dt.strftime(fmt), next_to_dt.strftime(fmt) + + from_parts = last_from.split("-") + to_parts = last_to.split("-") + from_d = date(int(from_parts[0]), int(from_parts[1]), int(from_parts[2])) + to_d = date(int(to_parts[0]), int(to_parts[1]), int(to_parts[2])) + + first_of_month = from_d.replace(day=1) + if from_d == first_of_month: + if to_d.month == 12: + last_of_month = date(to_d.year, 12, 31) + else: + next_first = date(to_d.year, to_d.month + 1, 1) + last_of_month = next_first - timedelta(days=1) + if to_d == last_of_month: + if to_d.month == 12: + nstart = date(to_d.year + 1, 1, 1) + else: + nstart = date(to_d.year, to_d.month + 1, 1) + if nstart.month == 12: + nend = date(nstart.year, 12, 31) + else: + nend = date(nstart.year, nstart.month + 1, 1) - timedelta(days=1) + return nstart.isoformat(), nend.isoformat() + + duration = to_d - from_d + next_from = to_d + timedelta(days=1) + next_to = next_from + duration + return next_from.isoformat(), next_to.isoformat() diff --git a/redmine_reporter/yaml_config.py b/redmine_reporter/yaml_config.py index c8ee774..b87602a 100644 --- a/redmine_reporter/yaml_config.py +++ b/redmine_reporter/yaml_config.py @@ -149,3 +149,38 @@ def resolve_output_path( arg = f"{arg}.{default_format}" return arg + + +def save_period_to_config( + config_path: str, + from_str: str, + to_str: str, + precision: str, + dynamic: bool, +) -> None: + """Save committed period to YAML config file. + + Writes period.last_used.from / period.last_used.to. + When dynamic=False, also overwrites period.default_from / period.default_to. + + Preserves all other sections unchanged. Creates config file if missing. + """ + import yaml + + path = Path(config_path) + raw: dict = {} + if path.exists(): + with open(path, "r", encoding="utf-8") as fh: + raw = yaml.safe_load(fh) or {} + + period = raw.setdefault("period", {}) + period["last_used"] = {"from": from_str, "to": to_str} + + if not dynamic: + period["default_from"] = from_str.split("T")[0] if "T" in from_str else from_str + period["default_to"] = to_str.split("T")[0] if "T" in to_str else to_str + + ensure_config_dir(path.parent) + with open(path, "w", encoding="utf-8") as fh: + yaml.dump(raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False) + path.chmod(0o600) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2b9770e..7935232 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -538,3 +538,230 @@ class TestOutputPathResolution: mock_formatter.save.assert_called_once() saved_path = mock_formatter.save.call_args.args[1] assert saved_path == "myreport.xlsx" + + +# --------------------------------------------------------------------------- +# #44: --commit tests +# --------------------------------------------------------------------------- + + +class TestCommitFlag: + """Tests for --commit flag.""" + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_commit_saves_period_to_config(self, mock_save, mock_fetch, tmp_path): + """--commit сохраняет период в YAML-конфиг.""" + import yaml + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + config_path = tmp_path / "config.yml" + config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}})) + + with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--commit", + "--output", + str(tmp_path / "report.xlsx"), + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + mock_save.assert_called_once_with( + str(config_path), "2026-06-01", "2026-06-30", "date", True + ) + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_commit_with_precision_datetime_passes_datetime_strings( + self, mock_save, mock_fetch, tmp_path + ): + """При precision=datetime --commit сохраняет timestamps.""" + import yaml + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + config_path = tmp_path / "config.yml" + config_path.write_text(yaml.dump({"period": {"precision": "datetime", "dynamic": True}})) + + with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + code = main( + [ + "--date", + "2026-06-30--2026-06-30", + "--commit", + "--output", + str(tmp_path / "report.xlsx"), + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + call_args = mock_save.call_args + assert call_args is not None + saved_from, saved_to = call_args.args[1], call_args.args[2] + assert "T" in saved_from + assert "T" in saved_to + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path): + """Без записей --commit не пишет конфиг.""" + mock_fetch.return_value = None + + config_path = tmp_path / "config.yml" + config_path.write_text("period:\n precision: date\n dynamic: true\n") + + code = main( + [ + "--date", + "2026-01-01--2026-01-31", + "--commit", + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_commit_dynamic_false_overwrites_defaults(self, mock_save, mock_fetch, tmp_path): + """При dynamic=false --commit перезаписывает default_from/to.""" + import yaml + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + config_path = tmp_path / "config.yml" + config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": False}})) + + with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--commit", + "--output", + str(tmp_path / "report.xlsx"), + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + mock_save.assert_called_once_with( + str(config_path), "2026-06-01", "2026-06-30", "date", False + ) + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_commit_without_output_uses_default_path(self, mock_save, mock_fetch, tmp_path): + """--commit без --output сохраняет файл по шаблону из конфига.""" + import yaml + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + config_path = tmp_path / "config.yml" + config_path.write_text( + yaml.dump( + { + "period": {"precision": "date", "dynamic": True}, + "output": { + "dir": str(tmp_path / "reports"), + "filename": "report_{date}.{ext}", + "default_format": "xlsx", + }, + } + ) + ) + + with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--commit", + "--config-path", + str(config_path), + ] + ) + assert code == 0 + mock_formatter.save.assert_called_once() + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_commit_prints_info_to_stderr(self, mock_save, mock_fetch, capsys, tmp_path): + """--commit выводит сообщение о фиксации в stderr.""" + import yaml + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + config_path = tmp_path / "config.yml" + config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}})) + + with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get: + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter + code = main( + [ + "--date", + "2026-06-01--2026-06-30", + "--commit", + "--output", + str(tmp_path / "report.xlsx"), + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + captured = capsys.readouterr() + assert "commit" in captured.err.lower() or "Commit" in captured.err + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + @mock.patch("redmine_reporter.cli.save_period_to_config") + def test_commit_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path): + """Пустой список задач — --commit не сохраняет конфиг.""" + import yaml + + mock_fetch.return_value = [] + + config_path = tmp_path / "config.yml" + config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}})) + + code = main( + [ + "--date", + "2026-01-01--2026-01-31", + "--commit", + "--config-path", + str(config_path), + ] + ) + assert code == 0 + mock_save.assert_not_called() diff --git a/tests/test_config.py b/tests/test_config.py index 11f948c..06dbb51 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -451,3 +451,109 @@ class TestConfigPeriodPrecision: cfg = AppConfig.from_yaml(yaml_path) assert cfg.period_last_used_from == "" assert cfg.period_last_used_to == "" + + +class TestComputeNextPeriod: + """Tests for compute_next_period().""" + + def test_full_month_goes_to_next_full_month(self): + from redmine_reporter.config import compute_next_period + + nf, nt = compute_next_period("2026-06-01", "2026-06-30", "date") + assert nf == "2026-07-01" + assert nt == "2026-07-31" + + def test_december_to_next_year_january(self): + from redmine_reporter.config import compute_next_period + + nf, nt = compute_next_period("2025-12-01", "2025-12-31", "date") + assert nf == "2026-01-01" + assert nt == "2026-01-31" + + def test_february_2026_non_leap_to_march(self): + from redmine_reporter.config import compute_next_period + + nf, nt = compute_next_period("2026-02-01", "2026-02-28", "date") + assert nf == "2026-03-01" + assert nt == "2026-03-31" + + def test_arbitrary_range_same_length_from_to_plus_one(self): + from redmine_reporter.config import compute_next_period + + nf, nt = compute_next_period("2026-06-15", "2026-06-20", "date") + assert nf == "2026-06-21" + assert nt == "2026-06-26" + + def test_single_day_range_moves_one_day(self): + from redmine_reporter.config import compute_next_period + + nf, nt = compute_next_period("2026-06-15", "2026-06-15", "date") + assert nf == "2026-06-16" + assert nt == "2026-06-16" + + def test_cross_month_range(self): + from redmine_reporter.config import compute_next_period + + nf, nt = compute_next_period("2026-06-25", "2026-07-05", "date") + assert nf == "2026-07-06" + assert nt == "2026-07-16" + + def test_datetime_precision_moves_by_seconds(self): + from redmine_reporter.config import compute_next_period + + nf, nt = compute_next_period("2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime") + assert nf == "2026-06-30T12:00:01" + assert nt == "2026-06-30T15:00:01" + + +class TestDefaultDateRangeWithLastUsed: + """Tests that get_default_date_range() uses last_used when dynamic=True.""" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_uses_last_used_when_dynamic_true(self): + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "period:\n" + " precision: date\n" + " dynamic: true\n" + " last_used:\n" + " from: '2026-05-01'\n" + " to: '2026-05-31'\n" + ) + Config._app = AppConfig.from_yaml(yaml_path) + result = Config.get_default_date_range() + assert result == "2026-06-01--2026-06-30" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_ignores_last_used_when_dynamic_false(self): + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "period:\n" + " precision: date\n" + " dynamic: false\n" + " default_from: '2026-03-01'\n" + " default_to: '2026-03-15'\n" + " last_used:\n" + " from: '2026-06-01'\n" + " to: '2026-06-30'\n" + ) + Config._app = AppConfig.from_yaml(yaml_path) + result = Config.get_default_date_range() + assert result == "2026-03-01--2026-03-15" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_falls_back_when_no_last_used_even_with_dynamic(self): + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "period:\n" + " precision: date\n" + " dynamic: true\n" + " default_from: '2026-04-01'\n" + " default_to: '2026-04-15'\n" + ) + Config._app = AppConfig.from_yaml(yaml_path) + result = Config.get_default_date_range() + assert result == "2026-04-01--2026-04-15" diff --git a/tests/test_yaml_config.py b/tests/test_yaml_config.py index 557b001..65359a1 100644 --- a/tests/test_yaml_config.py +++ b/tests/test_yaml_config.py @@ -206,3 +206,115 @@ class TestResolveOutputPath: result = resolve_output_path("unknown_format") assert result == "unknown_format.xlsx" + + +class TestSavePeriodToConfig: + """Tests for save_period_to_config().""" + + def test_creates_last_used_in_empty_config(self, tmp_path): + import yaml + + from redmine_reporter.yaml_config import save_period_to_config + + config_path = tmp_path / "config.yml" + save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True) + + with open(config_path) as fh: + data = yaml.safe_load(fh) + + assert data["period"]["last_used"]["from"] == "2026-06-01" + assert data["period"]["last_used"]["to"] == "2026-06-30" + assert "default_from" not in data["period"] + + def test_overwrites_existing_last_used(self, tmp_path): + import yaml + + from redmine_reporter.yaml_config import save_period_to_config + + config_path = tmp_path / "config.yml" + config_path.write_text( + "period:\n" " last_used:\n" " from: '2026-05-01'\n" " to: '2026-05-31'\n" + ) + + save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True) + + with open(config_path) as fh: + data = yaml.safe_load(fh) + + assert data["period"]["last_used"]["from"] == "2026-06-01" + assert data["period"]["last_used"]["to"] == "2026-06-30" + + def test_dynamic_false_overwrites_defaults(self, tmp_path): + import yaml + + from redmine_reporter.yaml_config import save_period_to_config + + config_path = tmp_path / "config.yml" + config_path.write_text( + "period:\n" " default_from: '2026-01-01'\n" " default_to: '2026-01-31'\n" + ) + + save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", False) + + with open(config_path) as fh: + data = yaml.safe_load(fh) + + assert data["period"]["default_from"] == "2026-06-01" + assert data["period"]["default_to"] == "2026-06-30" + assert data["period"]["last_used"]["from"] == "2026-06-01" + assert data["period"]["last_used"]["to"] == "2026-06-30" + + def test_datetime_precision_stores_timestamps(self, tmp_path): + import yaml + + from redmine_reporter.yaml_config import save_period_to_config + + config_path = tmp_path / "config.yml" + + save_period_to_config( + str(config_path), + "2026-06-30T09:00:00", + "2026-06-30T12:00:00", + "datetime", + True, + ) + + with open(config_path) as fh: + data = yaml.safe_load(fh) + + assert data["period"]["last_used"]["from"] == "2026-06-30T09:00:00" + assert data["period"]["last_used"]["to"] == "2026-06-30T12:00:00" + + def test_preserves_existing_sections(self, tmp_path): + import yaml + + from redmine_reporter.yaml_config import save_period_to_config + + config_path = tmp_path / "config.yml" + config_path.write_text( + "redmine:\n" + " url: https://example.com\n" + " author: Test\n" + "output:\n" + " dir: /tmp\n" + ) + + save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True) + + with open(config_path) as fh: + data = yaml.safe_load(fh) + + assert data["redmine"]["url"] == "https://example.com" + assert data["redmine"]["author"] == "Test" + assert data["output"]["dir"] == "/tmp" + assert data["period"]["last_used"]["from"] == "2026-06-01" + + def test_sets_permissions_0600(self, tmp_path): + from redmine_reporter.yaml_config import save_period_to_config + + config_path = tmp_path / "config.yml" + + save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True) + + mode = config_path.stat().st_mode & 0o777 + assert mode == 0o600