generated from john/python-template
This commit is contained in:
+11
-7
@@ -31,7 +31,14 @@ SQLITE_CHECK_SAME_THREAD=false
|
||||
# --- filesystem paths ---
|
||||
UPLOAD_DIR=/app/uploads
|
||||
PROMPT_DIR=/app/prompts
|
||||
DATABASE_BACKUP_DIR=/app/data/backups
|
||||
DATABASE_BACKUP_DIR=/backup
|
||||
# --- backup workflow helpers (not Runtime Settings model fields) ---
|
||||
BACKUP_DIR=/backup
|
||||
# APP_DATA_BACKUP_DIR=/backup/data
|
||||
# UPLOADS_BACKUP_DIR=/backup/uploads
|
||||
BACKUP_RETENTION_DAYS=14
|
||||
# Optional mounted Synology destination path for replicated dumps
|
||||
# SYNOLOGY_BACKUP_DIR=/mnt/synology/transcription-backups
|
||||
|
||||
# --- worker reliability ---
|
||||
WORKER_MAX_RETRIES=0
|
||||
@@ -53,9 +60,6 @@ POSTGRES_PASSWORD=replace-with-strong-password
|
||||
# Required for token-based tunnel startup.
|
||||
CLOUDFLARE_TUNNEL_TOKEN=replace-with-cloudflare-tunnel-token
|
||||
|
||||
# --- backup workflow helpers ---
|
||||
# Used by deploy/backup/create_postgres_backup.sh
|
||||
BACKUP_DIR=./data/backups
|
||||
BACKUP_RETENTION_DAYS=14
|
||||
# Optional mounted Synology destination path for replicated dumps
|
||||
# SYNOLOGY_BACKUP_DIR=/mnt/synology/transcription-backups
|
||||
# --- deployment wiring helpers ---
|
||||
# Runtime Settings writes target this file path inside the app container.
|
||||
RUNTIME_SETTINGS_ENV_FILE=/app/.env.production
|
||||
|
||||
@@ -16,6 +16,8 @@ logs_file="logs-${timestamp}.tar.gz"
|
||||
manifest_file="backup-${timestamp}.manifest"
|
||||
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
mkdir -p "${APP_DATA_BACKUP_DIR}"
|
||||
app_data_backup_abs="$(cd "${APP_DATA_BACKUP_DIR}" && pwd -P)"
|
||||
|
||||
# If SYNOLOGY_BACKUP_DIR wasn't exported in the shell, read it from ENV_FILE.
|
||||
if [ -z "${SYNOLOGY_BACKUP_DIR}" ] && [ -f "${ENV_FILE}" ]; then
|
||||
@@ -68,10 +70,17 @@ find "${BACKUP_DIR}" -type f \( \
|
||||
-name 'backup-*.manifest' \
|
||||
\) -mtime +"${RETENTION_DAYS}" -delete
|
||||
|
||||
# Always keep a rolling incremental mirror of uploaded files in BACKUP_DIR.
|
||||
mkdir -p "${APP_DATA_BACKUP_DIR}"
|
||||
# Always keep a rolling mirror of app data in BACKUP_DIR, unless that target
|
||||
# is nested under /app/data (which would recursively copy into itself).
|
||||
if [ -d /app/data ]; then
|
||||
cp -a /app/data/. "${APP_DATA_BACKUP_DIR}/"
|
||||
case "${app_data_backup_abs}" in
|
||||
/app/data|/app/data/*)
|
||||
echo "Skipping app data mirror because APP_DATA_BACKUP_DIR is nested under /app/data: ${APP_DATA_BACKUP_DIR}"
|
||||
;;
|
||||
*)
|
||||
cp -a /app/data/. "${APP_DATA_BACKUP_DIR}/"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
mkdir -p "${UPLOADS_BACKUP_DIR}/documents" "${UPLOADS_BACKUP_DIR}/photos"
|
||||
|
||||
@@ -30,7 +30,7 @@ Settings manages installation-local registries, safe runtime .env settings, and
|
||||
- Runtime Settings includes an explicit "Other settings not shown here" markdown table listing:
|
||||
- secrets (`OPENROUTER_API_KEY`, `DATABASE__PASSWORD`)
|
||||
- high-risk database connection settings (`DATABASE__DRIVER`, `DATABASE__PATH`, `DATABASE__HOST`, `DATABASE__PORT`, `DATABASE__DATABASE`, `DATABASE__USER`)
|
||||
and directs edits for those keys to `.env`.
|
||||
and deployment/helper keys (`POSTGRES_*`, `CLOUDFLARE_TUNNEL_TOKEN`, `BACKUP_DIR`, `APP_DATA_BACKUP_DIR`, `UPLOADS_BACKUP_DIR`, `BACKUP_RETENTION_DAYS`, `SYNOLOGY_BACKUP_DIR`, `RUNTIME_SETTINGS_ENV_FILE`, `ENV_FILE`, `COMPOSE_FILE`), and directs edits for those keys to the resolved runtime env file path.
|
||||
- Document Types, Person Roles, and Tags support Add/Edit/Delete with existing guardrails.
|
||||
- Prompts exposes only `transcribe_document.md` for editing and restore-from-backup.
|
||||
- Home Page Text edits the same Markdown content rendered on `/homepage`.
|
||||
|
||||
@@ -711,7 +711,7 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
||||
|
||||
with ui.column().classes("w-full max-w-3xl mx-auto gap-2 mt-3"):
|
||||
ui.label("Other settings not shown here").classes("text-sm font-semibold")
|
||||
ui.label("Edit these directly in .env:").classes("text-xs ui-text-muted")
|
||||
ui.label(f"Edit these directly in {snapshot.env_file_path}:").classes("text-xs ui-text-muted")
|
||||
table_rows = [
|
||||
f"| {category.title} | {', '.join(f'`{key}`' for key in category.env_keys)} |"
|
||||
for category in HIDDEN_SETTINGS_CATEGORIES
|
||||
|
||||
@@ -84,6 +84,29 @@ HIDDEN_SETTINGS_CATEGORIES: tuple[HiddenSettingsCategory, ...] = (
|
||||
"DATABASE__USER",
|
||||
),
|
||||
),
|
||||
HiddenSettingsCategory(
|
||||
title="Deployment and backup/tunnel helpers (not Runtime Settings model fields)",
|
||||
reason=(
|
||||
"These keys are consumed by docker-compose, backup scripts, or deployment adapters and are "
|
||||
"intentionally edited outside the Runtime Settings UI."
|
||||
),
|
||||
env_keys=(
|
||||
"POSTGRES_DB",
|
||||
"POSTGRES_USER",
|
||||
"POSTGRES_PASSWORD",
|
||||
"POSTGRES_HOST",
|
||||
"POSTGRES_PORT",
|
||||
"CLOUDFLARE_TUNNEL_TOKEN",
|
||||
"RUNTIME_SETTINGS_ENV_FILE",
|
||||
"BACKUP_DIR",
|
||||
"APP_DATA_BACKUP_DIR",
|
||||
"UPLOADS_BACKUP_DIR",
|
||||
"BACKUP_RETENTION_DAYS",
|
||||
"SYNOLOGY_BACKUP_DIR",
|
||||
"ENV_FILE",
|
||||
"COMPOSE_FILE",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
RUNTIME_SETTINGS_CATALOG: tuple[RuntimeSettingDescriptor, ...] = (
|
||||
@@ -358,7 +381,15 @@ def save_runtime_settings(
|
||||
env_map[descriptor.env_key] = encoded
|
||||
|
||||
_validate_candidate_env(env_map=env_map, env_file_path=resolved_env_path)
|
||||
_write_env_lines_atomic(path=resolved_env_path, lines=mutable_lines)
|
||||
try:
|
||||
_write_env_lines_atomic(path=resolved_env_path, lines=mutable_lines)
|
||||
except OSError as exc:
|
||||
raise AppError(
|
||||
"Runtime settings file is not writable.",
|
||||
category=ErrorCategory.INFRA_PERSISTENT,
|
||||
suggestion="Verify file path and write permissions, then retry.",
|
||||
detail=f"Failed writing runtime env file {resolved_env_path}: {type(exc).__name__}: {exc}",
|
||||
) from exc
|
||||
refreshed = Settings(_env_file=resolved_env_path, _cli_parse_args=False)
|
||||
return read_runtime_settings_snapshot(settings=refreshed, env_file_path=resolved_env_path)
|
||||
|
||||
@@ -438,7 +469,15 @@ def _validate_candidate_env(*, env_map: dict[str, str], env_file_path: Path) ->
|
||||
def _read_env_lines(path: Path) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
return path.read_text(encoding="utf-8").splitlines()
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError as exc:
|
||||
raise AppError(
|
||||
"Runtime settings file is unreadable.",
|
||||
category=ErrorCategory.INFRA_PERSISTENT,
|
||||
suggestion="Verify file path and read permissions, then retry.",
|
||||
detail=f"Failed reading runtime env file {path}: {type(exc).__name__}: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
def _parse_env_map(lines: list[str]) -> dict[str, str]:
|
||||
@@ -485,21 +524,29 @@ def _upsert_env_key(lines: list[str], key: str, value: str) -> None:
|
||||
|
||||
|
||||
def _write_env_lines_atomic(*, path: Path, lines: list[str]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = "\n".join(lines).rstrip("\n")
|
||||
if content:
|
||||
content += "\n"
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
delete=False,
|
||||
dir=path.parent,
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
suffix=".env.tmp",
|
||||
) as handle:
|
||||
temp_path = Path(handle.name)
|
||||
handle.write(content)
|
||||
temp_path.replace(path)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = "\n".join(lines).rstrip("\n")
|
||||
if content:
|
||||
content += "\n"
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
delete=False,
|
||||
dir=path.parent,
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
suffix=".env.tmp",
|
||||
) as handle:
|
||||
temp_path = Path(handle.name)
|
||||
handle.write(content)
|
||||
temp_path.replace(path)
|
||||
except OSError as exc:
|
||||
raise AppError(
|
||||
"Runtime settings file is not writable.",
|
||||
category=ErrorCategory.INFRA_PERSISTENT,
|
||||
suggestion="Verify file path and write permissions, then retry.",
|
||||
detail=f"Failed writing runtime env file {path}: {type(exc).__name__}: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
def _escape_json_string(value: str) -> str:
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.errors import AppError
|
||||
from transcription.ui import runtime_settings_store
|
||||
from transcription.ui.runtime_settings_store import HIDDEN_SETTINGS_CATEGORIES
|
||||
from transcription.ui.runtime_settings_store import RUNTIME_SETTINGS_CATALOG
|
||||
from transcription.ui.runtime_settings_store import SETTINGS_UI_EXCLUDED_FIELDS
|
||||
@@ -35,6 +36,12 @@ def test_hidden_settings_categories_list_secret_and_high_risk_env_keys():
|
||||
assert "DATABASE__PASSWORD" in hidden_keys
|
||||
assert "DATABASE__DRIVER" in hidden_keys
|
||||
assert "DATABASE__HOST" in hidden_keys
|
||||
assert "POSTGRES_DB" in hidden_keys
|
||||
assert "POSTGRES_USER" in hidden_keys
|
||||
assert "POSTGRES_PASSWORD" in hidden_keys
|
||||
assert "CLOUDFLARE_TUNNEL_TOKEN" in hidden_keys
|
||||
assert "BACKUP_DIR" in hidden_keys
|
||||
assert "SYNOLOGY_BACKUP_DIR" in hidden_keys
|
||||
|
||||
|
||||
def test_runtime_settings_snapshot_includes_catalog_fields(tmp_path: Path):
|
||||
@@ -98,3 +105,31 @@ def test_runtime_settings_uses_override_env_file(tmp_path: Path, monkeypatch):
|
||||
|
||||
assert snapshot.env_file_path == target
|
||||
assert "PORT=9001" in target.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_save_runtime_settings_surfaces_unreadable_target(tmp_path: Path, monkeypatch):
|
||||
settings = _settings_for_runtime_editing(tmp_path)
|
||||
env_path = tmp_path / ".env"
|
||||
|
||||
def _raise_read_error(_self, encoding="utf-8"):
|
||||
_ = encoding
|
||||
raise OSError("denied")
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", _raise_read_error)
|
||||
with pytest.raises(AppError) as exc:
|
||||
save_runtime_settings(settings=settings, updates={"port": "9001"}, env_file_path=env_path)
|
||||
assert exc.value.message == "Runtime settings file is unreadable."
|
||||
|
||||
|
||||
def test_save_runtime_settings_surfaces_unwritable_target(tmp_path: Path, monkeypatch):
|
||||
settings = _settings_for_runtime_editing(tmp_path)
|
||||
env_path = tmp_path / ".env"
|
||||
|
||||
def _raise_write_error(*args, **kwargs):
|
||||
_ = args, kwargs
|
||||
raise OSError("denied")
|
||||
|
||||
monkeypatch.setattr(runtime_settings_store, "_write_env_lines_atomic", _raise_write_error)
|
||||
with pytest.raises(AppError) as exc:
|
||||
save_runtime_settings(settings=settings, updates={"port": "9001"}, env_file_path=env_path)
|
||||
assert exc.value.message == "Runtime settings file is not writable."
|
||||
|
||||
Reference in New Issue
Block a user