feat: output path defaults (#43) + datetime precision & dedup (#47)

#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
This commit is contained in:
Кокос Артем Николаевич
2026-07-07 10:16:46 +07:00
parent 67350bfcd6
commit 47152f8f04
9 changed files with 718 additions and 15 deletions

View File

@@ -58,5 +58,5 @@ multi_line_output = 3
warn_unused_configs = true warn_unused_configs = true
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*"] module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*", "yaml", "requests", "urllib3.*"]
ignore_missing_imports = true ignore_missing_imports = true

View File

@@ -3,7 +3,7 @@ import logging
import os import os
import re import re
import sys import sys
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
@@ -14,7 +14,7 @@ from .client import RedmineAPIError, fetch_issues_with_spent_time
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 .report_builder import build_grouped_report, calculate_summary 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]: 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 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: def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="redmine-reporter", prog="redmine-reporter",
@@ -243,6 +266,7 @@ def main(argv: Optional[List[str]] = None) -> int:
to_date, to_date,
user_id=args.user_id or args.user_login or args.user_name, user_id=args.user_id or args.user_login or args.user_name,
by_activity=args.by_activity, by_activity=args.by_activity,
dedup_before=_compute_dedup_cutoff(),
) )
except RedmineAPIError as e: except RedmineAPIError as e:
print(f"{e.message}", file=sys.stderr) 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) print(f" [{activity}]: {value}h", file=sys.stderr)
if args.output: 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: if not output_ext:
print( print(
@@ -317,8 +358,8 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1 return 1
try: try:
formatter.save(rows, args.output) formatter.save(rows, output_arg)
print(f"✅ Report saved to {args.output}") print(f"✅ Report saved to {output_arg}")
except Exception as e: except Exception as e:
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)

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
import requests import requests
@@ -144,6 +145,25 @@ def _get_activity_name(entry, activities: Dict[int, str]) -> str:
return str(activity) 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]: def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = [] all_issues: List[Issue] = []
@@ -220,12 +240,15 @@ def fetch_issues_with_spent_time(
to_date: str, to_date: str,
user_id: Optional[Union[int, str]] = None, user_id: Optional[Union[int, str]] = None,
by_activity: bool = False, by_activity: bool = False,
dedup_before: Optional[datetime] = None,
) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]: ) -> Optional[List[Tuple[Issue, float, Optional[Dict[str, float]]]]]:
""" """
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.
If user_id is None, uses current user. If user_id is None, uses current user.
If by_activity is True, returns per-activity breakdown as third tuple element. 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. Returns list of (issue, total_hours, activities) tuples.
Raises RedmineAPIError on API/auth/network failures. Raises RedmineAPIError on API/auth/network failures.
""" """
@@ -238,14 +261,34 @@ def fetch_issues_with_spent_time(
else _get_current_user_id(redmine) else _get_current_user_id(redmine)
) )
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {} activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
time_entries = redmine.time_entry.filter( time_entries = list(
user_id=target_user_id, from_date=from_date, to_date=to_date redmine.time_entry.filter(user_id=target_user_id, from_date=from_date, to_date=to_date)
) )
except RedmineAPIError: except RedmineAPIError:
raise raise
except Exception as exc: except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from 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 (и активности, если требуется) # Агрегируем часы по 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]] = {}
@@ -278,7 +321,9 @@ def fetch_issues_with_spent_time(
result = [] result = []
for issue in issues: for issue in issues:
iid = issue.id 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 activity_breakdown = spent_by_activity.get(iid) if by_activity else None
result.append((issue, total_hours, activity_breakdown)) result.append((issue, total_hours, activity_breakdown))

View File

@@ -40,6 +40,12 @@ class EmailConfig:
attach: bool = True attach: bool = True
@dataclass
class PeriodLastUsed:
from_: str = ""
to: str = ""
@dataclass @dataclass
class AppConfig: class AppConfig:
redmine_url: str = "" redmine_url: str = ""
@@ -50,6 +56,8 @@ class AppConfig:
period_default_from: str = "" period_default_from: str = ""
period_default_to: str = "" period_default_to: str = ""
period_dynamic: bool = False period_dynamic: bool = False
period_last_used_from: str = ""
period_last_used_to: 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"
@@ -93,6 +101,8 @@ class AppConfig:
period_default_from=cls._resolve_str(raw, "period", "default_from"), period_default_from=cls._resolve_str(raw, "period", "default_from"),
period_default_to=cls._resolve_str(raw, "period", "default_to"), period_default_to=cls._resolve_str(raw, "period", "default_to"),
period_dynamic=cls._resolve_bool(raw, "period", "dynamic"), 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_dir=cls._resolve_str(raw, "output", "dir"),
output_filename=cls._resolve_str(raw, "output", "filename") output_filename=cls._resolve_str(raw, "output", "filename")
or "{author}_{from}_{to}.{ext}", or "{author}_{from}_{to}.{ext}",
@@ -112,8 +122,14 @@ class AppConfig:
) )
@staticmethod @staticmethod
def _resolve_str(raw: dict, section: str, key: str) -> str: def _resolve_str(raw: dict, section: str, *keys: str) -> str:
value = raw.get(section, {}).get(key, "") value = raw.get(section)
for key in keys:
if isinstance(value, dict):
value = value.get(key)
else:
value = None
break
if isinstance(value, str): if isinstance(value, str):
return resolve_env_vars(value) return resolve_env_vars(value)
if value is None: if value is None:
@@ -291,6 +307,42 @@ class Config:
return cls._app.redmine_author return cls._app.redmine_author
return "" 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 @classmethod
def get_default_date_range(cls) -> str: def get_default_date_range(cls) -> str:
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip() from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()

View File

@@ -99,3 +99,53 @@ def expand_filename_template(
for key, value in replacements.items(): for key, value in replacements.items():
result = result.replace("{" + key + "}", value) result = result.replace("{" + key + "}", value)
return result 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

View File

@@ -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.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_output_without_extension(mock_fetch, tmp_path): @mock.patch("redmine_reporter.cli.get_formatter_by_extension")
"""Файл без расширения -- выход 1 с подсказкой.""" def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
"""Файл без расширения — приложение расширяет default_format (#43)."""
issue = _MockIssue() issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)] mock_fetch.return_value = [(issue, 1.0)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
output = str(tmp_path / "report") output = str(tmp_path / "report")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output]) code = main(
assert code == 1 [
"--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) @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 mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)]) code = main(["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)])
assert code == 0 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"

View File

@@ -435,3 +435,239 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class):
assert len(call_chunks[2].split(",")) == 50 assert len(call_chunks[2].split(",")) == 50
assert result is not None assert result is not None
assert len(result) == 250 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}

View File

@@ -154,6 +154,9 @@ period:
default_from: "2026-06-01" default_from: "2026-06-01"
default_to: "2026-06-30" default_to: "2026-06-30"
dynamic: true dynamic: true
last_used:
from: "2026-06-30T09:00:00"
to: "2026-06-30T12:00:00"
output: output:
dir: ~/reports dir: ~/reports
@@ -196,6 +199,8 @@ class TestAppConfigFromYaml:
assert cfg.period_default_from == "2026-06-01" assert cfg.period_default_from == "2026-06-01"
assert cfg.period_default_to == "2026-06-30" assert cfg.period_default_to == "2026-06-30"
assert cfg.period_dynamic is True 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_dir == "~/reports"
assert cfg.output_filename == "{author}_{from}_{to}.{ext}" assert cfg.output_filename == "{author}_{from}_{to}.{ext}"
assert cfg.output_default_format == "xlsx" assert cfg.output_default_format == "xlsx"
@@ -387,3 +392,62 @@ class TestConfigYamlFallback:
clear=True, clear=True,
): ):
assert Config.get_default_date_range() == "2026-07-01--2026-07-15" 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 == ""

View File

@@ -122,3 +122,87 @@ class TestExpandFilenameTemplate:
ext="xlsx", ext="xlsx",
) )
assert result == "Кокос_А.А._{unknown}.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"