#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:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user