Add Redmine API token authentication

This commit is contained in:
Кокос Артем Николаевич
2026-05-22 17:18:30 +07:00
parent 7bc6e024c0
commit 8bc8181ce3
14 changed files with 190 additions and 43 deletions

View File

@@ -2,7 +2,6 @@ import os
import sys
import argparse
from typing import List, Optional
from redminelib.resources import Issue
from .config import Config
from .client import fetch_issues_with_spent_time
@@ -31,7 +30,9 @@ def main(argv: Optional[List[str]] = None) -> int:
help="Date range in format YYYY-MM-DD--YYYY-MM-DD (default from .env or %(default)s)",
)
parser.add_argument(
"--compact", action="store_true", help="Use compact plain-text output instead of table"
"--compact",
action="store_true",
help="Use compact plain-text output instead of table",
)
parser.add_argument(
"--output",
@@ -82,7 +83,10 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1
formatter = get_formatter_by_extension(
output_ext, author=Config.get_author(args.author), from_date=from_date, to_date=to_date
output_ext,
author=Config.get_author(args.author),
from_date=from_date,
to_date=to_date,
)
if not formatter:
@@ -98,7 +102,10 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"✅ Report saved to {args.output}")
except ImportError as e:
if output_ext == ".odt":
print("❌ odfpy is not installed. Install with: pip install odfpy", file=sys.stderr)
print(
"❌ odfpy is not installed. Install with: pip install odfpy",
file=sys.stderr,
)
else:
print(f"❌ Import error: {e}", file=sys.stderr)
return 1

View File

@@ -1,9 +1,18 @@
from typing import List, Optional, Dict, Tuple
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}
def fetch_issues_with_spent_time(
from_date: str, to_date: str
@@ -16,9 +25,8 @@ def fetch_issues_with_spent_time(
redmine = Redmine(
Config.REDMINE_URL,
username=Config.REDMINE_USER,
password=Config.REDMINE_PASSWORD,
requests={"verify": "/etc/ssl/certs/ca-certificates.crt"},
**_get_redmine_auth_kwargs(),
requests=REQUESTS_OPTIONS,
)
current_user = redmine.user.get("current")
@@ -40,7 +48,9 @@ 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

@@ -6,6 +6,7 @@ load_dotenv()
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")
@@ -32,7 +33,9 @@ class Config:
def validate(cls) -> None:
if not cls.REDMINE_URL:
raise ValueError("REDMINE_URL is required (set via env or .env)")
if not cls.REDMINE_USER:
raise ValueError("REDMINE_USER is required")
if not cls.REDMINE_PASSWORD:
raise ValueError("REDMINE_PASSWORD is required")
if cls.REDMINE_API_KEY:
return
if not (cls.REDMINE_USER and cls.REDMINE_PASSWORD):
raise ValueError(
"REDMINE_API_KEY is required, or set both REDMINE_USER and REDMINE_PASSWORD"
)

View File

@@ -14,7 +14,9 @@ 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,4 +1,4 @@
from typing import List, Dict, Any
from typing import Dict, List
from .base import Formatter
from ..types import ReportRow

View File

@@ -1,7 +1,6 @@
import os
from importlib import resources
from typing import List
from odf.opendocument import load
from odf.opendocument import OpenDocument, load
from odf.text import P
from odf.table import Table, TableColumn, TableRow, TableCell
from odf.style import Style, TableColumnProperties, TableCellProperties
@@ -21,7 +20,7 @@ class ODTFormatter(Formatter):
self.from_date = from_date
self.to_date = to_date
def format(self, rows: List[ReportRow]) -> "OpenDocument":
def format(self, rows: List[ReportRow]) -> OpenDocument:
"""
Форматирует данные в объект OpenDocument.
"""
@@ -44,7 +43,9 @@ 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)
@@ -102,7 +103,9 @@ 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)
@@ -110,7 +113,9 @@ 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

@@ -33,7 +33,9 @@ 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 = ""
@@ -47,7 +49,9 @@ 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(