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
[[tool.mypy.overrides]]
module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*"]
module = ["odf.*", "redminelib.*", "tabulate", "openpyxl.*", "yaml", "requests", "urllib3.*"]
ignore_missing_imports = true

View File

@@ -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)

View File

@@ -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))

View File

@@ -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()

View File

@@ -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

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("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"

View File

@@ -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}

View File

@@ -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 == ""

View File

@@ -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"