style: apply ruff format to all source files
This commit is contained in:
@@ -25,7 +25,9 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
from_date, to_date = parts[0].strip(), parts[1].strip()
|
from_date, to_date = parts[0].strip(), parts[1].strip()
|
||||||
date_pattern = r"\d{4}-\d{2}-\d{2}"
|
date_pattern = r"\d{4}-\d{2}-\d{2}"
|
||||||
if not re.fullmatch(date_pattern, from_date) or not re.fullmatch(date_pattern, to_date):
|
if not re.fullmatch(date_pattern, from_date) or not re.fullmatch(
|
||||||
|
date_pattern, to_date
|
||||||
|
):
|
||||||
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
|
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -46,7 +48,7 @@ def _run_init_config(config_path: str, force: bool) -> int:
|
|||||||
|
|
||||||
if path.exists() and not force:
|
if path.exists() and not force:
|
||||||
print(
|
print(
|
||||||
f"⚠️ {path} already exists.\n" f" Use --init-config --force to overwrite.",
|
f"⚠️ {path} already exists.\n Use --init-config --force to overwrite.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
@@ -90,7 +92,9 @@ def _run_init_config(config_path: str, force: bool) -> int:
|
|||||||
|
|
||||||
ensure_config_dir(path.parent)
|
ensure_config_dir(path.parent)
|
||||||
with open(path, "w", encoding="utf-8") as fh:
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
yaml.dump(data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
yaml.dump(
|
||||||
|
data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
|
||||||
|
)
|
||||||
path.chmod(0o600)
|
path.chmod(0o600)
|
||||||
|
|
||||||
sections_found = [s for s in data if data[s]]
|
sections_found = [s for s in data if data[s]]
|
||||||
@@ -222,7 +226,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
"--no-time", action="store_true", help="Do not include spent time into table"
|
"--no-time", action="store_true", help="Do not include spent time into table"
|
||||||
)
|
)
|
||||||
parser.add_argument("--url", help="Override Redmine URL from .env (REDMINE_URL)")
|
parser.add_argument("--url", help="Override Redmine URL from .env (REDMINE_URL)")
|
||||||
parser.add_argument("--api-key", help="Override Redmine API key from .env (REDMINE_API_KEY)")
|
parser.add_argument(
|
||||||
|
"--api-key", help="Override Redmine API key from .env (REDMINE_API_KEY)"
|
||||||
|
)
|
||||||
parser.add_argument("--config", help="Path to .env config file")
|
parser.add_argument("--config", help="Path to .env config file")
|
||||||
parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
|
parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
|
||||||
parser.add_argument("--debug", action="store_true", help="Enable debug output")
|
parser.add_argument("--debug", action="store_true", help="Enable debug output")
|
||||||
@@ -417,7 +423,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if output_arg is None:
|
if output_arg is None:
|
||||||
print("❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
|
print(
|
||||||
|
"❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr
|
||||||
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
ret = _save_and_maybe_send(
|
ret = _save_and_maybe_send(
|
||||||
@@ -446,7 +454,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if output_arg is None:
|
if output_arg is None:
|
||||||
print("❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr)
|
print(
|
||||||
|
"❌ Не удалось определить путь для сохранения отчёта.", file=sys.stderr
|
||||||
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
ret = _save_and_maybe_send(
|
ret = _save_and_maybe_send(
|
||||||
@@ -493,7 +503,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
to_str = to_date
|
to_str = to_date
|
||||||
|
|
||||||
try:
|
try:
|
||||||
save_period_to_config(args.config_path, from_str, to_str, precision, dynamic)
|
save_period_to_config(
|
||||||
|
args.config_path, from_str, to_str, precision, dynamic
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(
|
print(
|
||||||
f"❌ Не удалось сохранить период в конфиг: {e}",
|
f"❌ Не удалось сохранить период в конфиг: {e}",
|
||||||
|
|||||||
@@ -170,7 +170,9 @@ def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]
|
|||||||
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
|
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
|
||||||
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
|
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
|
||||||
issue_list_str = ",".join(str(x) for x in chunk)
|
issue_list_str = ",".join(str(x) for x in chunk)
|
||||||
issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc")
|
issues = redmine.issue.filter(
|
||||||
|
issue_id=issue_list_str, status_id="*", sort="project:asc"
|
||||||
|
)
|
||||||
all_issues.extend(issues)
|
all_issues.extend(issues)
|
||||||
return all_issues
|
return all_issues
|
||||||
|
|
||||||
@@ -262,7 +264,9 @@ def fetch_issues_with_spent_time(
|
|||||||
)
|
)
|
||||||
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
|
activities_lookup = _load_time_entry_activities(redmine) if by_activity else {}
|
||||||
time_entries = list(
|
time_entries = list(
|
||||||
redmine.time_entry.filter(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
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ class AppConfig:
|
|||||||
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}",
|
||||||
output_default_format=cls._resolve_str(raw, "output", "default_format") or "xlsx",
|
output_default_format=cls._resolve_str(raw, "output", "default_format")
|
||||||
|
or "xlsx",
|
||||||
email=cls._resolve_email(raw),
|
email=cls._resolve_email(raw),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -397,7 +398,9 @@ class Config:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def compute_next_period(last_from: str, last_to: str, precision: str) -> tuple[str, str]:
|
def compute_next_period(
|
||||||
|
last_from: str, last_to: str, precision: str
|
||||||
|
) -> tuple[str, str]:
|
||||||
"""Compute the next report period based on the last committed period.
|
"""Compute the next report period based on the last committed period.
|
||||||
|
|
||||||
- For a full calendar month → next full calendar month.
|
- For a full calendar month → next full calendar month.
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ from .base import Formatter
|
|||||||
class ODTFormatter(Formatter):
|
class ODTFormatter(Formatter):
|
||||||
"""Форматтер для экспорта в ODT."""
|
"""Форматтер для экспорта в ODT."""
|
||||||
|
|
||||||
def __init__(self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs):
|
def __init__(
|
||||||
|
self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Инициализирует форматтер с параметрами для шапки отчета.
|
Инициализирует форматтер с параметрами для шапки отчета.
|
||||||
"""
|
"""
|
||||||
@@ -28,7 +30,11 @@ class ODTFormatter(Formatter):
|
|||||||
Форматирует данные в объект OpenDocument.
|
Форматирует данные в объект OpenDocument.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with resources.files("redmine_reporter").joinpath("templates/template.odt").open("rb") as f:
|
with (
|
||||||
|
resources.files("redmine_reporter")
|
||||||
|
.joinpath("templates/template.odt")
|
||||||
|
.open("rb") as f
|
||||||
|
):
|
||||||
doc = load(f)
|
doc = load(f)
|
||||||
|
|
||||||
# Удаляем все текстовые параграфы из шаблона, оставляя только
|
# Удаляем все текстовые параграфы из шаблона, оставляя только
|
||||||
@@ -52,7 +58,9 @@ class ODTFormatter(Formatter):
|
|||||||
# Стиль ячеек
|
# Стиль ячеек
|
||||||
cell_style_name = "TableCellStyle"
|
cell_style_name = "TableCellStyle"
|
||||||
cell_style = Style(name=cell_style_name, family="table-cell")
|
cell_style = Style(name=cell_style_name, family="table-cell")
|
||||||
cell_props = TableCellProperties(padding="0.04in", border="0.05pt solid #000000")
|
cell_props = TableCellProperties(
|
||||||
|
padding="0.04in", border="0.05pt solid #000000"
|
||||||
|
)
|
||||||
cell_style.addElement(cell_props)
|
cell_style.addElement(cell_props)
|
||||||
doc.automaticstyles.addElement(cell_style)
|
doc.automaticstyles.addElement(cell_style)
|
||||||
|
|
||||||
@@ -100,7 +108,9 @@ class ODTFormatter(Formatter):
|
|||||||
# в остальных — covered-cell для валидности ODF (#13)
|
# в остальных — covered-cell для валидности ODF (#13)
|
||||||
if first_version_in_project and first_row_in_version:
|
if first_version_in_project and first_row_in_version:
|
||||||
cell_project = TableCell(stylename=cell_style_name)
|
cell_project = TableCell(stylename=cell_style_name)
|
||||||
cell_project.setAttribute("numberrowsspanned", str(total_project_rows))
|
cell_project.setAttribute(
|
||||||
|
"numberrowsspanned", str(total_project_rows)
|
||||||
|
)
|
||||||
p = P(stylename=para_style_name, text=project)
|
p = P(stylename=para_style_name, text=project)
|
||||||
cell_project.addElement(p)
|
cell_project.addElement(p)
|
||||||
row.addElement(cell_project)
|
row.addElement(cell_project)
|
||||||
@@ -111,7 +121,9 @@ class ODTFormatter(Formatter):
|
|||||||
# в остальных — covered-cell для валидности ODF (#13)
|
# в остальных — covered-cell для валидности ODF (#13)
|
||||||
if first_row_in_version:
|
if first_row_in_version:
|
||||||
cell_version = TableCell(stylename=cell_style_name)
|
cell_version = TableCell(stylename=cell_style_name)
|
||||||
cell_version.setAttribute("numberrowsspanned", str(row_span_version))
|
cell_version.setAttribute(
|
||||||
|
"numberrowsspanned", str(row_span_version)
|
||||||
|
)
|
||||||
p = P(stylename=para_style_name, text=version)
|
p = P(stylename=para_style_name, text=version)
|
||||||
cell_version.addElement(p)
|
cell_version.addElement(p)
|
||||||
row.addElement(cell_version)
|
row.addElement(cell_version)
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ class XLSXFormatter(Formatter):
|
|||||||
и числовой столбец с часами для удобного суммирования.
|
и числовой столбец с часами для удобного суммирования.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_HEADER_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
|
_HEADER_FILL = PatternFill(
|
||||||
_TOTAL_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
start_color="D9E1F2", end_color="D9E1F2", fill_type="solid"
|
||||||
|
)
|
||||||
|
_TOTAL_FILL = PatternFill(
|
||||||
|
start_color="FFF2CC", end_color="FFF2CC", fill_type="solid"
|
||||||
|
)
|
||||||
_BORDER = Border(
|
_BORDER = Border(
|
||||||
left=Side(style="thin"),
|
left=Side(style="thin"),
|
||||||
right=Side(style="thin"),
|
right=Side(style="thin"),
|
||||||
@@ -40,7 +44,15 @@ class XLSXFormatter(Formatter):
|
|||||||
else:
|
else:
|
||||||
ws.title = "Report"
|
ws.title = "Report"
|
||||||
|
|
||||||
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Hours", "Spent Time"]
|
headers = [
|
||||||
|
"Project",
|
||||||
|
"Version",
|
||||||
|
"Issue ID",
|
||||||
|
"Subject",
|
||||||
|
"Status",
|
||||||
|
"Hours",
|
||||||
|
"Spent Time",
|
||||||
|
]
|
||||||
ws.append(headers)
|
ws.append(headers)
|
||||||
self._style_header_row(ws, headers)
|
self._style_header_row(ws, headers)
|
||||||
|
|
||||||
@@ -89,7 +101,10 @@ class XLSXFormatter(Formatter):
|
|||||||
)
|
)
|
||||||
self._style_total_row(ws, current_row, bold=False)
|
self._style_total_row(ws, current_row, bold=False)
|
||||||
ws.merge_cells(
|
ws.merge_cells(
|
||||||
start_row=current_row, start_column=2, end_row=current_row, end_column=5
|
start_row=current_row,
|
||||||
|
start_column=2,
|
||||||
|
end_row=current_row,
|
||||||
|
end_column=5,
|
||||||
)
|
)
|
||||||
current_row += 1
|
current_row += 1
|
||||||
|
|
||||||
@@ -99,7 +114,8 @@ class XLSXFormatter(Formatter):
|
|||||||
|
|
||||||
if not self.no_time:
|
if not self.no_time:
|
||||||
project_hours = sum(
|
project_hours = sum(
|
||||||
sum(r.get("hours", 0.0) for r in task_rows) for task_rows in versions.values()
|
sum(r.get("hours", 0.0) for r in task_rows)
|
||||||
|
for task_rows in versions.values()
|
||||||
)
|
)
|
||||||
ws.append(
|
ws.append(
|
||||||
[
|
[
|
||||||
@@ -114,7 +130,10 @@ class XLSXFormatter(Formatter):
|
|||||||
)
|
)
|
||||||
self._style_total_row(ws, current_row, bold=True)
|
self._style_total_row(ws, current_row, bold=True)
|
||||||
ws.merge_cells(
|
ws.merge_cells(
|
||||||
start_row=current_row, start_column=1, end_row=current_row, end_column=5
|
start_row=current_row,
|
||||||
|
start_column=1,
|
||||||
|
end_row=current_row,
|
||||||
|
end_column=5,
|
||||||
)
|
)
|
||||||
current_row += 1
|
current_row += 1
|
||||||
project_totals[project] = project_hours
|
project_totals[project] = project_hours
|
||||||
@@ -137,7 +156,9 @@ class XLSXFormatter(Formatter):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
self._style_total_row(ws, current_row, bold=True)
|
self._style_total_row(ws, current_row, bold=True)
|
||||||
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=5)
|
ws.merge_cells(
|
||||||
|
start_row=current_row, start_column=1, end_row=current_row, end_column=5
|
||||||
|
)
|
||||||
|
|
||||||
for start, end in project_ranges:
|
for start, end in project_ranges:
|
||||||
ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1)
|
ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1)
|
||||||
@@ -167,7 +188,9 @@ class XLSXFormatter(Formatter):
|
|||||||
cell.font = Font(bold=True)
|
cell.font = Font(bold=True)
|
||||||
cell.fill = self._HEADER_FILL
|
cell.fill = self._HEADER_FILL
|
||||||
cell.border = self._BORDER
|
cell.border = self._BORDER
|
||||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
cell.alignment = Alignment(
|
||||||
|
horizontal="center", vertical="center", wrap_text=True
|
||||||
|
)
|
||||||
|
|
||||||
def _style_data_row(self, ws: Worksheet, row: int) -> None:
|
def _style_data_row(self, ws: Worksheet, row: int) -> None:
|
||||||
for col_idx in range(1, 8):
|
for col_idx in range(1, 8):
|
||||||
@@ -190,7 +213,15 @@ class XLSXFormatter(Formatter):
|
|||||||
|
|
||||||
def _apply_column_widths(self, ws: Worksheet) -> None:
|
def _apply_column_widths(self, ws: Worksheet) -> None:
|
||||||
# Минимальные ширины по умолчанию
|
# Минимальные ширины по умолчанию
|
||||||
widths: Dict[int, float] = {1: 18.0, 2: 16.0, 3: 12.0, 4: 45.0, 5: 14.0, 6: 10.0, 7: 14.0}
|
widths: Dict[int, float] = {
|
||||||
|
1: 18.0,
|
||||||
|
2: 16.0,
|
||||||
|
3: 12.0,
|
||||||
|
4: 45.0,
|
||||||
|
5: 14.0,
|
||||||
|
6: 10.0,
|
||||||
|
7: 14.0,
|
||||||
|
}
|
||||||
|
|
||||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
||||||
for col_idx, cell in enumerate(row, start=1):
|
for col_idx, cell in enumerate(row, start=1):
|
||||||
|
|||||||
@@ -40,8 +40,12 @@ def build_message(
|
|||||||
period: str,
|
period: str,
|
||||||
) -> MIMEMultipart:
|
) -> MIMEMultipart:
|
||||||
"""Формирует MIME-письмо с подстановками и вложением."""
|
"""Формирует MIME-письмо с подстановками и вложением."""
|
||||||
subject = email_config.subject.replace("{author}", author).replace("{period}", period)
|
subject = email_config.subject.replace("{author}", author).replace(
|
||||||
body = email_config.body_text.replace("{author}", author).replace("{period}", period)
|
"{period}", period
|
||||||
|
)
|
||||||
|
body = email_config.body_text.replace("{author}", author).replace(
|
||||||
|
"{period}", period
|
||||||
|
)
|
||||||
|
|
||||||
msg = MIMEMultipart()
|
msg = MIMEMultipart()
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
@@ -91,7 +95,9 @@ def send_report(
|
|||||||
"""
|
"""
|
||||||
smtp_cfg = email_config.smtp
|
smtp_cfg = email_config.smtp
|
||||||
msg = build_message(email_config, file_path, author, period)
|
msg = build_message(email_config, file_path, author, period)
|
||||||
all_recipients = list(email_config.to) + list(email_config.cc) + list(email_config.bcc)
|
all_recipients = (
|
||||||
|
list(email_config.to) + list(email_config.cc) + list(email_config.bcc)
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=SMTP_TIMEOUT) as server:
|
with smtplib.SMTP(smtp_cfg.host, smtp_cfg.port, timeout=SMTP_TIMEOUT) as server:
|
||||||
@@ -100,21 +106,18 @@ def send_report(
|
|||||||
if smtp_cfg.user:
|
if smtp_cfg.user:
|
||||||
server.login(smtp_cfg.user, smtp_cfg.password)
|
server.login(smtp_cfg.user, smtp_cfg.password)
|
||||||
|
|
||||||
server.send_message(msg, from_addr=email_config.from_, to_addrs=all_recipients)
|
server.send_message(
|
||||||
|
msg, from_addr=email_config.from_, to_addrs=all_recipients
|
||||||
|
)
|
||||||
except smtplib.SMTPAuthenticationError:
|
except smtplib.SMTPAuthenticationError:
|
||||||
raise RedmineAPIError(
|
raise RedmineAPIError(
|
||||||
"Ошибка аутентификации SMTP. Проверьте логин и пароль."
|
"Ошибка аутентификации SMTP. Проверьте логин и пароль."
|
||||||
) from None
|
) from None
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
raise RedmineAPIError(
|
raise RedmineAPIError("Таймаут соединения с SMTP-сервером.") from None
|
||||||
"Таймаут соединения с SMTP-сервером."
|
|
||||||
) from None
|
|
||||||
except smtplib.SMTPException as exc:
|
except smtplib.SMTPException as exc:
|
||||||
raise RedmineAPIError(
|
raise RedmineAPIError(f"Ошибка отправки письма: {exc}") from exc
|
||||||
f"Ошибка отправки письма: {exc}"
|
|
||||||
) from exc
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise RedmineAPIError(
|
raise RedmineAPIError(
|
||||||
f"Не удалось подключиться к SMTP-серверу {smtp_cfg.host}:{smtp_cfg.port}"
|
f"Не удалось подключиться к SMTP-серверу {smtp_cfg.host}:{smtp_cfg.port}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ def build_grouped_report(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
|
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
|
||||||
issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id))
|
issue_hours = sorted(
|
||||||
|
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)
|
||||||
|
)
|
||||||
|
|
||||||
rows: List[ReportRow] = []
|
rows: List[ReportRow] = []
|
||||||
prev_project: str = ""
|
prev_project: str = ""
|
||||||
@@ -67,7 +69,9 @@ def build_grouped_report(
|
|||||||
time_text = ""
|
time_text = ""
|
||||||
|
|
||||||
display_project = project if project != prev_project else ""
|
display_project = project if project != prev_project else ""
|
||||||
display_version = version if (project != prev_project or version != prev_version) else ""
|
display_version = (
|
||||||
|
version if (project != prev_project or version != prev_version) else ""
|
||||||
|
)
|
||||||
|
|
||||||
rows.append(
|
rows.append(
|
||||||
cast(
|
cast(
|
||||||
@@ -122,7 +126,9 @@ def calculate_summary(
|
|||||||
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
|
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
|
||||||
}
|
}
|
||||||
if by_activity:
|
if by_activity:
|
||||||
result.update({f"activity:{k}": round(v, 2) for k, v in by_activity_name.items()})
|
result.update(
|
||||||
|
{f"activity:{k}": round(v, 2) for k, v in by_activity_name.items()}
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ def resolve_env_vars(value: str) -> str:
|
|||||||
var_name = match.group(1)
|
var_name = match.group(1)
|
||||||
env_value = os.environ.get(var_name)
|
env_value = os.environ.get(var_name)
|
||||||
if env_value is None:
|
if env_value is None:
|
||||||
logger.warning("Environment variable %s is not set, using empty string", var_name)
|
logger.warning(
|
||||||
|
"Environment variable %s is not set, using empty string", var_name
|
||||||
|
)
|
||||||
return ""
|
return ""
|
||||||
return env_value
|
return env_value
|
||||||
|
|
||||||
@@ -182,5 +184,7 @@ def save_period_to_config(
|
|||||||
|
|
||||||
ensure_config_dir(path.parent)
|
ensure_config_dir(path.parent)
|
||||||
with open(path, "w", encoding="utf-8") as fh:
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
yaml.dump(raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
yaml.dump(
|
||||||
|
raw, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
|
||||||
|
)
|
||||||
path.chmod(0o600)
|
path.chmod(0o600)
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ def test_cli_returns_zero_on_no_entries(mock_fetch):
|
|||||||
@mock.patch.dict(os.environ, {}, clear=True)
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
def test_cli_config_error():
|
def test_cli_config_error():
|
||||||
"""Невалидный конфиг -- выход 1."""
|
"""Невалидный конфиг -- выход 1."""
|
||||||
code = main(["--date", "2026-01-01--2026-01-31", "--config-path", "/nonexistent/config.yml"])
|
code = main(
|
||||||
|
["--date", "2026-01-01--2026-01-31", "--config-path", "/nonexistent/config.yml"]
|
||||||
|
)
|
||||||
assert code == 1
|
assert code == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -183,7 +185,7 @@ def test_cli_config_file_loading(mock_fetch, tmp_path):
|
|||||||
"""--config загружает переменные из указанного .env-файла."""
|
"""--config загружает переменные из указанного .env-файла."""
|
||||||
config_path = tmp_path / "custom.env"
|
config_path = tmp_path / "custom.env"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
"REDMINE_URL=https://config.redmine.loc\n" "REDMINE_API_KEY=config-token\n",
|
"REDMINE_URL=https://config.redmine.loc\nREDMINE_API_KEY=config-token\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
mock_fetch.return_value = None
|
mock_fetch.return_value = None
|
||||||
@@ -248,7 +250,9 @@ def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
|
|||||||
"""CLI выводит понятное сообщение при таймауте."""
|
"""CLI выводит понятное сообщение при таймауте."""
|
||||||
from redmine_reporter.client import RedmineAPIError
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
mock_fetch.side_effect = RedmineAPIError("Redmine request timed out after 30 seconds")
|
mock_fetch.side_effect = RedmineAPIError(
|
||||||
|
"Redmine request timed out after 30 seconds"
|
||||||
|
)
|
||||||
code = main(["--date", "2026-01-01--2026-01-31"])
|
code = main(["--date", "2026-01-01--2026-01-31"])
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert code == 1
|
assert code == 1
|
||||||
@@ -263,7 +267,9 @@ def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
|
|||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
output = str(tmp_path / "report.xlsx")
|
output = str(tmp_path / "report.xlsx")
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get_formatter:
|
with mock.patch(
|
||||||
|
"redmine_reporter.cli.get_formatter_by_extension"
|
||||||
|
) as mock_get_formatter:
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get_formatter.return_value = mock_formatter
|
mock_get_formatter.return_value = mock_formatter
|
||||||
main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"])
|
main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"])
|
||||||
@@ -454,7 +460,9 @@ class TestOutputPathResolution:
|
|||||||
|
|
||||||
@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_output_without_extension_appends_default_format(self, mock_fetch, tmp_path):
|
def test_output_without_extension_appends_default_format(
|
||||||
|
self, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
"""--output report без расширения → добавляет .xlsx."""
|
"""--output report без расширения → добавляет .xlsx."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
@@ -548,7 +556,9 @@ class TestCommitFlag:
|
|||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
|
config_path.write_text(
|
||||||
|
yaml.dump({"period": {"precision": "date", "dynamic": True}})
|
||||||
|
)
|
||||||
|
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -583,7 +593,9 @@ class TestCommitFlag:
|
|||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(yaml.dump({"period": {"precision": "datetime", "dynamic": True}}))
|
config_path.write_text(
|
||||||
|
yaml.dump({"period": {"precision": "datetime", "dynamic": True}})
|
||||||
|
)
|
||||||
|
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -630,7 +642,9 @@ class TestCommitFlag:
|
|||||||
@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")
|
||||||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
def test_commit_dynamic_false_overwrites_defaults(self, mock_save, mock_fetch, tmp_path):
|
def test_commit_dynamic_false_overwrites_defaults(
|
||||||
|
self, mock_save, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
"""При dynamic=false --commit перезаписывает default_from/to."""
|
"""При dynamic=false --commit перезаписывает default_from/to."""
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -638,7 +652,9 @@ class TestCommitFlag:
|
|||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": False}}))
|
config_path.write_text(
|
||||||
|
yaml.dump({"period": {"precision": "date", "dynamic": False}})
|
||||||
|
)
|
||||||
|
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -663,7 +679,9 @@ class TestCommitFlag:
|
|||||||
@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")
|
||||||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
def test_commit_without_output_uses_default_path(self, mock_save, mock_fetch, tmp_path):
|
def test_commit_without_output_uses_default_path(
|
||||||
|
self, mock_save, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
"""--commit без --output сохраняет файл по шаблону из конфига."""
|
"""--commit без --output сохраняет файл по шаблону из конфига."""
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -702,7 +720,9 @@ class TestCommitFlag:
|
|||||||
@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")
|
||||||
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
@mock.patch("redmine_reporter.cli.save_period_to_config")
|
||||||
def test_commit_prints_info_to_stderr(self, mock_save, mock_fetch, capsys, tmp_path):
|
def test_commit_prints_info_to_stderr(
|
||||||
|
self, mock_save, mock_fetch, capsys, tmp_path
|
||||||
|
):
|
||||||
"""--commit выводит сообщение о фиксации в stderr."""
|
"""--commit выводит сообщение о фиксации в stderr."""
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -710,7 +730,9 @@ class TestCommitFlag:
|
|||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
|
config_path.write_text(
|
||||||
|
yaml.dump({"period": {"precision": "date", "dynamic": True}})
|
||||||
|
)
|
||||||
|
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -741,7 +763,9 @@ class TestCommitFlag:
|
|||||||
mock_fetch.return_value = []
|
mock_fetch.return_value = []
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
|
config_path.write_text(
|
||||||
|
yaml.dump({"period": {"precision": "date", "dynamic": True}})
|
||||||
|
)
|
||||||
|
|
||||||
code = main(
|
code = main(
|
||||||
[
|
[
|
||||||
@@ -791,10 +815,13 @@ class TestSendFlag:
|
|||||||
output = str(tmp_path / "report.xlsx")
|
output = str(tmp_path / "report.xlsx")
|
||||||
code = main(
|
code = main(
|
||||||
[
|
[
|
||||||
"--date", "2026-06-01--2026-06-30",
|
"--date",
|
||||||
"--output", output,
|
"2026-06-01--2026-06-30",
|
||||||
|
"--output",
|
||||||
|
output,
|
||||||
"--send",
|
"--send",
|
||||||
"--config-path", str(config_path),
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assert code == 0
|
assert code == 0
|
||||||
@@ -804,7 +831,9 @@ class TestSendFlag:
|
|||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
def test_send_without_output_saves_to_default_path(self, mock_get, mock_send, mock_fetch, tmp_path):
|
def test_send_without_output_saves_to_default_path(
|
||||||
|
self, mock_get, mock_send, mock_fetch, tmp_path
|
||||||
|
):
|
||||||
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
@@ -830,9 +859,11 @@ class TestSendFlag:
|
|||||||
|
|
||||||
code = main(
|
code = main(
|
||||||
[
|
[
|
||||||
"--date", "2026-06-01--2026-06-30",
|
"--date",
|
||||||
|
"2026-06-01--2026-06-30",
|
||||||
"--send",
|
"--send",
|
||||||
"--config-path", str(config_path),
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assert code == 0
|
assert code == 0
|
||||||
@@ -851,10 +882,13 @@ class TestSendFlag:
|
|||||||
|
|
||||||
code = main(
|
code = main(
|
||||||
[
|
[
|
||||||
"--date", "2026-06-01--2026-06-30",
|
"--date",
|
||||||
"--output", str(tmp_path / "report.xlsx"),
|
"2026-06-01--2026-06-30",
|
||||||
|
"--output",
|
||||||
|
str(tmp_path / "report.xlsx"),
|
||||||
"--send",
|
"--send",
|
||||||
"--config-path", str(config_path),
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assert code == 1
|
assert code == 1
|
||||||
@@ -865,7 +899,9 @@ class TestSendFlag:
|
|||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
@mock.patch("redmine_reporter.cli.send_report")
|
@mock.patch("redmine_reporter.cli.send_report")
|
||||||
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
|
||||||
def test_send_smtp_error_reported_to_stderr(self, mock_get, mock_send, mock_fetch, tmp_path, capsys):
|
def test_send_smtp_error_reported_to_stderr(
|
||||||
|
self, mock_get, mock_send, mock_fetch, tmp_path, capsys
|
||||||
|
):
|
||||||
"""Ошибка SMTP выводится в stderr, exit code 1."""
|
"""Ошибка SMTP выводится в stderr, exit code 1."""
|
||||||
from redmine_reporter.client import RedmineAPIError
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
@@ -873,7 +909,9 @@ class TestSendFlag:
|
|||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
mock_send.side_effect = RedmineAPIError("Не удалось подключиться к SMTP-серверу bad:587")
|
mock_send.side_effect = RedmineAPIError(
|
||||||
|
"Не удалось подключиться к SMTP-серверу bad:587"
|
||||||
|
)
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -890,10 +928,13 @@ class TestSendFlag:
|
|||||||
|
|
||||||
code = main(
|
code = main(
|
||||||
[
|
[
|
||||||
"--date", "2026-06-01--2026-06-30",
|
"--date",
|
||||||
"--output", str(tmp_path / "report.xlsx"),
|
"2026-06-01--2026-06-30",
|
||||||
|
"--output",
|
||||||
|
str(tmp_path / "report.xlsx"),
|
||||||
"--send",
|
"--send",
|
||||||
"--config-path", str(config_path),
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assert code == 1
|
assert code == 1
|
||||||
@@ -932,11 +973,14 @@ class TestSendFlag:
|
|||||||
|
|
||||||
code = main(
|
code = main(
|
||||||
[
|
[
|
||||||
"--date", "2026-06-01--2026-06-30",
|
"--date",
|
||||||
"--output", str(tmp_path / "report.xlsx"),
|
"2026-06-01--2026-06-30",
|
||||||
|
"--output",
|
||||||
|
str(tmp_path / "report.xlsx"),
|
||||||
"--send",
|
"--send",
|
||||||
"--commit",
|
"--commit",
|
||||||
"--config-path", str(config_path),
|
"--config-path",
|
||||||
|
str(config_path),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assert code == 0
|
assert code == 0
|
||||||
|
|||||||
@@ -157,7 +157,9 @@ def test_fetch_uses_username_password_when_no_api_key(mock_redmine_class):
|
|||||||
assert "key" not in kwargs
|
assert "key" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True)
|
@mock.patch.dict(
|
||||||
|
os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True
|
||||||
|
)
|
||||||
@mock.patch("redmine_reporter.client.Redmine")
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
def test_fetch_uses_custom_verify_path(mock_redmine_class):
|
def test_fetch_uses_custom_verify_path(mock_redmine_class):
|
||||||
mock_redmine = mock_redmine_class.return_value
|
mock_redmine = mock_redmine_class.return_value
|
||||||
@@ -390,7 +392,9 @@ def test_fetch_mounts_retry_adapter(mock_redmine_class):
|
|||||||
assert "http://" in prefixes
|
assert "http://" in prefixes
|
||||||
|
|
||||||
# Проверяем retry-конфигурацию адаптера
|
# Проверяем retry-конфигурацию адаптера
|
||||||
https_adapter = next(call.args[1] for call in mount_calls if call.args[0] == "https://")
|
https_adapter = next(
|
||||||
|
call.args[1] for call in mount_calls if call.args[0] == "https://"
|
||||||
|
)
|
||||||
max_retries = https_adapter.max_retries
|
max_retries = https_adapter.max_retries
|
||||||
assert max_retries.total == 3
|
assert max_retries.total == 3
|
||||||
assert 429 in max_retries.status_forcelist
|
assert 429 in max_retries.status_forcelist
|
||||||
@@ -422,7 +426,9 @@ def test_fetch_chunks_large_issue_count(mock_redmine_class):
|
|||||||
ids_str = kwargs.get("issue_id", "")
|
ids_str = kwargs.get("issue_id", "")
|
||||||
call_chunks.append(ids_str)
|
call_chunks.append(ids_str)
|
||||||
ids = [int(x) for x in ids_str.split(",")]
|
ids = [int(x) for x in ids_str.split(",")]
|
||||||
return [mock.MagicMock(id=i, project="P", subject="T", status="New") for i in ids]
|
return [
|
||||||
|
mock.MagicMock(id=i, project="P", subject="T", status="New") for i in ids
|
||||||
|
]
|
||||||
|
|
||||||
mock_redmine.issue.filter.side_effect = issue_filter_side_effect
|
mock_redmine.issue.filter.side_effect = issue_filter_side_effect
|
||||||
|
|
||||||
@@ -473,7 +479,9 @@ def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
|
|||||||
mock_issue2.project = "P"
|
mock_issue2.project = "P"
|
||||||
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
|
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)
|
result = fetch_issues_with_spent_time(
|
||||||
|
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||||
|
)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
@@ -482,7 +490,9 @@ def test_dedup_filters_entries_created_before_cutoff(mock_redmine_class):
|
|||||||
|
|
||||||
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.client.Redmine")
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
def test_dedup_filters_entries_with_old_created_even_if_updated_recently(mock_redmine_class):
|
def test_dedup_filters_entries_with_old_created_even_if_updated_recently(
|
||||||
|
mock_redmine_class,
|
||||||
|
):
|
||||||
"""Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика)."""
|
"""Записи с created_on < cutoff исключаются даже при updated_on >= cutoff (AND-логика)."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -500,7 +510,9 @@ def test_dedup_filters_entries_with_old_created_even_if_updated_recently(mock_re
|
|||||||
mock_redmine.time_entry.filter.return_value = [e1]
|
mock_redmine.time_entry.filter.return_value = [e1]
|
||||||
mock_redmine.issue.filter.return_value = []
|
mock_redmine.issue.filter.return_value = []
|
||||||
|
|
||||||
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
|
result = fetch_issues_with_spent_time(
|
||||||
|
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||||
|
)
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@@ -531,7 +543,9 @@ def test_dedup_keeps_entries_created_after_cutoff(mock_redmine_class):
|
|||||||
mock_issue1.status = "New"
|
mock_issue1.status = "New"
|
||||||
mock_redmine.issue.filter.return_value = [mock_issue1]
|
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||||
|
|
||||||
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
|
result = fetch_issues_with_spent_time(
|
||||||
|
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||||
|
)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
@@ -563,7 +577,9 @@ def test_dedup_entries_without_created_on_are_kept(mock_redmine_class):
|
|||||||
mock_issue1.status = "New"
|
mock_issue1.status = "New"
|
||||||
mock_redmine.issue.filter.return_value = [mock_issue1]
|
mock_redmine.issue.filter.return_value = [mock_issue1]
|
||||||
|
|
||||||
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
|
result = fetch_issues_with_spent_time(
|
||||||
|
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||||
|
)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
@@ -589,7 +605,9 @@ def test_dedup_handles_string_created_on(mock_redmine_class):
|
|||||||
mock_redmine.time_entry.filter.return_value = [e1]
|
mock_redmine.time_entry.filter.return_value = [e1]
|
||||||
mock_redmine.issue.filter.return_value = []
|
mock_redmine.issue.filter.return_value = []
|
||||||
|
|
||||||
result = fetch_issues_with_spent_time("2026-07-01", "2026-07-01", dedup_before=dedup_cutoff)
|
result = fetch_issues_with_spent_time(
|
||||||
|
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||||
|
)
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@@ -665,7 +683,9 @@ def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
|
|||||||
mock_issue_akiy.status = "New"
|
mock_issue_akiy.status = "New"
|
||||||
mock_redmine.issue.filter.return_value = [mock_issue_new, mock_issue_akiy]
|
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)
|
result = fetch_issues_with_spent_time(
|
||||||
|
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||||
|
)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
|
|||||||
@@ -328,7 +328,9 @@ class TestConfigYamlFallback:
|
|||||||
|
|
||||||
assert Config.get_redmine_url() == "https://cli-override.example.com"
|
assert Config.get_redmine_url() == "https://cli-override.example.com"
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, {"REDMINE_URL": "https://env-redmine.example.com/"}, clear=True)
|
@mock.patch.dict(
|
||||||
|
os.environ, {"REDMINE_URL": "https://env-redmine.example.com/"}, clear=True
|
||||||
|
)
|
||||||
def test_env_beats_yaml(self):
|
def test_env_beats_yaml(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
yaml_path = Path(tmp) / "config.yml"
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
@@ -501,7 +503,9 @@ class TestComputeNextPeriod:
|
|||||||
def test_datetime_precision_moves_by_seconds(self):
|
def test_datetime_precision_moves_by_seconds(self):
|
||||||
from redmine_reporter.config import compute_next_period
|
from redmine_reporter.config import compute_next_period
|
||||||
|
|
||||||
nf, nt = compute_next_period("2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime")
|
nf, nt = compute_next_period(
|
||||||
|
"2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime"
|
||||||
|
)
|
||||||
assert nf == "2026-06-30T12:00:01"
|
assert nf == "2026-06-30T12:00:01"
|
||||||
assert nt == "2026-06-30T15:00:01"
|
assert nt == "2026-06-30T15:00:01"
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ def odt_formatter():
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
yield ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31")
|
yield ODTFormatter(
|
||||||
|
author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -- Тесты упаковки formatters как полноценного пакета --
|
# -- Тесты упаковки formatters как полноценного пакета --
|
||||||
@@ -171,7 +173,11 @@ def _simulate_missing_odfpy():
|
|||||||
|
|
||||||
saved = {}
|
saved = {}
|
||||||
for key in list(sys.modules.keys()):
|
for key in list(sys.modules.keys()):
|
||||||
if key == "odf" or key.startswith("odf.") or key == "redmine_reporter.formatters.odt":
|
if (
|
||||||
|
key == "odf"
|
||||||
|
or key.startswith("odf.")
|
||||||
|
or key == "redmine_reporter.formatters.odt"
|
||||||
|
):
|
||||||
saved[key] = sys.modules.pop(key)
|
saved[key] = sys.modules.pop(key)
|
||||||
return saved
|
return saved
|
||||||
|
|
||||||
@@ -474,7 +480,9 @@ def test_odt_empty_author_no_garbage_in_header(fake_rows):
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
formatter = ODTFormatter(author="", from_date="2026-01-01", to_date="2026-01-31")
|
formatter = ODTFormatter(
|
||||||
|
author="", from_date="2026-01-01", to_date="2026-01-31"
|
||||||
|
)
|
||||||
doc = formatter.format(fake_rows)
|
doc = formatter.format(fake_rows)
|
||||||
|
|
||||||
from odf.text import P
|
from odf.text import P
|
||||||
@@ -501,7 +509,9 @@ def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31")
|
formatter = ODTFormatter(
|
||||||
|
author="Тест", from_date="2026-01-01", to_date="2026-01-31"
|
||||||
|
)
|
||||||
output_file = tmp_path / "report.odt"
|
output_file = tmp_path / "report.odt"
|
||||||
formatter.save(fake_rows, str(output_file))
|
formatter.save(fake_rows, str(output_file))
|
||||||
|
|
||||||
@@ -537,7 +547,9 @@ def test_odt_has_covered_cells_for_spans(fake_rows):
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31")
|
formatter = ODTFormatter(
|
||||||
|
author="Тест", from_date="2026-01-01", to_date="2026-01-31"
|
||||||
|
)
|
||||||
doc = formatter.format(fake_rows)
|
doc = formatter.format(fake_rows)
|
||||||
|
|
||||||
from odf.table import CoveredTableCell
|
from odf.table import CoveredTableCell
|
||||||
|
|||||||
@@ -36,12 +36,16 @@ class TestBuildMessage:
|
|||||||
def test_subject_substitution(self):
|
def test_subject_substitution(self):
|
||||||
"""{author} и {period} подставляются в тему."""
|
"""{author} и {period} подставляются в тему."""
|
||||||
cfg = _make_email_config(subject="Отчёт {author} за {period}", attach=False)
|
cfg = _make_email_config(subject="Отчёт {author} за {period}", attach=False)
|
||||||
msg = build_message(cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30")
|
msg = build_message(
|
||||||
|
cfg, "/tmp/report.xlsx", "Кокос А.Н.", "2026-06-01--2026-06-30"
|
||||||
|
)
|
||||||
assert msg["Subject"] == "Отчёт Кокос А.Н. за 2026-06-01--2026-06-30"
|
assert msg["Subject"] == "Отчёт Кокос А.Н. за 2026-06-01--2026-06-30"
|
||||||
|
|
||||||
def test_body_substitution(self):
|
def test_body_substitution(self):
|
||||||
"""{author} и {period} подставляются в тело."""
|
"""{author} и {period} подставляются в тело."""
|
||||||
cfg = _make_email_config(body_text="Автор: {author}, период: {period}", attach=False)
|
cfg = _make_email_config(
|
||||||
|
body_text="Автор: {author}, период: {period}", attach=False
|
||||||
|
)
|
||||||
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1")
|
msg = build_message(cfg, "/tmp/report.xlsx", "Иванов", "Q1")
|
||||||
plain_part = msg.get_payload()[0]
|
plain_part = msg.get_payload()[0]
|
||||||
assert "Автор: Иванов, период: Q1" in plain_part.as_string()
|
assert "Автор: Иванов, период: Q1" in plain_part.as_string()
|
||||||
@@ -81,14 +85,20 @@ class TestBuildMessage:
|
|||||||
msg = build_message(cfg, str(report), "A", "P")
|
msg = build_message(cfg, str(report), "A", "P")
|
||||||
assert len(msg.get_payload()) == 1
|
assert len(msg.get_payload()) == 1
|
||||||
|
|
||||||
@pytest.mark.parametrize("ext,expected_mime", [
|
@pytest.mark.parametrize(
|
||||||
(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
|
"ext,expected_mime",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
".xlsx",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
),
|
||||||
(".odt", "application/vnd.oasis.opendocument.text"),
|
(".odt", "application/vnd.oasis.opendocument.text"),
|
||||||
(".csv", "text/csv"),
|
(".csv", "text/csv"),
|
||||||
(".html", "text/html"),
|
(".html", "text/html"),
|
||||||
(".json", "application/json"),
|
(".json", "application/json"),
|
||||||
(".md", "text/markdown"),
|
(".md", "text/markdown"),
|
||||||
])
|
],
|
||||||
|
)
|
||||||
def test_mime_type_by_extension(self, ext, expected_mime, tmp_path):
|
def test_mime_type_by_extension(self, ext, expected_mime, tmp_path):
|
||||||
report = tmp_path / f"report{ext}"
|
report = tmp_path / f"report{ext}"
|
||||||
report.write_text("content")
|
report.write_text("content")
|
||||||
@@ -154,7 +164,9 @@ class TestSendReport:
|
|||||||
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||||
mock_smtp = mock.MagicMock()
|
mock_smtp = mock.MagicMock()
|
||||||
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
mock_smtp.login.side_effect = smtplib.SMTPAuthenticationError(535, b"Bad auth")
|
mock_smtp.login.side_effect = smtplib.SMTPAuthenticationError(
|
||||||
|
535, b"Bad auth"
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(RedmineAPIError, match="Ошибка аутентификации SMTP"):
|
with pytest.raises(RedmineAPIError, match="Ошибка аутентификации SMTP"):
|
||||||
send_report(cfg, str(report), "A", "P")
|
send_report(cfg, str(report), "A", "P")
|
||||||
@@ -176,7 +188,9 @@ class TestSendReport:
|
|||||||
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
with mock.patch("smtplib.SMTP") as mock_smtp_class:
|
||||||
mock_smtp = mock.MagicMock()
|
mock_smtp = mock.MagicMock()
|
||||||
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
mock_smtp_class.return_value.__enter__.return_value = mock_smtp
|
||||||
mock_smtp.send_message.side_effect = smtplib.SMTPException("Something went wrong")
|
mock_smtp.send_message.side_effect = smtplib.SMTPException(
|
||||||
|
"Something went wrong"
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(RedmineAPIError, match="Ошибка отправки письма"):
|
with pytest.raises(RedmineAPIError, match="Ошибка отправки письма"):
|
||||||
send_report(cfg, str(report), "A", "P")
|
send_report(cfg, str(report), "A", "P")
|
||||||
|
|||||||
@@ -217,7 +217,9 @@ class TestSavePeriodToConfig:
|
|||||||
from redmine_reporter.yaml_config import save_period_to_config
|
from redmine_reporter.yaml_config import save_period_to_config
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
|
save_period_to_config(
|
||||||
|
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||||
|
)
|
||||||
|
|
||||||
with open(config_path) as fh:
|
with open(config_path) as fh:
|
||||||
data = yaml.safe_load(fh)
|
data = yaml.safe_load(fh)
|
||||||
@@ -233,10 +235,12 @@ class TestSavePeriodToConfig:
|
|||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
"period:\n" " last_used:\n" " from: '2026-05-01'\n" " to: '2026-05-31'\n"
|
"period:\n last_used:\n from: '2026-05-01'\n to: '2026-05-31'\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
|
save_period_to_config(
|
||||||
|
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||||
|
)
|
||||||
|
|
||||||
with open(config_path) as fh:
|
with open(config_path) as fh:
|
||||||
data = yaml.safe_load(fh)
|
data = yaml.safe_load(fh)
|
||||||
@@ -251,10 +255,12 @@ class TestSavePeriodToConfig:
|
|||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
"period:\n" " default_from: '2026-01-01'\n" " default_to: '2026-01-31'\n"
|
"period:\n default_from: '2026-01-01'\n default_to: '2026-01-31'\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", False)
|
save_period_to_config(
|
||||||
|
str(config_path), "2026-06-01", "2026-06-30", "date", False
|
||||||
|
)
|
||||||
|
|
||||||
with open(config_path) as fh:
|
with open(config_path) as fh:
|
||||||
data = yaml.safe_load(fh)
|
data = yaml.safe_load(fh)
|
||||||
@@ -299,7 +305,9 @@ class TestSavePeriodToConfig:
|
|||||||
" dir: /tmp\n"
|
" dir: /tmp\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
|
save_period_to_config(
|
||||||
|
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||||
|
)
|
||||||
|
|
||||||
with open(config_path) as fh:
|
with open(config_path) as fh:
|
||||||
data = yaml.safe_load(fh)
|
data = yaml.safe_load(fh)
|
||||||
@@ -314,7 +322,9 @@ class TestSavePeriodToConfig:
|
|||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
|
|
||||||
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
|
save_period_to_config(
|
||||||
|
str(config_path), "2026-06-01", "2026-06-30", "date", True
|
||||||
|
)
|
||||||
|
|
||||||
mode = config_path.stat().st_mode & 0o777
|
mode = config_path.stat().st_mode & 0o777
|
||||||
assert mode == 0o600
|
assert mode == 0o600
|
||||||
|
|||||||
Reference in New Issue
Block a user