feat(translation): Post translation columns + settings + migration (#143 step 1)
Post gains post_title_translated / description_translated / translated_source_lang / translation_engine_version / translated_at — filled by the translate sweep so viewing is instant. ImportSettings gains translation_enabled (OFF by default), interpreter_base_url (EMPTY — no default host; the operator points it at their own Interpreter proxy behind a reverse proxy) and translation_target_lang (en), exposed + validated via /settings/import. Migration 0083. Settings defaults + patch + validation test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
"""post-text translation via Interpreter (milestone 143) — Post columns + settings
|
||||||
|
|
||||||
|
Post gains the translated title/description + the detected source language,
|
||||||
|
Interpreter engine_version (cache key), and translated_at — filled by the
|
||||||
|
translate sweep. ImportSettings gains translation_enabled (OFF by default),
|
||||||
|
interpreter_base_url (EMPTY — the operator sets their own, behind a reverse
|
||||||
|
proxy), and translation_target_lang (en).
|
||||||
|
|
||||||
|
Revision ID: 0083
|
||||||
|
Revises: 0082
|
||||||
|
Create Date: 2026-07-07
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0083"
|
||||||
|
down_revision: Union[str, None] = "0082"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"post", sa.Column("post_title_translated", sa.Text(), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"post", sa.Column("description_translated", sa.Text(), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"post",
|
||||||
|
sa.Column("translated_source_lang", sa.String(8), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"post",
|
||||||
|
sa.Column("translation_engine_version", sa.String(128), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"post",
|
||||||
|
sa.Column("translated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"import_settings",
|
||||||
|
sa.Column(
|
||||||
|
"translation_enabled", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"import_settings",
|
||||||
|
sa.Column(
|
||||||
|
"interpreter_base_url", sa.Text(), nullable=False, server_default="",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"import_settings",
|
||||||
|
sa.Column(
|
||||||
|
"translation_target_lang", sa.Text(), nullable=False,
|
||||||
|
server_default="en",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("import_settings", "translation_target_lang")
|
||||||
|
op.drop_column("import_settings", "interpreter_base_url")
|
||||||
|
op.drop_column("import_settings", "translation_enabled")
|
||||||
|
op.drop_column("post", "translated_at")
|
||||||
|
op.drop_column("post", "translation_engine_version")
|
||||||
|
op.drop_column("post", "translated_source_lang")
|
||||||
|
op.drop_column("post", "description_translated")
|
||||||
|
op.drop_column("post", "post_title_translated")
|
||||||
@@ -32,6 +32,9 @@ _EDITABLE_FIELDS = (
|
|||||||
"extdl_mediafire_enabled",
|
"extdl_mediafire_enabled",
|
||||||
"extdl_dropbox_enabled",
|
"extdl_dropbox_enabled",
|
||||||
"extdl_pixeldrain_enabled",
|
"extdl_pixeldrain_enabled",
|
||||||
|
"translation_enabled",
|
||||||
|
"interpreter_base_url",
|
||||||
|
"translation_target_lang",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Per-host external-download toggles — all plain booleans, validated uniformly.
|
# Per-host external-download toggles — all plain booleans, validated uniformly.
|
||||||
@@ -69,6 +72,9 @@ async def get_import_settings():
|
|||||||
"extdl_mediafire_enabled": row.extdl_mediafire_enabled,
|
"extdl_mediafire_enabled": row.extdl_mediafire_enabled,
|
||||||
"extdl_dropbox_enabled": row.extdl_dropbox_enabled,
|
"extdl_dropbox_enabled": row.extdl_dropbox_enabled,
|
||||||
"extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled,
|
"extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled,
|
||||||
|
"translation_enabled": row.translation_enabled,
|
||||||
|
"interpreter_base_url": row.interpreter_base_url,
|
||||||
|
"translation_target_lang": row.translation_target_lang,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +134,15 @@ async def update_import_settings():
|
|||||||
for tog in _EXTDL_TOGGLE_FIELDS:
|
for tog in _EXTDL_TOGGLE_FIELDS:
|
||||||
if tog in body and not isinstance(body[tog], bool):
|
if tog in body and not isinstance(body[tog], bool):
|
||||||
return jsonify({"error": f"{tog} must be a boolean"}), 400
|
return jsonify({"error": f"{tog} must be a boolean"}), 400
|
||||||
|
# Translation (#143): base URL may be empty (feature off until set — no
|
||||||
|
# default host; the operator points it at their own Interpreter proxy).
|
||||||
|
if "translation_enabled" in body and not isinstance(
|
||||||
|
body["translation_enabled"], bool
|
||||||
|
):
|
||||||
|
return jsonify({"error": "translation_enabled must be a boolean"}), 400
|
||||||
|
for key in ("interpreter_base_url", "translation_target_lang"):
|
||||||
|
if key in body and not isinstance(body[key], str):
|
||||||
|
return jsonify({"error": f"{key} must be a string"}), 400
|
||||||
if "series_suggest_threshold" in body:
|
if "series_suggest_threshold" in body:
|
||||||
v = body["series_suggest_threshold"]
|
v = body["series_suggest_threshold"]
|
||||||
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
|
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
|
||||||
|
|||||||
@@ -92,6 +92,21 @@ class ImportSettings(Base):
|
|||||||
Boolean, nullable=False, default=True, server_default="true",
|
Boolean, nullable=False, default=True, server_default="true",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# -- Post-text translation via the Interpreter LAN service (milestone 143).
|
||||||
|
# Off by default with NO default host — it needs a reachable Interpreter
|
||||||
|
# service (the operator's, behind a reverse proxy), which not every install
|
||||||
|
# has; the operator sets the URL and flips it on. Empty base_url OR disabled
|
||||||
|
# → the translate sweep no-ops.
|
||||||
|
translation_enabled: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="false",
|
||||||
|
)
|
||||||
|
interpreter_base_url: Mapped[str] = mapped_column(
|
||||||
|
Text, nullable=False, default="", server_default="",
|
||||||
|
)
|
||||||
|
translation_target_lang: Mapped[str] = mapped_column(
|
||||||
|
Text, nullable=False, default="en", server_default="en",
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def load(cls, session) -> ImportSettings:
|
async def load(cls, session) -> ImportSettings:
|
||||||
"""The singleton settings row (id=1), via an async session."""
|
"""The singleton settings row (id=1), via an async session."""
|
||||||
|
|||||||
@@ -47,6 +47,24 @@ class Post(Base):
|
|||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
attachment_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
attachment_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# -- Post-text translation (milestone 143). Filled by the translate_posts
|
||||||
|
# sweep via the Interpreter LAN service so viewing is instant.
|
||||||
|
# translated_source_lang is the DETECTED original language; "en" (or a
|
||||||
|
# passthrough) means nothing to translate and the *_translated columns stay
|
||||||
|
# NULL. engine_version keys the Interpreter cache — re-runs are ~1ms and a
|
||||||
|
# model upgrade re-translates instead of serving stale.
|
||||||
|
post_title_translated: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
description_translated: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
translated_source_lang: Mapped[str | None] = mapped_column(
|
||||||
|
String(8), nullable=True
|
||||||
|
)
|
||||||
|
translation_engine_version: Mapped[str | None] = mapped_column(
|
||||||
|
String(128), nullable=True
|
||||||
|
)
|
||||||
|
translated_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
downloaded_at: Mapped[datetime] = mapped_column(
|
downloaded_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,6 +28,31 @@ async def test_patch_import_settings(client):
|
|||||||
assert body["transparency_threshold"] == 0.7
|
assert body["transparency_threshold"] == 0.7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_translation_settings_defaults_and_patch(client):
|
||||||
|
# #143: translation is OFF by default with NO default host — the operator
|
||||||
|
# points it at their own Interpreter proxy and enables it.
|
||||||
|
body = await (await client.get("/api/settings/import")).get_json()
|
||||||
|
assert body["translation_enabled"] is False
|
||||||
|
assert body["interpreter_base_url"] == ""
|
||||||
|
assert body["translation_target_lang"] == "en"
|
||||||
|
|
||||||
|
ok = await client.patch("/api/settings/import", json={
|
||||||
|
"translation_enabled": True,
|
||||||
|
"interpreter_base_url": "http://interpreter.lan",
|
||||||
|
})
|
||||||
|
assert ok.status_code == 200
|
||||||
|
out = await ok.get_json()
|
||||||
|
assert out["translation_enabled"] is True
|
||||||
|
assert out["interpreter_base_url"] == "http://interpreter.lan"
|
||||||
|
|
||||||
|
# Type validation.
|
||||||
|
assert (await client.patch(
|
||||||
|
"/api/settings/import", json={"translation_enabled": "yes"})).status_code == 400
|
||||||
|
assert (await client.patch(
|
||||||
|
"/api/settings/import", json={"interpreter_base_url": 123})).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_patch_rejects_non_object(client):
|
async def test_patch_rejects_non_object(client):
|
||||||
resp = await client.patch("/api/settings/import", json=[1, 2, 3])
|
resp = await client.patch("/api/settings/import", json=[1, 2, 3])
|
||||||
|
|||||||
Reference in New Issue
Block a user