Tighten configuration and export handling

This commit is contained in:
Кокос Артем Николаевич
2026-05-22 17:41:56 +07:00
parent 8bc8181ce3
commit 2db0ab1f0b
20 changed files with 423 additions and 350 deletions

View File

@@ -1,12 +1,14 @@
import os
import sys
import argparse
import os
import re
import sys
from datetime import datetime
from typing import List, Optional
from .config import Config
from .client import 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
from .formatters.factory import get_formatter_by_extension, get_console_formatter
def parse_date_range(date_arg: str) -> tuple[str, str]:
@@ -15,7 +17,22 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
parts = date_arg.split("--", 1)
if len(parts) != 2:
raise ValueError("Invalid date range format")
return 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}"
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")
try:
start = datetime.strptime(from_date, "%Y-%m-%d").date()
end = datetime.strptime(to_date, "%Y-%m-%d").date()
except ValueError as e:
raise ValueError("Date range contains invalid calendar date") from e
if start > end:
raise ValueError("Date range start must be less than or equal to end")
return start.isoformat(), end.isoformat()
def main(argv: Optional[List[str]] = None) -> int:
@@ -36,7 +53,7 @@ def main(argv: Optional[List[str]] = None) -> int:
)
parser.add_argument(
"--output",
help="Path to output .odt file (e.g., report.odt). If omitted, prints to stdout.",
help="Path to output file (.odt, .csv, .md, .html). If omitted, prints to stdout.",
)
parser.add_argument(
"--author", default="", help="Override author name from .env (REDMINE_AUTHOR)"

View File

@@ -1,17 +1,21 @@
from typing import Any, Dict, List, Optional, Tuple
from redminelib import Redmine
from redminelib.resources import Issue
from .config import Config
from .utils import get_version
REQUESTS_OPTIONS = {"verify": "/etc/ssl/certs/ca-certificates.crt"}
def _get_redmine_auth_kwargs() -> Dict[str, Any]:
"""Return Redmine auth kwargs. API key has priority over legacy password auth."""
if Config.REDMINE_API_KEY:
return {"key": Config.REDMINE_API_KEY}
return {"username": Config.REDMINE_USER, "password": Config.REDMINE_PASSWORD}
api_key = Config.get_redmine_api_key()
if api_key:
return {"key": api_key}
return {
"username": Config.get_redmine_user(),
"password": Config.get_redmine_password(),
}
def fetch_issues_with_spent_time(
@@ -24,9 +28,9 @@ def fetch_issues_with_spent_time(
"""
redmine = Redmine(
Config.REDMINE_URL,
Config.get_redmine_url(),
**_get_redmine_auth_kwargs(),
requests=REQUESTS_OPTIONS,
requests={"verify": Config.get_redmine_verify()},
)
current_user = redmine.user.get("current")
@@ -48,9 +52,7 @@ def fetch_issues_with_spent_time(
# Загружаем полные объекты задач
issue_list_str = ",".join(str(i) for i in issue_ids)
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")
# Сопоставляем задачи с суммарным временем
result = []

View File

@@ -1,41 +1,68 @@
import os
from typing import Union
from dotenv import load_dotenv
load_dotenv()
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt"
FALSE_VALUES = {"0", "false", "no", "off"}
TRUE_VALUES = {"1", "true", "yes", "on"}
class Config:
REDMINE_URL = os.getenv("REDMINE_URL", "").strip().rstrip("/")
REDMINE_API_KEY = os.getenv("REDMINE_API_KEY")
REDMINE_USER = os.getenv("REDMINE_USER")
REDMINE_PASSWORD = os.getenv("REDMINE_PASSWORD")
REDMINE_AUTHOR = os.getenv("REDMINE_AUTHOR")
DEFAULT_FROM_DATE = os.getenv("DEFAULT_FROM_DATE")
DEFAULT_TO_DATE = os.getenv("DEFAULT_TO_DATE")
@classmethod
def get_redmine_url(cls) -> str:
return os.getenv("REDMINE_URL", "").strip().rstrip("/")
@classmethod
def get_redmine_api_key(cls) -> str:
return os.getenv("REDMINE_API_KEY", "").strip()
@classmethod
def get_redmine_user(cls) -> str:
return os.getenv("REDMINE_USER", "").strip()
@classmethod
def get_redmine_password(cls) -> str:
return os.getenv("REDMINE_PASSWORD", "")
@classmethod
def get_redmine_verify(cls) -> Union[bool, str]:
value = os.getenv("REDMINE_VERIFY", "").strip()
if not value:
return DEFAULT_REDMINE_VERIFY
normalized = value.lower()
if normalized in FALSE_VALUES:
return False
if normalized in TRUE_VALUES:
return True
return value
@classmethod
def get_author(cls, cli_author: str = "") -> str:
"""Возвращает автора: из CLI если задан, иначе из .env, иначе — заглушку."""
if cli_author:
return cli_author
if cls.REDMINE_AUTHOR:
return cls.REDMINE_AUTHOR
return ""
return os.getenv("REDMINE_AUTHOR", "").strip()
@classmethod
def get_default_date_range(cls) -> str:
if cls.DEFAULT_FROM_DATE and cls.DEFAULT_TO_DATE:
return f"{cls.DEFAULT_FROM_DATE}--{cls.DEFAULT_TO_DATE}"
default_from_date = os.getenv("DEFAULT_FROM_DATE", "").strip()
default_to_date = os.getenv("DEFAULT_TO_DATE", "").strip()
if default_from_date and default_to_date:
return f"{default_from_date}--{default_to_date}"
# fallback hardcoded
return "2025-12-19--2026-01-31"
@classmethod
def validate(cls) -> None:
if not cls.REDMINE_URL:
if not cls.get_redmine_url():
raise ValueError("REDMINE_URL is required (set via env or .env)")
if cls.REDMINE_API_KEY:
if cls.get_redmine_api_key():
return
if not (cls.REDMINE_USER and cls.REDMINE_PASSWORD):
if not (cls.get_redmine_user() and cls.get_redmine_password()):
raise ValueError(
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD"
)

View File

@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
from typing import Any, List
from ..types import ReportRow

View File

@@ -1,7 +1,9 @@
from typing import List
from tabulate import tabulate
from .base import Formatter
from ..types import ReportRow
from .base import Formatter
class TableFormatter(Formatter):

View File

@@ -1,8 +1,9 @@
import csv
import io
from typing import List
from .base import Formatter
from ..types import ReportRow
from .base import Formatter
class CSVFormatter(Formatter):
@@ -14,9 +15,7 @@ class CSVFormatter(Formatter):
def format(self, rows: List[ReportRow]) -> str:
output = io.StringIO()
writer = csv.writer(output, dialect="excel")
writer.writerow(
["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]
)
writer.writerow(["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"])
for r in rows:
writer.writerow(
[

View File

@@ -1,10 +1,11 @@
from typing import Dict, Type, Optional
from typing import Dict, Optional, Type
from .base import Formatter
from .console import TableFormatter, CompactFormatter
from .console import CompactFormatter, TableFormatter
from .csv import CSVFormatter
from .html import HTMLFormatter
from .markdown import MarkdownFormatter
from .odt import ODTFormatter
from .html import HTMLFormatter
# Словарь для сопоставления расширений файлов с классами форматтеров
FORMATTER_MAP: Dict[str, Type[Formatter]] = {

View File

@@ -1,6 +1,8 @@
from html import escape
from typing import Dict, List
from .base import Formatter
from ..types import ReportRow
from .base import Formatter
class HTMLFormatter(Formatter):
@@ -36,34 +38,38 @@ class HTMLFormatter(Formatter):
]
for project, versions in projects.items():
project_text = escape(project)
total_project_rows = sum(len(tasks) for tasks in versions.values())
first_version_in_project = True
for version, task_rows in versions.items():
version_text = escape(version)
row_span_version = len(task_rows)
first_row_in_version = True
for r in task_rows:
task_cell = escape(f"{r['issue_id']}. {r['subject']}")
status_text = escape(r["status_ru"])
time_text = escape(r["time_text"])
lines.append(" <tr>")
# Ячейка "Проект" - только в первой строке проекта
if first_version_in_project and first_row_in_version:
lines.append(
f' <td rowspan="{total_project_rows}" style="vertical-align: top;">{project}</td>'
f' <td rowspan="{total_project_rows}" style="vertical-align: top;">{project_text}</td>'
)
# Ячейка "Версия" - только в первой строке версии
if first_row_in_version:
lines.append(
f' <td rowspan="{row_span_version}" style="vertical-align: top;">{version}</td>'
f' <td rowspan="{row_span_version}" style="vertical-align: top;">{version_text}</td>'
)
first_row_in_version = False
# Остальные колонки
task_cell = f"{r['issue_id']}. {r['subject']}"
lines.append(f" <td>{task_cell}</td>")
lines.append(f" <td>{r['status_ru']}</td>")
lines.append(f" <td>{r['time_text']}</td>")
lines.append(f" <td>{status_text}</td>")
lines.append(f" <td>{time_text}</td>")
lines.append(" </tr>")

View File

@@ -1,6 +1,11 @@
from typing import List
from .base import Formatter
from ..types import ReportRow
from .base import Formatter
def _escape_markdown_table_cell(value: object) -> str:
return str(value).replace("\\", "\\\\").replace("|", "\\|").replace("\n", "<br>")
class MarkdownFormatter(Formatter):
@@ -15,10 +20,13 @@ class MarkdownFormatter(Formatter):
"|--------|--------|--------|--------|-----------|",
]
for r in rows:
task_cell = f"{r['issue_id']}. {r['subject']}"
task_cell = _escape_markdown_table_cell(f"{r['issue_id']}. {r['subject']}")
lines.append(
f"| {r['display_project']} | {r['display_version']} "
f"| {task_cell} | {r['status_ru']} | {r['time_text']} |"
f"| {_escape_markdown_table_cell(r['display_project'])} "
f"| {_escape_markdown_table_cell(r['display_version'])} "
f"| {task_cell} "
f"| {_escape_markdown_table_cell(r['status_ru'])} "
f"| {_escape_markdown_table_cell(r['time_text'])} |"
)
return "\n".join(lines)

View File

@@ -1,12 +1,14 @@
from importlib import resources
from typing import List
from typing import Dict, List
from odf.opendocument import OpenDocument, load
from odf.style import Style, TableCellProperties, TableColumnProperties
from odf.table import Table, TableCell, TableColumn, TableRow
from odf.text import P
from odf.table import Table, TableColumn, TableRow, TableCell
from odf.style import Style, TableColumnProperties, TableCellProperties
from .base import Formatter
from ..types import ReportRow
from ..utils import get_month_name_from_range
from .base import Formatter
class ODTFormatter(Formatter):
@@ -25,11 +27,7 @@ class ODTFormatter(Formatter):
Форматирует данные в объект 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)
para_style_name = "Standard"
@@ -43,9 +41,7 @@ class ODTFormatter(Formatter):
# Стиль ячеек
cell_style_name = "TableCellStyle"
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)
doc.automaticstyles.addElement(cell_style)
@@ -73,7 +69,7 @@ class ODTFormatter(Formatter):
header_row.addElement(cell)
table.addElement(header_row)
projects = {}
projects: Dict[str, Dict[str, List[ReportRow]]] = {}
for r in rows:
project = r["project"]
version = r["version"]
@@ -103,9 +99,7 @@ class ODTFormatter(Formatter):
# Ячейка "Проект" - только в первой строке всего проекта
if first_version_in_project and first_row_in_version:
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)
cell_project.addElement(p)
row.addElement(cell_project)
@@ -113,9 +107,7 @@ class ODTFormatter(Formatter):
# Ячейка "Версия" - только в первой строке каждой версии
if first_row_in_version:
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)
cell_version.addElement(p)
row.addElement(cell_version)

View File

@@ -1,5 +1,7 @@
from typing import List, Tuple, cast
from redminelib.resources import Issue
from .types import ReportRow
from .utils import get_version, hours_to_human
@@ -33,9 +35,7 @@ def build_grouped_report(
"""
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
issue_hours = sorted(
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]))
)
issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0])))
rows: List[ReportRow] = []
prev_project: str = ""
@@ -49,9 +49,7 @@ def build_grouped_report(
time_text = hours_to_human(hours) if fill_time 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(
cast(

View File

@@ -30,7 +30,10 @@ def get_month_name_from_range(from_date: str, to_date: str) -> str:
def get_version(issue) -> str:
"""Возвращает версию задачи или '<N/A>', если не задана."""
return str(getattr(issue, "fixed_version", "<N/A>"))
version = getattr(issue, "fixed_version", None)
if version is None:
return "<N/A>"
return str(version)
def hours_to_human(hours: float) -> str: