Files
redmine-reporter/redmine_reporter/config.py
Кокос Артем Николаевич a1febd6999 fix: require HTTPS for Redmine URL
Config.validate() now rejects REDMINE_URL values whose scheme is not
HTTPS (http://, missing scheme, or any other scheme). The API key is
sent in request headers, so a plain-HTTP endpoint would expose it.
Scheme comparison is case-insensitive per RFC 3986.

Closes #54
2026-07-17 12:48:45 +07:00

464 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import logging
import os
from dataclasses import dataclass, field
from datetime import date, timedelta
from pathlib import Path
from typing import Union
import yaml
from dotenv import load_dotenv
from .yaml_config import check_file_permissions, resolve_env_vars
load_dotenv(override=False)
logger = logging.getLogger(__name__)
DEFAULT_REDMINE_VERIFY = "/etc/ssl/certs/ca-certificates.crt"
FALSE_VALUES = {"0", "false", "no", "off"}
TRUE_VALUES = {"1", "true", "yes", "on"}
@dataclass
class SmtpConfig:
host: str = ""
port: int = 587
user: str = ""
password: str = ""
tls: bool = True
@dataclass
class EmailConfig:
smtp: SmtpConfig = field(default_factory=SmtpConfig)
from_: str = ""
to: list = field(default_factory=list)
cc: list = field(default_factory=list)
bcc: list = field(default_factory=list)
subject: str = "Отчёт {author} за {period}"
body_text: str = "Во вложении отчёт."
attach: bool = True
html: bool = False
@dataclass
class PeriodLastUsed:
from_: str = ""
to: str = ""
@dataclass
class AppConfig:
redmine_url: str = ""
redmine_api_key: str = ""
redmine_author: str = ""
redmine_verify: Union[bool, str] = DEFAULT_REDMINE_VERIFY
period_precision: str = "date"
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"
report_no_time: bool = False
email: EmailConfig = field(default_factory=EmailConfig)
@classmethod
def from_yaml(cls, path: Union[str, Path]) -> "AppConfig":
"""Загружает настройки из YAML-файла.
Если файл не существует — возвращает конфиг со значениями по умолчанию.
Неизвестные ключи верхнего уровня логируются с warning.
"""
path = Path(path)
if not path.exists():
return cls()
# Проверяем права файла
for warning in check_file_permissions(path):
logger.warning(warning)
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
# Предупреждаем о неизвестных ключах верхнего уровня
known_keys = {
"redmine",
"period",
"output",
"report",
"email",
}
for key in raw:
if key not in known_keys:
logger.warning("Unknown top-level key in config: %s", key)
return cls(
redmine_url=cls._resolve_str(raw, "redmine", "url"),
redmine_api_key=cls._resolve_str(raw, "redmine", "api_key"),
redmine_author=cls._resolve_str(raw, "redmine", "author"),
redmine_verify=cls._resolve_verify(raw),
period_precision=cls._resolve_str(raw, "period", "precision") or "date",
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}",
output_default_format=cls._resolve_str(raw, "output", "default_format")
or "xlsx",
report_no_time=cls._resolve_bool(raw, "report", "no_time"),
email=cls._resolve_email(raw),
)
@classmethod
def from_env(cls) -> "AppConfig":
"""Загружает настройки из переменных окружения."""
return cls(
redmine_url=os.getenv("REDMINE_URL", "").strip().rstrip("/"),
redmine_api_key=os.getenv("REDMINE_API_KEY", "").strip(),
redmine_author=os.getenv("REDMINE_AUTHOR", "").strip(),
period_default_from=os.getenv("DEFAULT_FROM_DATE", "").strip(),
period_default_to=os.getenv("DEFAULT_TO_DATE", "").strip(),
)
@staticmethod
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:
return ""
return str(value)
@staticmethod
def _resolve_bool(raw: dict, section: str, key: str) -> bool:
value = raw.get(section, {}).get(key)
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.lower()
if normalized in TRUE_VALUES:
return True
if normalized in FALSE_VALUES:
return False
return False
@classmethod
def _resolve_verify(cls, raw: dict) -> Union[bool, str]:
value = raw.get("redmine", {}).get("verify_ssl")
if value is None:
return DEFAULT_REDMINE_VERIFY
if isinstance(value, bool):
return DEFAULT_REDMINE_VERIFY if value else False
if isinstance(value, str):
normalized = value.lower()
if normalized in FALSE_VALUES:
return False
if normalized in TRUE_VALUES:
return DEFAULT_REDMINE_VERIFY
return resolve_env_vars(value)
return DEFAULT_REDMINE_VERIFY
@classmethod
def _resolve_email(cls, raw: dict) -> EmailConfig:
email_raw = raw.get("email", {}) or {}
smtp_raw = email_raw.get("smtp", {}) or {}
return EmailConfig(
smtp=SmtpConfig(
host=cls._safe_str(smtp_raw.get("host")),
port=cls._safe_int(smtp_raw.get("port"), 587),
user=cls._safe_str(smtp_raw.get("user")),
password=resolve_env_vars(cls._safe_str(smtp_raw.get("password"))),
tls=cls._safe_bool(smtp_raw.get("tls"), True),
),
from_=cls._safe_str(email_raw.get("from")),
to=cls._safe_list(email_raw.get("to")),
cc=cls._safe_list(email_raw.get("cc")),
bcc=cls._safe_list(email_raw.get("bcc")),
subject=resolve_env_vars(
cls._safe_str(email_raw.get("subject")) or "Отчёт {author} за {period}"
),
body_text=resolve_env_vars(
cls._safe_str(email_raw.get("body_text")) or "Во вложении отчёт."
),
attach=cls._safe_bool(email_raw.get("attach"), True),
html=cls._safe_bool(email_raw.get("html"), False),
)
@staticmethod
def _safe_str(value) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
return str(value)
@staticmethod
def _safe_int(value, default: int = 0) -> int:
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
@staticmethod
def _safe_bool(value, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.lower()
if normalized in TRUE_VALUES:
return True
if normalized in FALSE_VALUES:
return False
return default
@staticmethod
def _safe_list(value) -> list:
if value is None:
return []
if isinstance(value, list):
return [str(v) for v in value]
return [str(value)]
class Config:
_cli_url: str | None = None
_cli_api_key: str | None = None
_app: AppConfig | None = None
@classmethod
def load_yaml(cls, path: str) -> None:
"""Загружает YAML-конфиг. Не бросает исключений при отсутствии файла."""
cls._app = AppConfig.from_yaml(path)
@classmethod
def load_config(cls, path: str) -> None:
"""Загружает переменные из указанного .env-файла с override."""
load_dotenv(path, override=True)
@classmethod
def set_redmine_url(cls, url: str) -> None:
cls._cli_url = url.strip().rstrip("/") if url else None
@classmethod
def set_redmine_api_key(cls, key: str) -> None:
cls._cli_api_key = key.strip() if key else None
@classmethod
def get_redmine_url(cls) -> str:
if cls._cli_url is not None:
return cls._cli_url
env = os.getenv("REDMINE_URL", "").strip().rstrip("/")
if env:
return env
if cls._app:
return cls._app.redmine_url.strip().rstrip("/")
return ""
@classmethod
def get_redmine_api_key(cls) -> str:
if cls._cli_api_key is not None:
return cls._cli_api_key
env = os.getenv("REDMINE_API_KEY", "").strip()
if env:
return env
if cls._app:
return cls._app.redmine_api_key
return ""
@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", "").strip()
@classmethod
def get_redmine_verify(cls) -> Union[bool, str]:
value = os.getenv("REDMINE_VERIFY", "").strip()
if value:
normalized = value.lower()
if normalized in FALSE_VALUES:
return False
if normalized in TRUE_VALUES:
return True
return value
if cls._app:
return cls._app.redmine_verify
return DEFAULT_REDMINE_VERIFY
@classmethod
def get_author(cls, cli_author: str = "") -> str:
if cli_author:
return cli_author
env = os.getenv("REDMINE_AUTHOR", "").strip()
if env:
return env
if cls._app:
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_report_no_time(cls) -> bool:
"""Возвращает report.no_time из YAML-конфига (по умолчанию False)."""
if cls._app:
return cls._app.report_no_time
return False
@classmethod
def get_email_config(cls) -> "EmailConfig | None":
"""Возвращает EmailConfig из YAML-конфига или None, если не настроен."""
if cls._app is None:
return None
email = cls._app.email
if not email.smtp.host:
return None
return email
@classmethod
def get_default_date_range(cls) -> str:
from_env = os.getenv("DEFAULT_FROM_DATE", "").strip()
to_env = os.getenv("DEFAULT_TO_DATE", "").strip()
today = date.today()
today_str = today.isoformat()
if from_env:
return f"{from_env}--{to_env or today_str}"
if (
cls._app
and cls._app.period_dynamic
and cls._app.period_last_used_from
and cls._app.period_last_used_to
):
nf, nt = compute_next_period(
cls._app.period_last_used_from,
cls._app.period_last_used_to,
cls._app.period_precision or "date",
)
return f"{nf}--{nt}"
if cls._app and cls._app.period_default_from:
default_to = cls._app.period_default_to or today_str
return f"{cls._app.period_default_from}--{default_to}"
start = today.replace(day=1)
return f"{start.isoformat()}--{today_str}"
@classmethod
def validate(cls) -> None:
url = cls.get_redmine_url()
if not url:
raise ValueError("REDMINE_URL is required (set via env or .env)")
if not url.lower().startswith("https://"):
raise ValueError(
"REDMINE_URL must use HTTPS: the API key is sent in request "
"headers and requires TLS"
)
if cls.get_redmine_api_key():
return
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"
)
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.
- For a full calendar month → next full calendar month.
- For an arbitrary range → same duration, starting the day after last_to.
- For datetime precision → same duration, starting 1 second after last_to.
"""
from datetime import datetime as dt_mod
if precision == "datetime":
fmt = "%Y-%m-%dT%H:%M:%S"
from_dt = dt_mod.fromisoformat(last_from.replace("Z", "+00:00"))
to_dt = dt_mod.fromisoformat(last_to.replace("Z", "+00:00"))
duration = to_dt - from_dt
next_from_dt = to_dt + timedelta(seconds=1)
next_to_dt = next_from_dt + duration
return next_from_dt.strftime(fmt), next_to_dt.strftime(fmt)
from_parts = last_from.split("-")
to_parts = last_to.split("-")
from_d = date(int(from_parts[0]), int(from_parts[1]), int(from_parts[2]))
to_d = date(int(to_parts[0]), int(to_parts[1]), int(to_parts[2]))
first_of_month = from_d.replace(day=1)
if from_d == first_of_month:
if to_d.month == 12:
last_of_month = date(to_d.year, 12, 31)
else:
next_first = date(to_d.year, to_d.month + 1, 1)
last_of_month = next_first - timedelta(days=1)
if to_d == last_of_month:
if to_d.month == 12:
nstart = date(to_d.year + 1, 1, 1)
else:
nstart = date(to_d.year, to_d.month + 1, 1)
if nstart.month == 12:
nend = date(nstart.year, 12, 31)
else:
nend = date(nstart.year, nstart.month + 1, 1) - timedelta(days=1)
return nstart.isoformat(), nend.isoformat()
duration = to_d - from_d
next_from = to_d + timedelta(days=1)
next_to = next_from + duration
return next_from.isoformat(), next_to.isoformat()