From 47152f8f0406af18794a7341fee61510cc84a0bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Tue, 7 Jul 2026 10:16:46 +0700 Subject: [PATCH] feat: output path defaults (#43) + datetime precision & dedup (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #43 — Имя файла и пути по умолчанию: - resolve_output_path() в yaml_config.py: резолвит --output с учётом output.dir, filename_template, default_format из YAML-конфига - Bare format (xlsx/odt/...) → путь по шаблону - Без расширения → автодописывается default_format (.xlsx) - Config.get_output_dir/filename/default_format() #47 — datetime precision и дедупликация: - period.last_used.from/to в YAML-конфиге и AppConfig - period.precision: date|datetime - _compute_dedup_cutoff() в cli.py: при precision=datetime вычисляет cutoff из period.last_used.to - _parse_datetime() + AND-логика дедупликации в client.py: запись исключается если created_on И updated_on < cutoff - Пропуск issues без часов после дедупликации Closes #43 Closes #47 --- pyproject.toml | 2 +- redmine_reporter/cli.py | 51 ++++++- redmine_reporter/client.py | 51 ++++++- redmine_reporter/config.py | 56 +++++++- redmine_reporter/yaml_config.py | 50 +++++++ tests/test_cli.py | 139 ++++++++++++++++++- tests/test_client.py | 236 ++++++++++++++++++++++++++++++++ tests/test_config.py | 64 +++++++++ tests/test_yaml_config.py | 84 ++++++++++++ 9 files changed, 718 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d2b778b..96dedd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,5 +58,5 @@ multi_line_output = 3 warn_unused_configs = true [[tool.mypy.overrides]] -module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*"] +module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*", "yaml", "requests", "urllib3.*"] ignore_missing_imports = true diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 8520ca0..660883f 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -3,7 +3,7 @@ import logging import os import re import sys -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import List, Optional @@ -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 +from .yaml_config import ensure_config_dir, resolve_output_path def parse_date_range(date_arg: str) -> tuple[str, str]: @@ -99,6 +99,29 @@ def _run_init_config(config_path: str, force: bool) -> int: return 0 +def _compute_dedup_cutoff() -> Optional[datetime]: + """Compute deduplication cutoff from config. + + If period.precision == 'datetime' and period.last_used.to is set, + return the datetime to filter out entries already accounted for. + Otherwise return None. + """ + if Config.get_period_precision() != "datetime": + return None + + last_to = Config.get_last_used_to() + if not last_to: + return None + + try: + dt = datetime.fromisoformat(last_to.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except (ValueError, TypeError): + return None + + def main(argv: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser( prog="redmine-reporter", @@ -243,6 +266,7 @@ def main(argv: Optional[List[str]] = None) -> int: to_date, user_id=args.user_id or args.user_login or args.user_name, by_activity=args.by_activity, + dedup_before=_compute_dedup_cutoff(), ) except RedmineAPIError as e: print(f"❌ {e.message}", file=sys.stderr) @@ -284,7 +308,24 @@ def main(argv: Optional[List[str]] = None) -> int: print(f" [{activity}]: {value}h", file=sys.stderr) if args.output: - output_ext = os.path.splitext(args.output)[1].lower() + 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 output_arg is None: + print( + "❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx", + file=sys.stderr, + ) + return 1 + + output_ext = os.path.splitext(output_arg)[1].lower() if not output_ext: print( @@ -317,8 +358,8 @@ def main(argv: Optional[List[str]] = None) -> int: return 1 try: - formatter.save(rows, args.output) - print(f"✅ Report saved to {args.output}") + 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) diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py index fcb5cb4..1501adc 100644 --- a/redmine_reporter/client.py +++ b/redmine_reporter/client.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Any, Dict, List, Optional, Tuple, Union import requests @@ -144,6 +145,25 @@ def _get_activity_name(entry, activities: Dict[int, str]) -> str: return str(activity) +def _parse_datetime(value: Any) -> Optional[datetime]: + """Parse a datetime from Redmine API response. + + Accepts datetime objects, ISO strings (with or without timezone), + or None. Returns a timezone-aware datetime or None. + """ + if value is None: + return None + if isinstance(value, datetime): + return value + if isinstance(value, str): + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + return dt + except (ValueError, TypeError): + return None + return None + + def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]: """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" all_issues: List[Issue] = [] @@ -220,12 +240,15 @@ def fetch_issues_with_spent_time( to_date: str, user_id: Optional[Union[int, str]] = None, by_activity: bool = False, + dedup_before: Optional[datetime] = None, ) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]: """ Fetch unique issues linked to time entries of the given user in date range, along with total spent hours per issue. If user_id is None, uses current user. If by_activity is True, returns per-activity breakdown as third tuple element. + If dedup_before is set, filters out time entries whose created_on AND updated_on + are both before dedup_before (AND logic: both must be < cutoff to exclude). Returns list of (issue, total_hours, activities) tuples. Raises RedmineAPIError on API/auth/network failures. """ @@ -238,14 +261,34 @@ def fetch_issues_with_spent_time( else _get_current_user_id(redmine) ) activities_lookup = _load_time_entry_activities(redmine) if by_activity else {} - time_entries = redmine.time_entry.filter( - user_id=target_user_id, from_date=from_date, to_date=to_date + time_entries = list( + redmine.time_entry.filter(user_id=target_user_id, from_date=from_date, to_date=to_date) ) except RedmineAPIError: raise except Exception as exc: raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc + # Дедупликация: отсекаем записи, которые были учтены в предыдущем отчёте. + # Запись исключается, если BOTH created_on AND updated_on < dedup_before. + # Записи без метаданных (created_on/updated_on == None) не фильтруются. + if dedup_before is not None: + filtered: list = [] + for entry in time_entries: + created = _parse_datetime(getattr(entry, "created_on", None)) + updated = _parse_datetime(getattr(entry, "updated_on", None)) + + if created is None and updated is None: + filtered.append(entry) + elif created is not None and updated is not None: + if created >= dedup_before and updated >= dedup_before: + filtered.append(entry) + elif created is not None and created >= dedup_before: + filtered.append(entry) + elif updated is not None and updated >= dedup_before: + filtered.append(entry) + time_entries = filtered + # Агрегируем часы по issue.id (и активности, если требуется) spent_time: Dict[int, float] = {} spent_by_activity: Dict[int, Dict[str, float]] = {} @@ -278,7 +321,9 @@ def fetch_issues_with_spent_time( result = [] for issue in issues: iid = issue.id - total_hours = spent_time.get(iid, 0.0) + if iid not in spent_time: + continue + total_hours = spent_time[iid] activity_breakdown = spent_by_activity.get(iid) if by_activity else None result.append((issue, total_hours, activity_breakdown)) diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 57b4acb..1f66f32 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -40,6 +40,12 @@ class EmailConfig: attach: bool = True +@dataclass +class PeriodLastUsed: + from_: str = "" + to: str = "" + + @dataclass class AppConfig: redmine_url: str = "" @@ -50,6 +56,8 @@ class AppConfig: period_default_from: str = "" period_default_to: str = "" period_dynamic: bool = False + period_last_used_from: str = "" + period_last_used_to: str = "" output_dir: str = "" output_filename: str = "{author}_{from}_{to}.{ext}" output_default_format: str = "xlsx" @@ -93,6 +101,8 @@ class AppConfig: period_default_from=cls._resolve_str(raw, "period", "default_from"), period_default_to=cls._resolve_str(raw, "period", "default_to"), period_dynamic=cls._resolve_bool(raw, "period", "dynamic"), + period_last_used_from=cls._resolve_str(raw, "period", "last_used", "from"), + period_last_used_to=cls._resolve_str(raw, "period", "last_used", "to"), output_dir=cls._resolve_str(raw, "output", "dir"), output_filename=cls._resolve_str(raw, "output", "filename") or "{author}_{from}_{to}.{ext}", @@ -112,8 +122,14 @@ class AppConfig: ) @staticmethod - def _resolve_str(raw: dict, section: str, key: str) -> str: - value = raw.get(section, {}).get(key, "") + def _resolve_str(raw: dict, section: str, *keys: str) -> str: + value = raw.get(section) + for key in keys: + if isinstance(value, dict): + value = value.get(key) + else: + value = None + break if isinstance(value, str): return resolve_env_vars(value) if value is None: @@ -291,6 +307,42 @@ class Config: return cls._app.redmine_author return "" + @classmethod + def get_period_precision(cls) -> str: + if cls._app: + return cls._app.period_precision or "date" + return "date" + + @classmethod + def get_last_used_from(cls) -> str: + if cls._app: + return cls._app.period_last_used_from + return "" + + @classmethod + def get_last_used_to(cls) -> str: + if cls._app: + return cls._app.period_last_used_to + return "" + + @classmethod + def get_output_dir(cls) -> str: + if cls._app: + return cls._app.output_dir + return "" + + @classmethod + def get_output_filename(cls) -> str: + if cls._app: + return cls._app.output_filename or "{author}_{from}_{to}.{ext}" + return "{author}_{from}_{to}.{ext}" + + @classmethod + def get_output_default_format(cls) -> str: + if cls._app: + return cls._app.output_default_format or "xlsx" + return "xlsx" + @classmethod def get_default_date_range(cls) -> str: from_env = os.getenv("DEFAULT_FROM_DATE", "").strip() diff --git a/redmine_reporter/yaml_config.py b/redmine_reporter/yaml_config.py index 153dfa0..c8ee774 100644 --- a/redmine_reporter/yaml_config.py +++ b/redmine_reporter/yaml_config.py @@ -99,3 +99,53 @@ def expand_filename_template( for key, value in replacements.items(): result = result.replace("{" + key + "}", value) return result + + +KNOWN_FORMAT_NAMES = {"xlsx", "odt", "csv", "md", "html", "json"} + + +def resolve_output_path( + output_arg: str | None, + *, + output_dir: str = "", + filename_template: str = "{author}_{from}_{to}.{ext}", + default_format: str = "xlsx", + author: str = "", + from_date: str = "", + to_date: str = "", +) -> str | None: + """Resolve the final output path from --output argument and config settings. + + Returns None if output_arg is None (meaning no file output, use console). + + Resolution rules: + - None → None (console output) + - Bare format name (xlsx, odt, csv, md, html, json) → use template path + - Path without extension → append .{default_format} + - Path with known extension → use as-is + - Path with unknown extension → use as-is + """ + if output_arg is None: + return None + + arg = output_arg.strip() + + import os + + if arg.lower() in KNOWN_FORMAT_NAMES: + ext = arg.lower() + resolved_dir = os.path.expanduser(output_dir) if output_dir else "." + filename = expand_filename_template( + filename_template, + author=author, + from_date=from_date, + to_date=to_date, + ext=ext, + ) + return os.path.join(resolved_dir, filename) + + _, ext = os.path.splitext(arg) + if not ext: + arg = f"{arg}.{default_format}" + + return arg diff --git a/tests/test_cli.py b/tests/test_cli.py index 37e4dc8..2b9770e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -97,13 +97,27 @@ def test_cli_unknown_output_extension(mock_fetch, tmp_path): @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") -def test_cli_output_without_extension(mock_fetch, tmp_path): - """Файл без расширения -- выход 1 с подсказкой.""" +@mock.patch("redmine_reporter.cli.get_formatter_by_extension") +def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path): + """Файл без расширения — приложение расширяет default_format (#43).""" issue = _MockIssue() mock_fetch.return_value = [(issue, 1.0)] + mock_formatter = mock.MagicMock() + mock_get.return_value = mock_formatter output = str(tmp_path / "report") - code = main(["--date", "2026-01-01--2026-01-31", "--output", output]) - assert code == 1 + code = main( + [ + "--date", + "2026-01-01--2026-01-31", + "--output", + output, + "--config-path", + str(tmp_path / "nonexistent.yml"), + ] + ) + assert code == 0 + saved_path = mock_formatter.save.call_args.args[1] + assert saved_path.endswith(".xlsx") @mock.patch.dict(os.environ, VALID_ENV, clear=True) @@ -407,3 +421,120 @@ def test_cli_autoloads_yaml_config(mock_fetch, tmp_path): mock_fetch.return_value = None code = main(["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)]) assert code == 0 + + +# --------------------------------------------------------------------------- +# #43: --output path resolution tests +# --------------------------------------------------------------------------- + + +class TestOutputPathResolution: + """Tests for --output flag with path defaults.""" + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_bare_format_name_xlsx_uses_template_path(self, mock_fetch, tmp_path): + """--output xlsx без пути → использует шаблон из конфига.""" + import yaml + + config_path = tmp_path / "config.yml" + config_path.write_text( + yaml.dump( + { + "output": { + "dir": str(tmp_path / "reports"), + "filename": "report_{date}.{ext}", + "default_format": "xlsx", + } + } + ) + ) + + mock_fetch.return_value = None + code = main( + [ + "--date", + "2026-01-01--2026-01-31", + "--output", + "xlsx", + "--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") + def test_output_without_extension_appends_default_format(self, mock_fetch, tmp_path): + """--output report без расширения → добавляет .xlsx.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + output = str(tmp_path / "report") + 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-01-01--2026-01-31", + "--output", + output, + "--config-path", + str(tmp_path / "nonexistent.yml"), + ] + ) + assert code == 0 + mock_formatter.save.assert_called_once() + saved_path = mock_formatter.save.call_args.args[1] + assert saved_path.endswith(".xlsx") + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_output_with_known_extension_unchanged(self, mock_fetch, tmp_path): + """--output report.csv с явным расширением передаётся как есть.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + 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-01-01--2026-01-31", + "--output", + str(tmp_path / "report.csv"), + "--config-path", + str(tmp_path / "nonexistent.yml"), + ] + ) + assert code == 0 + mock_formatter.save.assert_called_once() + saved_path = mock_formatter.save.call_args.args[1] + assert saved_path.endswith(".csv") + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_output_bare_format_invalid_is_treated_as_path(self, mock_fetch, tmp_path): + """--output notanxlsx (no path, not known format) → treated as path + .xlsx appended.""" + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0)] + + 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-01-01--2026-01-31", + "--output", + "myreport", + "--config-path", + str(tmp_path / "nonexistent.yml"), + ] + ) + assert code == 0 + mock_formatter.save.assert_called_once() + saved_path = mock_formatter.save.call_args.args[1] + assert saved_path == "myreport.xlsx" diff --git a/tests/test_client.py b/tests/test_client.py index 034b5dc..82a902c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -435,3 +435,239 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class): assert len(call_chunks[2].split(",")) == 50 assert result is not None assert len(result) == 250 + + +# -- #47: Дедупликация time entries по datetime -- + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class): + """Записи с created_on/updated_on раньше dedup_before исключаются.""" + from datetime import datetime, timezone + + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine, user_id=123) + + dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) + + e1 = mock.MagicMock() + e1.issue.id = 1 + e1.hours = 2.0 + e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc) + e1.updated_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc) + + e2 = mock.MagicMock() + e2.issue.id = 2 + e2.hours = 1.0 + e2.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc) + e2.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc) + + mock_redmine.time_entry.filter.return_value = [e1, e2] + + mock_issue1 = mock.MagicMock() + mock_issue1.id = 1 + mock_issue1.project = "P" + mock_issue2 = mock.MagicMock() + mock_issue2.id = 2 + mock_issue2.project = "P" + mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2] + + result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) + + assert result is not None + assert len(result) == 1 + assert result[0][0].id == 2 + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_dedup_filters_entries_with_old_created_even_if_updated_recently(mock_redmine_class): + """Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика).""" + from datetime import datetime, timezone + + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine, user_id=123) + + dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) + + e1 = mock.MagicMock() + e1.issue.id = 1 + e1.hours = 2.0 + e1.created_on = datetime(2026, 7, 1, 10, 0, 0, tzinfo=timezone.utc) + e1.updated_on = datetime(2026, 7, 1, 15, 0, 0, tzinfo=timezone.utc) + + mock_redmine.time_entry.filter.return_value = [e1] + mock_redmine.issue.filter.return_value = [] + + result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) + + assert result is None + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class): + """Записи с created_on/updated_on >= dedup_before сохраняются.""" + from datetime import datetime, timezone + + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine, user_id=123) + + dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) + + e1 = mock.MagicMock() + e1.issue.id = 1 + e1.hours = 2.0 + e1.created_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) + e1.updated_on = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) + + mock_redmine.time_entry.filter.return_value = [e1] + + mock_issue1 = mock.MagicMock() + mock_issue1.id = 1 + mock_issue1.project = "P" + mock_issue1.subject = "T" + mock_issue1.status = "New" + mock_redmine.issue.filter.return_value = [mock_issue1] + + result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) + + assert result is not None + assert len(result) == 1 + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_dedup_entries_without_created_on_are_kept(mock_redmine_class): + """Записи без created_on/updated_on не фильтруются (сохраняются).""" + from datetime import datetime, timezone + + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine, user_id=123) + + dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) + + e1 = mock.MagicMock() + e1.issue.id = 1 + e1.hours = 2.0 + e1.created_on = None + e1.updated_on = None + + mock_redmine.time_entry.filter.return_value = [e1] + + mock_issue1 = mock.MagicMock() + mock_issue1.id = 1 + mock_issue1.project = "P" + mock_issue1.subject = "T" + mock_issue1.status = "New" + mock_redmine.issue.filter.return_value = [mock_issue1] + + result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) + + assert result is not None + assert len(result) == 1 + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_dedup_handles_string_created_on(mock_redmine_class): + """created_on может быть строкой ISO — парсим корректно.""" + from datetime import datetime, timezone + + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine, user_id=123) + + dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) + + e1 = mock.MagicMock() + e1.issue.id = 1 + e1.hours = 2.0 + e1.created_on = "2026-07-01T10:00:00Z" + e1.updated_on = "2026-07-01T10:00:00Z" + + mock_redmine.time_entry.filter.return_value = [e1] + mock_redmine.issue.filter.return_value = [] + + result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) + + assert result is None + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_no_dedup_when_cutoff_is_none(mock_redmine_class): + """Без dedup_before фильтрация не применяется.""" + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine, user_id=123) + + e1 = mock.MagicMock() + e1.issue.id = 1 + e1.hours = 2.0 + e1.created_on = None + e1.updated_on = None + + mock_redmine.time_entry.filter.return_value = [e1] + + mock_issue1 = mock.MagicMock() + mock_issue1.id = 1 + mock_issue1.project = "P" + mock_issue1.subject = "T" + mock_issue1.status = "New" + mock_redmine.issue.filter.return_value = [mock_issue1] + + result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=None) + + assert result is not None + assert len(result) == 1 + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_dedup_mixed_entries_correct_filtering(mock_redmine_class): + """Смешанные записи: старые исключаются, новые сохраняются, аки идут.""" + from datetime import datetime, timezone + + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine, user_id=123) + + dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc) + + e_old = mock.MagicMock() + e_old.issue.id = 10 + e_old.hours = 1.0 + e_old.created_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc) + e_old.updated_on = datetime(2026, 7, 1, 9, 0, 0, tzinfo=timezone.utc) + + e_new = mock.MagicMock() + e_new.issue.id = 20 + e_new.hours = 3.0 + e_new.created_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc) + e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0, tzinfo=timezone.utc) + + e_akiy = mock.MagicMock() + e_akiy.issue.id = 30 + e_akiy.hours = 5.0 + e_akiy.created_on = None + e_akiy.updated_on = None + + mock_redmine.time_entry.filter.return_value = [e_old, e_new, e_akiy] + + mock_issue_new = mock.MagicMock() + mock_issue_new.id = 20 + mock_issue_new.project = "P" + mock_issue_new.subject = "T" + mock_issue_new.status = "New" + mock_issue_akiy = mock.MagicMock() + mock_issue_akiy.id = 30 + mock_issue_akiy.project = "P" + mock_issue_akiy.subject = "T2" + mock_issue_akiy.status = "New" + mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy] + + result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff) + + assert result is not None + assert len(result) == 2 + ids = {r[0].id for r in result} + assert ids == {20, 30} diff --git a/tests/test_config.py b/tests/test_config.py index 3223905..11f948c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -154,6 +154,9 @@ period: default_from: "2026-06-01" default_to: "2026-06-30" dynamic: true + last_used: + from: "2026-06-30T09:00:00" + to: "2026-06-30T12:00:00" output: dir: ~/reports @@ -196,6 +199,8 @@ class TestAppConfigFromYaml: assert cfg.period_default_from == "2026-06-01" assert cfg.period_default_to == "2026-06-30" assert cfg.period_dynamic is True + assert cfg.period_last_used_from == "2026-06-30T09:00:00" + assert cfg.period_last_used_to == "2026-06-30T12:00:00" assert cfg.output_dir == "~/reports" assert cfg.output_filename == "{author}_{from}_{to}.{ext}" assert cfg.output_default_format == "xlsx" @@ -387,3 +392,62 @@ class TestConfigYamlFallback: clear=True, ): assert Config.get_default_date_range() == "2026-07-01--2026-07-15" + + +class TestConfigPeriodPrecision: + """Tests for period.precision and period.last_used.""" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_get_period_precision_defaults_to_date(self): + """Без YAML-конфига precision == 'date'.""" + Config._app = None + assert Config.get_period_precision() == "date" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_get_period_precision_from_yaml(self): + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("period:\n precision: datetime\n") + + Config._app = AppConfig.from_yaml(yaml_path) + assert Config.get_period_precision() == "datetime" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_get_last_used_from_yaml(self): + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "period:\n last_used:\n from: '2026-06-30T09:00:00'\n to: '2026-06-30T12:00:00'\n" + ) + + Config._app = AppConfig.from_yaml(yaml_path) + assert Config.get_last_used_from() == "2026-06-30T09:00:00" + assert Config.get_last_used_to() == "2026-06-30T12:00:00" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_get_last_used_returns_empty_when_not_set(self): + Config._app = AppConfig() + assert Config.get_last_used_from() == "" + assert Config.get_last_used_to() == "" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_yaml_loads_last_used_field(self): + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text( + "period:\n last_used:\n from: '2026-07-01T08:00:00'\n to: '2026-07-01T18:00:00'\n" + ) + + cfg = AppConfig.from_yaml(yaml_path) + assert cfg.period_last_used_from == "2026-07-01T08:00:00" + assert cfg.period_last_used_to == "2026-07-01T18:00:00" + + @mock.patch.dict(os.environ, {}, clear=True) + def test_yaml_without_last_used_has_empty_fields(self): + with tempfile.TemporaryDirectory() as tmp: + yaml_path = Path(tmp) / "config.yml" + yaml_path.write_text("period:\n precision: date\n") + + cfg = AppConfig.from_yaml(yaml_path) + assert cfg.period_last_used_from == "" + assert cfg.period_last_used_to == "" diff --git a/tests/test_yaml_config.py b/tests/test_yaml_config.py index c90b50c..557b001 100644 --- a/tests/test_yaml_config.py +++ b/tests/test_yaml_config.py @@ -122,3 +122,87 @@ class TestExpandFilenameTemplate: ext="xlsx", ) assert result == "Кокос_А.А._{unknown}.xlsx" + + +class TestResolveOutputPath: + """Tests for resolve_output_path().""" + + def test_none_returns_none(self): + from redmine_reporter.yaml_config import resolve_output_path + + assert resolve_output_path(None) is None + + def test_explicit_path_with_known_extension_is_unchanged(self): + from redmine_reporter.yaml_config import resolve_output_path + + result = resolve_output_path("/tmp/report.xlsx") + assert result == "/tmp/report.xlsx" + + def test_explicit_path_with_unknown_extension_is_unchanged(self): + from redmine_reporter.yaml_config import resolve_output_path + + result = resolve_output_path("report.odt") + assert result == "report.odt" + + def test_no_extension_appends_default_format(self): + from redmine_reporter.yaml_config import resolve_output_path + + result = resolve_output_path("report", default_format="xlsx") + assert result == "report.xlsx" + + def test_no_extension_appends_custom_default_format(self): + from redmine_reporter.yaml_config import resolve_output_path + + result = resolve_output_path("file", default_format="csv") + assert result == "file.csv" + + def test_bare_format_name_resolves_to_template_path(self): + from redmine_reporter.yaml_config import resolve_output_path + + result = resolve_output_path( + "xlsx", + output_dir="/tmp/reports", + filename_template="{date}.{ext}", + author="Кокос А.А.", + from_date="2026-06-01", + to_date="2026-06-30", + default_format="xlsx", + ) + assert result == "/tmp/reports/30_06_2026.xlsx" + + def test_bare_format_name_odt_resolves_to_template_path(self): + from redmine_reporter.yaml_config import resolve_output_path + + result = resolve_output_path( + "odt", + output_dir="~/reports", + filename_template="{author}_{from}_{to}.{ext}", + author="Кокос А.А.", + from_date="2026-06-01", + to_date="2026-06-30", + default_format="xlsx", + ) + assert result.startswith("/") and result.endswith( + "/reports/Кокос_А.А._2026-06-01_2026-06-30.odt" + ) + assert "~" not in result + + def test_bare_format_name_with_empty_output_dir_uses_cwd(self): + from redmine_reporter.yaml_config import resolve_output_path + + result = resolve_output_path( + "csv", + output_dir="", + filename_template="report.{ext}", + author="A", + from_date="2026-01-01", + to_date="2026-01-31", + ) + assert not result.startswith("/") + assert "report.csv" in result + + def test_unknown_bare_format_is_treated_as_path(self): + from redmine_reporter.yaml_config import resolve_output_path + + result = resolve_output_path("unknown_format") + assert result == "unknown_format.xlsx"