Initial Redmine tooling and local plugin forks

This commit is contained in:
Jason Thistlethwaite
2026-04-24 22:01:18 +00:00
commit 9f682af0eb
683 changed files with 56878 additions and 0 deletions
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""Reset RedmineUP Helpdesk mail settings on the LAN test Redmine instance.
This is intended to be run after importing a production database into the test
instance. It finds projects with the Helpdesk module enabled and rewrites only
the incoming/outgoing mail settings so test mail flows through Mailpit.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
DEFAULT_SSH_HOST = "reddev@192.168.50.170"
DEFAULT_SSH_KEY = Path("/tmp/reddev")
DEFAULT_REMOTE_REDMINE = "/usr/share/redmine"
DEFAULT_MAILPIT_HOST = "192.168.1.105"
MAIL_SETTING_NAMES = [
"helpdesk_protocol",
"helpdesk_host",
"helpdesk_port",
"helpdesk_username",
"helpdesk_password",
"helpdesk_use_ssl",
"helpdesk_imap_folder",
"helpdesk_move_on_success",
"helpdesk_move_on_failure",
"helpdesk_apop",
"helpdesk_delete_unprocessed",
"helpdesk_smtp_use_default_settings",
"helpdesk_smtp_server",
"helpdesk_smtp_port",
"helpdesk_smtp_domain",
"helpdesk_smtp_authentication",
"helpdesk_smtp_username",
"helpdesk_smtp_password",
"helpdesk_smtp_ssl",
"helpdesk_smtp_tls",
"helpdesk_answer_from",
]
SECRET_NAMES = {"helpdesk_password", "helpdesk_smtp_password"}
class ResetError(RuntimeError):
pass
@dataclass(frozen=True)
class RemoteRedmine:
ssh_host: str
ssh_key: Path
remote_redmine: str
def mysql_json_lines(self, sql: str) -> list[dict[str, Any]]:
stdout = self.mysql(sql)
rows: list[dict[str, Any]] = []
for line in stdout.splitlines():
if not line.strip():
continue
try:
rows.append(json.loads(bytes.fromhex(line.strip()).decode("utf-8")))
except (ValueError, json.JSONDecodeError) as exc:
raise ResetError(f"Remote query returned an unexpected row: {line[:200]}") from exc
return rows
def mysql(self, sql: str) -> str:
command = [
"ssh",
"-i",
str(self.ssh_key),
"-o",
"IdentitiesOnly=yes",
self.ssh_host,
self._mysql_runner_command(),
]
try:
result = subprocess.run(
command,
input=sql,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
except OSError as exc:
raise ResetError(f"Could not run ssh: {exc}") from exc
if result.returncode != 0:
raise ResetError(result.stderr.strip() or "Remote MySQL command failed.")
return result.stdout
def _mysql_runner_command(self) -> str:
ruby = (
"require 'yaml'; "
"c = YAML.load_file('config/database.yml')['production']; "
"ENV['MYSQL_PWD'] = c['password'].to_s; "
"args = ['--batch', '--raw', '--quick', '--skip-column-names', "
"'--default-character-set=utf8', '-h', c['host'].to_s, "
"'-P', (c['port'] || 3306).to_s, '-u', c['username'].to_s, c['database'].to_s]; "
"exec('mysql', *args)"
)
return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}"
def main() -> int:
parser = argparse.ArgumentParser(
description="Reset Helpdesk mail settings for projects with the contacts_helpdesk module enabled."
)
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
parser.add_argument("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))))
parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE))
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST, help="Host Redmine should use to reach Mailpit.")
parser.add_argument("--pop3-port", type=int, default=1110)
parser.add_argument("--smtp-port", type=int, default=1025)
parser.add_argument("--username", default="test")
parser.add_argument("--password", default="testpass")
parser.add_argument("--smtp-domain", default="example.test")
parser.add_argument(
"--from-pattern",
default="helpdesk-{identifier}@example.test",
help="Pattern for helpdesk_answer_from. Available fields: {id}, {identifier}, {name}.",
)
parser.add_argument(
"--project",
action="append",
default=[],
help="Optional project id or identifier to limit changes. Can be passed more than once.",
)
parser.add_argument("--dry-run", action="store_true", help="Show affected projects and settings without writing.")
args = parser.parse_args()
remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine)
try:
projects = find_helpdesk_projects(remote, args.project)
if not projects:
print("No active projects with contacts_helpdesk enabled matched the requested filters.")
return 0
print(f"Matched {len(projects)} Helpdesk-enabled project(s):")
for project in projects:
print(f" - #{project['id']} {project['identifier']} ({project['name']})")
values = build_values(args, projects)
if args.dry_run:
print("\nDry run. Planned settings:")
print_plan(values)
return 0
apply_values(remote, values)
print(f"\nUpdated {len(values)} setting row(s) across {len(projects)} project(s).")
print("Password values were written but not displayed.")
return 0
except ResetError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
def find_helpdesk_projects(remote: RemoteRedmine, filters: list[str]) -> list[dict[str, Any]]:
where = ["p.status = 1"]
if filters:
clauses = []
for value in filters:
if str(value).isdigit():
clauses.append(f"p.id = {sql_int(value)}")
clauses.append(f"p.identifier = {sql_string(value)}")
where.append("(" + " OR ".join(clauses) + ")")
return remote.mysql_json_lines(
f"""
SELECT HEX(CAST(JSON_OBJECT(
'id', p.id,
'identifier', p.identifier,
'name', p.name
) AS CHAR)) AS document
FROM projects p
JOIN enabled_modules em
ON em.project_id = p.id
AND em.name = 'contacts_helpdesk'
WHERE {' AND '.join(where)}
ORDER BY p.identifier;
"""
)
def build_values(args: argparse.Namespace, projects: list[dict[str, Any]]) -> list[tuple[int, str, str]]:
rows: list[tuple[int, str, str]] = []
for project in projects:
project_id = int(project["id"])
answer_from = args.from_pattern.format(
id=project_id,
identifier=project["identifier"],
name=project["name"],
)
settings = {
"helpdesk_protocol": "pop3",
"helpdesk_host": args.mailpit_host,
"helpdesk_port": str(args.pop3_port),
"helpdesk_username": args.username,
"helpdesk_password": args.password,
"helpdesk_use_ssl": "0",
"helpdesk_imap_folder": "",
"helpdesk_move_on_success": "",
"helpdesk_move_on_failure": "",
"helpdesk_apop": "0",
"helpdesk_delete_unprocessed": "0",
# RedmineUP's UI label is confusing: 1 means use the custom SMTP block.
"helpdesk_smtp_use_default_settings": "1",
"helpdesk_smtp_server": args.mailpit_host,
"helpdesk_smtp_port": str(args.smtp_port),
"helpdesk_smtp_domain": args.smtp_domain,
"helpdesk_smtp_authentication": "",
"helpdesk_smtp_username": "",
"helpdesk_smtp_password": "",
"helpdesk_smtp_ssl": "0",
"helpdesk_smtp_tls": "0",
"helpdesk_answer_from": answer_from,
}
rows.extend((project_id, name, settings[name]) for name in MAIL_SETTING_NAMES)
return rows
def apply_values(remote: RemoteRedmine, rows: list[tuple[int, str, str]]) -> None:
statements = ["START TRANSACTION;"]
for project_id, name, value in rows:
project_id_sql = sql_int(project_id)
name_sql = sql_string(name)
value_sql = sql_string(value)
statements.append(
f"""
UPDATE contacts_settings
SET value = {value_sql}, updated_on = UTC_TIMESTAMP()
WHERE project_id = {project_id_sql}
AND name = {name_sql};
INSERT INTO contacts_settings (project_id, name, value, updated_on)
SELECT {project_id_sql}, {name_sql}, {value_sql}, UTC_TIMESTAMP()
WHERE NOT EXISTS (
SELECT 1
FROM contacts_settings
WHERE project_id = {project_id_sql}
AND name = {name_sql}
);
"""
)
statements.append("COMMIT;")
remote.mysql("\n".join(statements))
def print_plan(rows: list[tuple[int, str, str]]) -> None:
current_project_id: int | None = None
for project_id, name, value in rows:
if project_id != current_project_id:
current_project_id = project_id
print(f"\nProject #{project_id}")
display_value = "<redacted>" if name in SECRET_NAMES else value
print(f" {name} = {display_value}")
def sql_int(value: Any) -> int:
try:
return max(0, int(value))
except (TypeError, ValueError):
return 0
def sql_string(value: Any) -> str:
return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'"
def shell_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
if __name__ == "__main__":
raise SystemExit(main())