release: bump version to 0.52.0

This commit is contained in:
linkong
2026-05-12 17:15:02 +08:00
parent b15d097b9c
commit b87cb310fd
70 changed files with 5589 additions and 2187 deletions

View File

@@ -81,6 +81,17 @@ DEFAULT_SETTINGS = {
"password_policy": "medium",
},
"tv": DEFAULT_TV_SETTINGS,
"smtp": {
"host": "",
"port": 587,
"username": "",
"password": "",
"from_address": "",
"from_name": "Planet",
"use_tls": False,
"use_starttls": True,
"timeout_seconds": 20,
},
"external_integrations": {
"ai_provider": {
"service_url": "",
@@ -95,6 +106,17 @@ DEFAULT_SETTINGS = {
"default_provider": "tavily",
"providers": {},
},
"ocr": {
"enabled": False,
"provider": "paddleocr",
"base_url": "",
"api_key": "",
"model": "",
"languages": ["zh", "en"],
"timeout_seconds": 30,
"max_file_size_mb": 20,
"output_format": "markdown",
},
},
}
@@ -153,6 +175,24 @@ class TVSettingsUpdate(BaseModel):
sources: list[TVStreamSourceUpdate] = Field(default_factory=list)
class SMTPSettingsUpdate(BaseModel):
host: str = Field(default="", max_length=255)
port: int = Field(default=587, ge=1, le=65535)
username: str = Field(default="", max_length=255)
password: Optional[str] = None
clear_password: bool = False
from_address: str = Field(default="", max_length=255)
from_name: str = Field(default="Planet", max_length=120)
use_tls: bool = False
use_starttls: bool = True
timeout_seconds: int = Field(default=20, ge=3, le=300)
class SMTPTestRequest(BaseModel):
to: EmailStr
settings: Optional[SMTPSettingsUpdate] = None
class AIProviderIntegrationUpdate(BaseModel):
service_url: str = ""
service_token: Optional[str] = None
@@ -198,10 +238,23 @@ class WebSearchIntegrationUpdate(BaseModel):
scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"])
class OCRIntegrationUpdate(BaseModel):
enabled: bool = False
provider: str = Field(default="paddleocr", max_length=80)
base_url: str = Field(default="", max_length=500)
api_key: Optional[str] = None
model: str = Field(default="", max_length=200)
languages: list[str] = Field(default_factory=lambda: ["zh", "en"])
timeout_seconds: int = Field(default=30, ge=3, le=300)
max_file_size_mb: int = Field(default=20, ge=1, le=200)
output_format: str = Field(default="markdown", pattern="^(markdown|text|json)$")
class ExternalIntegrationsUpdate(BaseModel):
ai_provider: AIProviderIntegrationUpdate
barentswatch: BarentsWatchIntegrationUpdate
web_search: WebSearchIntegrationUpdate | None = None
ocr: OCRIntegrationUpdate | None = None
def merge_with_defaults(category: str, payload: Optional[dict]) -> dict:
@@ -649,6 +702,59 @@ def _runtime_config_from_web_search_payload(web_search_payload: dict) -> WebSear
)
def _normalize_ocr_payload(ocr_payload: dict | None) -> dict:
raw = dict(ocr_payload or {})
languages = raw.get("languages")
if not isinstance(languages, list) or not languages:
languages = ["zh", "en"]
return {
"enabled": bool(raw.get("enabled", False)),
"provider": str(raw.get("provider") or "paddleocr").strip().lower() or "paddleocr",
"base_url": str(raw.get("base_url") or "").strip(),
"api_key": str(raw.get("api_key") or "").strip(),
"model": str(raw.get("model") or "").strip(),
"languages": [str(item).strip() for item in languages if str(item).strip()],
"timeout_seconds": int(raw.get("timeout_seconds") or 30),
"max_file_size_mb": int(raw.get("max_file_size_mb") or 20),
"output_format": str(raw.get("output_format") or "markdown").strip() or "markdown",
}
def _resolve_ocr_api_key(ocr_config: dict) -> tuple[str, str]:
saved_key = ocr_config.get("api_key") or ""
if saved_key:
return str(saved_key), "runtime"
return _resolve_web_search_env_secret("OCR_API_KEY")
def _build_ocr_payload(
current_payload: dict,
update: OCRIntegrationUpdate | None,
) -> dict:
current_ocr = _normalize_ocr_payload(current_payload.get("ocr") or {})
if update is None:
return current_ocr
current_key, current_key_source = _resolve_ocr_api_key(current_ocr)
current_key_preview = _mask_secret(current_key, current_key_source)["preview"]
ocr_payload = {
"enabled": update.enabled,
"provider": update.provider.strip().lower() or current_ocr.get("provider") or "paddleocr",
"base_url": update.base_url.strip(),
"model": update.model.strip(),
"languages": [item.strip() for item in update.languages if item.strip()] or ["zh", "en"],
"timeout_seconds": update.timeout_seconds,
"max_file_size_mb": update.max_file_size_mb,
"output_format": update.output_format.strip() or "markdown",
}
if not _is_secret_placeholder(update.api_key, current_key_preview):
ocr_payload["api_key"] = str(update.api_key).strip()
elif current_ocr.get("api_key"):
ocr_payload["api_key"] = current_ocr.get("api_key") or ""
else:
ocr_payload["api_key"] = ""
return ocr_payload
async def get_runtime_web_search_config(db: AsyncSession) -> WebSearchConfig:
runtime_record = await get_setting_record(db, "external_integrations")
payload = merge_with_defaults(
@@ -684,6 +790,7 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
)
normalized_ai = _normalize_ai_provider_payload(raw_payload.get("ai_provider") or {})
normalized_web_search = _normalize_web_search_payload(raw_payload.get("web_search") or {})
normalized_ocr = _normalize_ocr_payload(raw_payload.get("ocr") or {})
default_provider = normalized_ai["default_provider"]
providers_payload: dict[str, dict] = {}
for provider in sorted({
@@ -731,6 +838,7 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
web_search_providers_payload.get(normalized_web_search["default_provider"])
or _web_search_provider_defaults(normalized_web_search["default_provider"])
)
ocr_api_key, ocr_api_key_source = _resolve_ocr_api_key(normalized_ocr)
barentswatch_record = await get_barentswatch_config_record(db)
barentswatch_auth = barentswatch_record.auth_config if barentswatch_record else {}
barentswatch_auth = barentswatch_auth or {}
@@ -782,6 +890,18 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
"scrape_formats": display_web_search_config.get("scrape_formats") or ["markdown"],
"source": "runtime" if runtime_setting else "env",
},
"ocr": {
"enabled": normalized_ocr["enabled"],
"provider": normalized_ocr["provider"],
"base_url": normalized_ocr["base_url"],
"api_key": _mask_secret(ocr_api_key, ocr_api_key_source),
"model": normalized_ocr["model"],
"languages": normalized_ocr["languages"],
"timeout_seconds": normalized_ocr["timeout_seconds"],
"max_file_size_mb": normalized_ocr["max_file_size_mb"],
"output_format": normalized_ocr["output_format"],
"source": "runtime" if normalized_ocr.get("api_key") else (ocr_api_key_source or "default"),
},
}
@@ -792,11 +912,12 @@ async def save_external_integrations_payload(
current_payload = await get_setting_payload(db, "external_integrations")
ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider)
web_search_payload = _build_web_search_payload(current_payload, update.web_search)
ocr_payload = _build_ocr_payload(current_payload, update.ocr)
await save_setting_payload(
db,
"external_integrations",
{"ai_provider": ai_payload, "web_search": web_search_payload},
{"ai_provider": ai_payload, "web_search": web_search_payload, "ocr": ocr_payload},
)
default_endpoint = get_data_sources_config().get_yaml_url("barentswatch_vessels")
@@ -919,6 +1040,97 @@ async def update_security_settings(
return {"status": "updated", "security": payload}
def _serialize_smtp_payload(payload: dict) -> dict:
password = str(payload.get("password") or "")
return {
"host": payload.get("host") or "",
"port": int(payload.get("port") or 587),
"username": payload.get("username") or "",
"password": _mask_secret(password, "runtime" if password else ""),
"from_address": payload.get("from_address") or "",
"from_name": payload.get("from_name") or "Planet",
"use_tls": bool(payload.get("use_tls", False)),
"use_starttls": bool(payload.get("use_starttls", True)),
"timeout_seconds": int(payload.get("timeout_seconds") or 20),
"configured": bool(payload.get("host") and payload.get("from_address")),
}
def _build_smtp_payload(current_payload: dict, update: SMTPSettingsUpdate) -> dict:
current_password = str(current_payload.get("password") or "")
current_preview = _mask_secret(current_password, "runtime" if current_password else "")["preview"]
if update.clear_password:
password = ""
elif _is_secret_placeholder(update.password, current_preview):
password = current_password
else:
password = str(update.password).strip()
return {
"host": update.host.strip(),
"port": update.port,
"username": update.username.strip(),
"password": password,
"from_address": update.from_address.strip(),
"from_name": update.from_name.strip() or "Planet",
"use_tls": update.use_tls,
"use_starttls": update.use_starttls,
"timeout_seconds": update.timeout_seconds,
}
@router.get("/smtp")
async def get_smtp_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return {"smtp": _serialize_smtp_payload(await get_setting_payload(db, "smtp"))}
@router.put("/smtp")
async def update_smtp_settings(
payload: SMTPSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if current_user.role not in ("admin", "super_admin"):
raise HTTPException(status_code=403, detail="Only administrators can change SMTP settings")
current = await get_setting_payload(db, "smtp")
merged = _build_smtp_payload(current, payload)
saved = await save_setting_payload(db, "smtp", merged)
return {"status": "updated", "smtp": _serialize_smtp_payload(saved)}
@router.post("/smtp/test")
async def test_smtp_settings(
payload: SMTPTestRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if current_user.role not in ("admin", "super_admin"):
raise HTTPException(status_code=403, detail="Only administrators can test SMTP settings")
from app.services.email import EmailError, send_email
current = await get_setting_payload(db, "smtp")
config = _build_smtp_payload(current, payload.settings) if payload.settings else current
if not config.get("host") or not config.get("from_address"):
raise HTTPException(
status_code=400,
detail="Host and from_address are required to send a test email",
)
try:
await send_email(
db,
to=payload.to,
subject="Planet SMTP test",
text_body="This is a test email from Planet SMTP settings.",
html_body="<p>This is a test email from Planet SMTP settings.</p>",
config=config,
)
except EmailError as exc:
return {"success": False, "message": str(exc), "code": exc.code}
return {"success": True, "message": "Test email sent"}
@router.get("/tv")
async def get_tv_settings(
current_user: User = Depends(get_current_user),
@@ -1033,10 +1245,11 @@ async def connect_ai_provider_integration(
)
)
current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {})
current_ocr = _normalize_ocr_payload(current_payload.get("ocr") or {})
await save_setting_payload(
db,
"external_integrations",
{"ai_provider": draft_ai_payload, "web_search": current_web_search},
{"ai_provider": draft_ai_payload, "web_search": current_web_search, "ocr": current_ocr},
)
return {
"success": True,
@@ -1110,6 +1323,21 @@ async def reveal_web_search_secrets(
}
@router.get("/integrations/ocr/secrets")
async def reveal_ocr_secrets(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
ocr_payload = _normalize_ocr_payload(current_payload.get("ocr") or {})
api_key, api_key_source = _resolve_ocr_api_key(ocr_payload)
return {
"provider": ocr_payload["provider"],
"api_key": api_key,
"api_key_source": api_key_source,
}
@router.post("/integrations/web-search/connect")
async def connect_web_search_integration(
payload: WebSearchIntegrationUpdate,