generated from john/python-template
Add ability to edit safe .env.example options to Settings
Quality Gate / gate (push) Failing after 47s
Quality Gate / gate (push) Failing after 47s
This commit is contained in:
@@ -44,6 +44,10 @@ Objective: improve research value with person-centric outputs.
|
|||||||
|
|
||||||
## V6.2 - Access Control and Multi-User Readiness
|
## V6.2 - Access Control and Multi-User Readiness
|
||||||
|
|
||||||
|
[ *More thoughts on user accounts:*
|
||||||
|
* *Create a generic "view only" user that does not have the rights to alter any of the data*
|
||||||
|
* *Limit user accounts access to data by Tag. I have distant family members that I would want to share the transcribed data with, but they would only be interested in a subset of it. For example my Cochran cousins would have no interest in Lancaster documents, so limit the Cochra Clan cousins to view-only access to documents tagged "cochran clan"* ]
|
||||||
|
|
||||||
Objective: prepare for managed collaboration beyond single-user operation.
|
Objective: prepare for managed collaboration beyond single-user operation.
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|||||||
@@ -2,35 +2,41 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Settings manages installation-local registries and editable text assets from one route.
|
Settings manages installation-local registries, safe runtime .env settings, and editable text assets from one route.
|
||||||
|
|
||||||
## Route
|
## Route
|
||||||
|
|
||||||
| Route | Purpose |
|
| Route | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/settings` | Manage Document Types, Person Roles, Tags, Prompts, and Home Page Text. |
|
| `/settings` | Manage Runtime Settings, Document Types, Person Roles, Tags, Prompts, and Home Page Text. |
|
||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- The page title is **Settings**.
|
- The page title is **Settings**.
|
||||||
- Configuration surfaces are grouped as tabs:
|
- Configuration surfaces are grouped as tabs:
|
||||||
|
- **Runtime Settings**
|
||||||
- **Document Types**
|
- **Document Types**
|
||||||
- **Person Roles**
|
- **Person Roles**
|
||||||
- **Tags**
|
- **Tags**
|
||||||
- **Prompts**
|
- **Prompts**
|
||||||
- **Home Page Text**
|
- **Home Page Text**
|
||||||
|
- Runtime Settings exposes an allowlisted set of non-secret fields synchronized with `Settings` model fields except excluded secret/unsafe fields.
|
||||||
|
- Runtime Settings persists changes to `.env`, validates by constructing a `Settings` instance, and reports validation failures through the shared UI error presenter.
|
||||||
|
- Runtime Settings changes require application restart to take effect.
|
||||||
- Document Types, Person Roles, and Tags support Add/Edit/Delete with existing guardrails.
|
- 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.
|
- Prompts exposes only `transcribe_document.md` for editing and restore-from-backup.
|
||||||
- Home Page Text edits the same Markdown content rendered on `/homepage`.
|
- Home Page Text edits the same Markdown content rendered on `/homepage`.
|
||||||
|
|
||||||
## Acceptance Checklist
|
## Acceptance Checklist
|
||||||
|
|
||||||
- `/ui/settings` renders all five tabs.
|
- `/ui/settings` renders all six tabs.
|
||||||
- Registry and prompt workflows keep existing validation and error handling.
|
- Registry and prompt workflows keep existing validation and error handling.
|
||||||
|
- Runtime Settings excludes secret fields and rejects invalid values.
|
||||||
- Saving Home Page Text persists content for the homepage view.
|
- Saving Home Page Text persists content for the homepage view.
|
||||||
|
|
||||||
## Implementation Anchors
|
## Implementation Anchors
|
||||||
|
|
||||||
- `src/transcription/ui/pages/settings_page.py`
|
- `src/transcription/ui/pages/settings_page.py`
|
||||||
|
- `src/transcription/ui/runtime_settings_store.py`
|
||||||
- `src/transcription/ui/homepage_store.py`
|
- `src/transcription/ui/homepage_store.py`
|
||||||
- `tests/ui/test_pages_registration.py`
|
- `tests/ui/test_pages_registration.py`
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from transcription.ui.components.primitives import section_header_row
|
|||||||
from transcription.ui.components.table.registry import render_registry_table
|
from transcription.ui.components.table.registry import render_registry_table
|
||||||
from transcription.ui.homepage_store import read_homepage_markdown
|
from transcription.ui.homepage_store import read_homepage_markdown
|
||||||
from transcription.ui.homepage_store import save_homepage_markdown
|
from transcription.ui.homepage_store import save_homepage_markdown
|
||||||
|
from transcription.ui.runtime_settings_store import read_runtime_settings_snapshot
|
||||||
|
from transcription.ui.runtime_settings_store import save_runtime_settings
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
@@ -497,14 +499,78 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
with ui.row().classes("items-center gap-2"):
|
with ui.row().classes("items-center gap-2"):
|
||||||
ui.button("Save home text", icon="save", on_click=save_home_text).classes("ui-btn-primary")
|
ui.button("Save home text", icon="save", on_click=save_home_text).classes("ui-btn-primary")
|
||||||
|
|
||||||
|
@ui.refreshable
|
||||||
|
async def render_runtime_settings() -> None:
|
||||||
|
with archival_card("Runtime Settings"):
|
||||||
|
ui.label("Edit non-secret .env settings. Secret fields are intentionally excluded.").classes(
|
||||||
|
"text-xs ui-text-muted"
|
||||||
|
)
|
||||||
|
ui.label("Changes are persisted to .env and apply after restart.").classes(
|
||||||
|
"text-xs ui-text-muted mb-3"
|
||||||
|
)
|
||||||
|
snapshot_outcome = await run_ui_action(
|
||||||
|
operation="settings.runtime.read",
|
||||||
|
title="Runtime settings unavailable",
|
||||||
|
action=lambda: _read_runtime_settings_snapshot(settings),
|
||||||
|
)
|
||||||
|
if not snapshot_outcome.ok or snapshot_outcome.value is None:
|
||||||
|
return
|
||||||
|
snapshot = snapshot_outcome.value
|
||||||
|
field_controls: dict[str, Any] = {}
|
||||||
|
for field in snapshot.fields:
|
||||||
|
with ui.column().classes("w-full gap-1 py-2 ui-header-divider"):
|
||||||
|
ui.label(field.label).classes("text-sm font-semibold")
|
||||||
|
ui.label(field.description).classes("text-xs ui-text-muted")
|
||||||
|
if field.control == "bool":
|
||||||
|
control = ui.checkbox(field.env_key, value=bool(field.value))
|
||||||
|
elif field.control == "select":
|
||||||
|
control = (
|
||||||
|
ui.select(
|
||||||
|
list(field.options),
|
||||||
|
label=field.env_key,
|
||||||
|
value=str(field.value),
|
||||||
|
)
|
||||||
|
.props("outlined")
|
||||||
|
.classes("w-full")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
control = (
|
||||||
|
ui.input(field.env_key, value=str(field.value)).props("outlined").classes("w-full")
|
||||||
|
)
|
||||||
|
field_controls[field.field_name] = control
|
||||||
|
|
||||||
|
async def save_runtime() -> None:
|
||||||
|
updates: dict[str, str | bool] = {}
|
||||||
|
for field in snapshot.fields:
|
||||||
|
control = field_controls[field.field_name]
|
||||||
|
if field.control == "bool":
|
||||||
|
updates[field.field_name] = bool(control.value)
|
||||||
|
else:
|
||||||
|
updates[field.field_name] = str(control.value or "")
|
||||||
|
save_outcome = await run_ui_action(
|
||||||
|
operation="settings.runtime.write",
|
||||||
|
title="Runtime settings save failed",
|
||||||
|
action=lambda: _write_runtime_settings(settings=settings, updates=updates),
|
||||||
|
)
|
||||||
|
if not save_outcome.ok:
|
||||||
|
return
|
||||||
|
ui.notify("Runtime settings saved to .env", type="positive")
|
||||||
|
render_runtime_settings.refresh()
|
||||||
|
|
||||||
|
with ui.row().classes("items-center gap-2 mt-3"):
|
||||||
|
ui.button("Save runtime settings", icon="save", on_click=save_runtime).classes("ui-btn-primary")
|
||||||
|
|
||||||
with ui.tabs().classes("w-full") as tabs:
|
with ui.tabs().classes("w-full") as tabs:
|
||||||
|
runtime_settings_tab = ui.tab("Runtime Settings")
|
||||||
document_types_tab = ui.tab("Document Types")
|
document_types_tab = ui.tab("Document Types")
|
||||||
person_roles_tab = ui.tab("Person Roles")
|
person_roles_tab = ui.tab("Person Roles")
|
||||||
tags_tab = ui.tab("Tags")
|
tags_tab = ui.tab("Tags")
|
||||||
prompts_tab = ui.tab("Prompts")
|
prompts_tab = ui.tab("Prompts")
|
||||||
home_page_text_tab = ui.tab("Home Page Text")
|
home_page_text_tab = ui.tab("Home Page Text")
|
||||||
|
|
||||||
with ui.tab_panels(tabs, value=document_types_tab).classes("w-full"):
|
with ui.tab_panels(tabs, value=runtime_settings_tab).classes("w-full"):
|
||||||
|
with ui.tab_panel(runtime_settings_tab):
|
||||||
|
await render_runtime_settings()
|
||||||
with ui.tab_panel(document_types_tab):
|
with ui.tab_panel(document_types_tab):
|
||||||
await render_document_types()
|
await render_document_types()
|
||||||
with ui.tab_panel(person_roles_tab):
|
with ui.tab_panel(person_roles_tab):
|
||||||
@@ -546,3 +612,11 @@ async def _read_home_page_text(settings: Settings) -> str:
|
|||||||
|
|
||||||
async def _write_home_page_text(settings: Settings, markdown_text: str) -> None:
|
async def _write_home_page_text(settings: Settings, markdown_text: str) -> None:
|
||||||
await run_blocking(save_homepage_markdown, markdown_text, settings=settings)
|
await run_blocking(save_homepage_markdown, markdown_text, settings=settings)
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_runtime_settings_snapshot(settings: Settings):
|
||||||
|
return await run_blocking(read_runtime_settings_snapshot, settings=settings)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_runtime_settings(*, settings: Settings, updates: dict[str, str | bool]):
|
||||||
|
return await run_blocking(save_runtime_settings, settings=settings, updates=updates)
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
"""Safe runtime settings catalog and .env persistence helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from transcription.config import Provider
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.errors import AppError
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
|
||||||
|
FieldControl = Literal["text", "bool", "select"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RuntimeSettingField:
|
||||||
|
"""UI metadata and value for a safe, editable runtime setting."""
|
||||||
|
|
||||||
|
field_name: str
|
||||||
|
env_key: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
control: FieldControl
|
||||||
|
value: str | bool
|
||||||
|
options: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RuntimeSettingsSnapshot:
|
||||||
|
"""Typed snapshot of runtime settings editable in UI."""
|
||||||
|
|
||||||
|
env_file_path: Path
|
||||||
|
fields: tuple[RuntimeSettingField, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RuntimeSettingDescriptor:
|
||||||
|
field_name: str
|
||||||
|
env_key: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
control: FieldControl
|
||||||
|
options: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS_UI_EXCLUDED_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"openrouter_api_key",
|
||||||
|
"database",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
RUNTIME_SETTINGS_CATALOG: tuple[RuntimeSettingDescriptor, ...] = (
|
||||||
|
RuntimeSettingDescriptor("host", "HOST", "Host", "Server bind host.", "text"),
|
||||||
|
RuntimeSettingDescriptor("port", "PORT", "Port", "Server bind port.", "text"),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"log_level",
|
||||||
|
"LOG_LEVEL",
|
||||||
|
"Log level",
|
||||||
|
"Application log verbosity.",
|
||||||
|
"select",
|
||||||
|
options=("critical", "error", "warning", "info", "debug", "trace"),
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor("reload", "RELOAD", "Reload", "Auto-reload on code changes.", "bool"),
|
||||||
|
RuntimeSettingDescriptor("log_dir", "LOG_DIR", "Log directory", "Directory for rotating log files.", "text"),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"log_file_name",
|
||||||
|
"LOG_FILE_NAME",
|
||||||
|
"Log file name",
|
||||||
|
"Active rotating log filename.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"log_file_max_bytes",
|
||||||
|
"LOG_FILE_MAX_BYTES",
|
||||||
|
"Log file max bytes",
|
||||||
|
"Maximum bytes per log file before rotation.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"log_file_backup_count",
|
||||||
|
"LOG_FILE_BACKUP_COUNT",
|
||||||
|
"Log file backup count",
|
||||||
|
"Number of rotated log files to keep.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"provider",
|
||||||
|
"PROVIDER",
|
||||||
|
"Provider",
|
||||||
|
"Transcription provider key.",
|
||||||
|
"select",
|
||||||
|
options=tuple(member.value for member in Provider),
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"provider_model",
|
||||||
|
"PROVIDER_MODEL",
|
||||||
|
"Provider model",
|
||||||
|
"Default model id used for transcription jobs.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"provider_models",
|
||||||
|
"PROVIDER_MODELS",
|
||||||
|
"Provider models (JSON array)",
|
||||||
|
"Allowed model ids as a JSON array.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"openrouter_http_referer",
|
||||||
|
"OPENROUTER_HTTP_REFERER",
|
||||||
|
"OpenRouter HTTP referer",
|
||||||
|
"Optional header value sent to OpenRouter.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"openrouter_app_title",
|
||||||
|
"OPENROUTER_APP_TITLE",
|
||||||
|
"OpenRouter app title",
|
||||||
|
"Optional app title sent to OpenRouter.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"default_prompt_name",
|
||||||
|
"DEFAULT_PROMPT_NAME",
|
||||||
|
"Default prompt file",
|
||||||
|
"Prompt filename used for new transcription requests.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"transcription_temperature",
|
||||||
|
"TRANSCRIPTION_TEMPERATURE",
|
||||||
|
"Temperature",
|
||||||
|
"Optional sampling temperature (0.0 to 2.0).",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"transcription_top_p",
|
||||||
|
"TRANSCRIPTION_TOP_P",
|
||||||
|
"Top P",
|
||||||
|
"Optional nucleus sampling probability (0.0 to 1.0).",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"environment",
|
||||||
|
"ENVIRONMENT",
|
||||||
|
"Environment",
|
||||||
|
"Runtime environment mode.",
|
||||||
|
"select",
|
||||||
|
options=("development", "test", "production"),
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"transcription_commit",
|
||||||
|
"TRANSCRIPTION_COMMIT",
|
||||||
|
"Transcription commit",
|
||||||
|
"Optional build or commit identifier for evidence provenance.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"bootstrap_schema_on_startup",
|
||||||
|
"BOOTSTRAP_SCHEMA_ON_STARTUP",
|
||||||
|
"Bootstrap schema on startup",
|
||||||
|
"Create schema on startup when enabled.",
|
||||||
|
"bool",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"sqlite_check_same_thread",
|
||||||
|
"SQLITE_CHECK_SAME_THREAD",
|
||||||
|
"SQLite check same thread",
|
||||||
|
"SQLite engine same-thread flag.",
|
||||||
|
"bool",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"upload_dir",
|
||||||
|
"UPLOAD_DIR",
|
||||||
|
"Upload directory",
|
||||||
|
"Root directory for uploaded media and generated files.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"prompt_dir",
|
||||||
|
"PROMPT_DIR",
|
||||||
|
"Prompt directory",
|
||||||
|
"Directory containing editable markdown prompts.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"database_backup_dir",
|
||||||
|
"DATABASE_BACKUP_DIR",
|
||||||
|
"Database backup directory",
|
||||||
|
"Directory for generated database backups.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_max_retries",
|
||||||
|
"WORKER_MAX_RETRIES",
|
||||||
|
"Worker max retries",
|
||||||
|
"Maximum retries per source attempt.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_provider_timeout_seconds",
|
||||||
|
"WORKER_PROVIDER_TIMEOUT_SECONDS",
|
||||||
|
"Worker provider timeout seconds",
|
||||||
|
"Provider call timeout budget in seconds.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_stale_job_seconds",
|
||||||
|
"WORKER_STALE_JOB_SECONDS",
|
||||||
|
"Worker stale job seconds",
|
||||||
|
"Seconds before queued jobs are considered stale.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_retry_backoff_seconds",
|
||||||
|
"WORKER_RETRY_BACKOFF_SECONDS",
|
||||||
|
"Worker retry backoff seconds",
|
||||||
|
"Base backoff delay between retries.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_shutdown_grace_seconds",
|
||||||
|
"WORKER_SHUTDOWN_GRACE_SECONDS",
|
||||||
|
"Worker shutdown grace seconds",
|
||||||
|
"Grace period before forced worker shutdown.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_poll_interval_seconds",
|
||||||
|
"WORKER_POLL_INTERVAL_SECONDS",
|
||||||
|
"Worker poll interval seconds",
|
||||||
|
"Worker polling interval for queued jobs.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_min_transcription_chars",
|
||||||
|
"WORKER_MIN_TRANSCRIPTION_CHARS",
|
||||||
|
"Worker min transcription chars",
|
||||||
|
"Minimum character threshold for successful transcription.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_min_transcription_lines",
|
||||||
|
"WORKER_MIN_TRANSCRIPTION_LINES",
|
||||||
|
"Worker min transcription lines",
|
||||||
|
"Minimum line threshold for successful transcription.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_fail_on_finish_reason_length",
|
||||||
|
"WORKER_FAIL_ON_FINISH_REASON_LENGTH",
|
||||||
|
"Fail on finish reason length",
|
||||||
|
"Treat provider finish_reason=length as failure when enabled.",
|
||||||
|
"bool",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def settings_catalog_field_names() -> frozenset[str]:
|
||||||
|
return frozenset(item.field_name for item in RUNTIME_SETTINGS_CATALOG)
|
||||||
|
|
||||||
|
|
||||||
|
def read_runtime_settings_snapshot(*, settings: Settings, env_file_path: Path | None = None) -> RuntimeSettingsSnapshot:
|
||||||
|
resolved_env_path = _resolve_env_file_path(settings=settings, env_file_path=env_file_path)
|
||||||
|
fields: list[RuntimeSettingField] = []
|
||||||
|
for descriptor in RUNTIME_SETTINGS_CATALOG:
|
||||||
|
value = getattr(settings, descriptor.field_name)
|
||||||
|
fields.append(
|
||||||
|
RuntimeSettingField(
|
||||||
|
field_name=descriptor.field_name,
|
||||||
|
env_key=descriptor.env_key,
|
||||||
|
label=descriptor.label,
|
||||||
|
description=descriptor.description,
|
||||||
|
control=descriptor.control,
|
||||||
|
value=_display_value(value),
|
||||||
|
options=descriptor.options,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return RuntimeSettingsSnapshot(
|
||||||
|
env_file_path=resolved_env_path,
|
||||||
|
fields=tuple(fields),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_runtime_settings(
|
||||||
|
*,
|
||||||
|
settings: Settings,
|
||||||
|
updates: dict[str, str | bool],
|
||||||
|
env_file_path: Path | None = None,
|
||||||
|
) -> RuntimeSettingsSnapshot:
|
||||||
|
descriptors = {item.field_name: item for item in RUNTIME_SETTINGS_CATALOG}
|
||||||
|
unknown_keys = sorted(set(updates) - set(descriptors))
|
||||||
|
if unknown_keys:
|
||||||
|
raise AppError(
|
||||||
|
"One or more settings fields cannot be edited from this page.",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Refresh the page and submit only editable fields.",
|
||||||
|
detail=f"Unknown settings keys: {unknown_keys}",
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_env_path = _resolve_env_file_path(settings=settings, env_file_path=env_file_path)
|
||||||
|
original_lines = _read_env_lines(resolved_env_path)
|
||||||
|
env_map = _parse_env_map(original_lines)
|
||||||
|
mutable_lines = list(original_lines)
|
||||||
|
|
||||||
|
for field_name, raw_value in updates.items():
|
||||||
|
descriptor = descriptors[field_name]
|
||||||
|
encoded = _encode_field_value(raw_value)
|
||||||
|
if encoded == "":
|
||||||
|
_delete_env_key(mutable_lines, descriptor.env_key)
|
||||||
|
env_map.pop(descriptor.env_key, None)
|
||||||
|
continue
|
||||||
|
_upsert_env_key(mutable_lines, descriptor.env_key, encoded)
|
||||||
|
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)
|
||||||
|
refreshed = Settings(_env_file=resolved_env_path, _cli_parse_args=False)
|
||||||
|
return read_runtime_settings_snapshot(settings=refreshed, env_file_path=resolved_env_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_env_file_path(*, settings: Settings, env_file_path: Path | None) -> Path:
|
||||||
|
if env_file_path is not None:
|
||||||
|
return env_file_path
|
||||||
|
|
||||||
|
configured = settings.model_config.get("env_file")
|
||||||
|
if configured is None:
|
||||||
|
return Path(".env")
|
||||||
|
if isinstance(configured, Path):
|
||||||
|
return Path(configured)
|
||||||
|
if isinstance(configured, str):
|
||||||
|
return Path(configured)
|
||||||
|
if isinstance(configured, (list, tuple)) and configured:
|
||||||
|
first = configured[0]
|
||||||
|
if isinstance(first, Path):
|
||||||
|
return Path(first)
|
||||||
|
if isinstance(first, str):
|
||||||
|
return Path(first)
|
||||||
|
return Path(".env")
|
||||||
|
|
||||||
|
|
||||||
|
def _display_value(value: object) -> str | bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
values = [str(item) for item in value]
|
||||||
|
escaped = ",".join(f'"{_escape_json_string(item)}"' for item in values)
|
||||||
|
return f"[{escaped}]"
|
||||||
|
if isinstance(value, Path):
|
||||||
|
return value.as_posix()
|
||||||
|
if isinstance(value, Provider):
|
||||||
|
return value.value
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_field_value(raw_value: str | bool) -> str:
|
||||||
|
if isinstance(raw_value, bool):
|
||||||
|
return "true" if raw_value else "false"
|
||||||
|
text = str(raw_value).strip()
|
||||||
|
if text == "":
|
||||||
|
return ""
|
||||||
|
if text.startswith("[") and text.endswith("]"):
|
||||||
|
return text
|
||||||
|
if " " in text or "#" in text:
|
||||||
|
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
return f'"{escaped}"'
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_candidate_env(*, env_map: dict[str, str], env_file_path: Path) -> None:
|
||||||
|
serialized = "\n".join(f"{key}={value}" for key, value in env_map.items()) + "\n"
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf-8", newline="\n", suffix=".env") as handle:
|
||||||
|
temp_path = Path(handle.name)
|
||||||
|
handle.write(serialized)
|
||||||
|
try:
|
||||||
|
Settings(_env_file=temp_path, _cli_parse_args=False)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise AppError(
|
||||||
|
"One or more settings values are invalid.",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Review the highlighted values and use the same formats shown in .env.example.",
|
||||||
|
detail=f"Invalid runtime settings for {env_file_path}: {exc}",
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_env_lines(path: Path) -> list[str]:
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
return path.read_text(encoding="utf-8").splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_env_map(lines: list[str]) -> dict[str, str]:
|
||||||
|
env_map: dict[str, str] = {}
|
||||||
|
for line in lines:
|
||||||
|
parsed = _parse_env_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
key, value = parsed
|
||||||
|
env_map[key] = value
|
||||||
|
return env_map
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_env_line(line: str) -> tuple[str, str] | None:
|
||||||
|
if not line or line.lstrip().startswith("#"):
|
||||||
|
return None
|
||||||
|
match = re.match(r"^\s*([A-Za-z_][A-Za-z0-9_]*(?:__[A-Za-z0-9_]+)*)\s*=\s*(.*)$", line)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return match.group(1), match.group(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_env_key(lines: list[str], key: str) -> None:
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
parsed = _parse_env_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
current_key, _ = parsed
|
||||||
|
if current_key == key:
|
||||||
|
del lines[index]
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_env_key(lines: list[str], key: str, value: str) -> None:
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
parsed = _parse_env_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
current_key, _ = parsed
|
||||||
|
if current_key == key:
|
||||||
|
lines[index] = f"{key}={value}"
|
||||||
|
return
|
||||||
|
lines.append(f"{key}={value}")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_json_string(value: str) -> str:
|
||||||
|
return value.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
@@ -26,6 +26,7 @@ class TestPageRegistration:
|
|||||||
assert jobs_response.status_code == 200
|
assert jobs_response.status_code == 200
|
||||||
assert tags_response.status_code == 200
|
assert tags_response.status_code == 200
|
||||||
assert settings_response.status_code == 200
|
assert settings_response.status_code == 200
|
||||||
|
assert "Runtime Settings" in settings_response.text
|
||||||
assert "Document Types" in settings_response.text
|
assert "Document Types" in settings_response.text
|
||||||
assert "Person Roles" in settings_response.text
|
assert "Person Roles" in settings_response.text
|
||||||
assert "Tags" in settings_response.text
|
assert "Tags" in settings_response.text
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Tests for safe runtime settings .env editing helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.errors import AppError
|
||||||
|
from transcription.ui.runtime_settings_store import RUNTIME_SETTINGS_CATALOG
|
||||||
|
from transcription.ui.runtime_settings_store import SETTINGS_UI_EXCLUDED_FIELDS
|
||||||
|
from transcription.ui.runtime_settings_store import read_runtime_settings_snapshot
|
||||||
|
from transcription.ui.runtime_settings_store import save_runtime_settings
|
||||||
|
from transcription.ui.runtime_settings_store import settings_catalog_field_names
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_for_runtime_editing(tmp_path: Path) -> Settings:
|
||||||
|
env_path = tmp_path / ".env"
|
||||||
|
env_path.write_text("OPENROUTER_API_KEY=test-key\n", encoding="utf-8")
|
||||||
|
return Settings(_env_file=env_path, _cli_parse_args=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_settings_catalog_classifies_all_non_secret_settings_fields():
|
||||||
|
available_fields = set(Settings.model_fields)
|
||||||
|
expected = available_fields - set(SETTINGS_UI_EXCLUDED_FIELDS)
|
||||||
|
assert settings_catalog_field_names() == expected
|
||||||
|
assert "openrouter_api_key" not in settings_catalog_field_names()
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_settings_snapshot_includes_catalog_fields(tmp_path: Path):
|
||||||
|
settings = _settings_for_runtime_editing(tmp_path)
|
||||||
|
|
||||||
|
snapshot = read_runtime_settings_snapshot(settings=settings, env_file_path=tmp_path / ".env")
|
||||||
|
|
||||||
|
assert snapshot.env_file_path == tmp_path / ".env"
|
||||||
|
assert len(snapshot.fields) == len(RUNTIME_SETTINGS_CATALOG)
|
||||||
|
assert any(field.field_name == "port" for field in snapshot.fields)
|
||||||
|
assert any(field.field_name == "provider" for field in snapshot.fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_runtime_settings_writes_allowed_updates(tmp_path: Path):
|
||||||
|
settings = _settings_for_runtime_editing(tmp_path)
|
||||||
|
env_path = tmp_path / ".env"
|
||||||
|
env_path.write_text(
|
||||||
|
"# sample env\nOPENROUTER_API_KEY=test-key\nPORT=8000\nLOG_LEVEL=info\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = save_runtime_settings(
|
||||||
|
settings=settings,
|
||||||
|
updates={
|
||||||
|
"port": "9001",
|
||||||
|
"log_level": "debug",
|
||||||
|
"reload": True,
|
||||||
|
},
|
||||||
|
env_file_path=env_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = env_path.read_text(encoding="utf-8")
|
||||||
|
assert "PORT=9001" in updated
|
||||||
|
assert "LOG_LEVEL=debug" in updated
|
||||||
|
assert "RELOAD=true" in updated
|
||||||
|
assert any(field.field_name == "port" and str(field.value) == "9001" for field in snapshot.fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_runtime_settings_rejects_invalid_values(tmp_path: Path):
|
||||||
|
settings = _settings_for_runtime_editing(tmp_path)
|
||||||
|
env_path = tmp_path / ".env"
|
||||||
|
|
||||||
|
with pytest.raises(AppError):
|
||||||
|
save_runtime_settings(
|
||||||
|
settings=settings,
|
||||||
|
updates={"port": "not-a-number"},
|
||||||
|
env_file_path=env_path,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user