release: bump version to 0.52.0
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user