feat: ContentGuardianAgent, onboarding UX, Team Activity action wiring, docs, agent help modal
ContentGuardianAgent consolidation:
- Merge 3 duplicate classes into single source in specialized/content_guardian.py
- Watchdog audit_committee() with heuristic scoring, coverage gaps, overlaps, alerts
- Remove misleading rejection_rate() helper; use acceptance_rate directly
- Integrate audit + alerts + trend signals into today_workflow_service.py
Team Activity page:
- QualityAuditPanel: health ring, per-agent critiques, coverage gaps, overlaps
- TrendSignalsPanel: opportunity cards with urgency/impact/coverage bars
- AlertBanner: persistent dismiss via POST /alerts/{id}/mark-read
- AgentHelpModal: dialog showing all 8 agents with descriptions, tools, schedule
- QualityAuditPanel action buttons: Fill gap -> /content-planning, Resolve overlap, View CTA on alerts/issues
- TrendSignalsPanel action buttons: Create content from this trend -> /blog-writer with trend context state
Onboarding system:
- Step 4 validation: no auto-pass via basic_ready; requires persona data or explicit progression
- Step 5 validation: logs warning on auto-pass without integration data
- OnboardingCompletionService: single DB session, transactional task creation, upsert pattern
- Business-without-website: nullable website_url on SIFIndexingTask and MarketTrendsTask
- DeepCompetitorAnalysisExecutor: 5-min timeout, 10-competitor cap, asyncio.wait_for
- Persona generation: async with 30s timeout, falls back to scheduler
- OnboardingProgressService.reset_onboarding(): resets session + pauses all DB tasks
- OnboardingControlService.reset_onboarding(): also cancels APScheduler jobs
- FinalStep TaskSchedulingPanel: shows scheduled/failed tasks after completion, 8s auto-redirect
- onboarding_completed agent activity event logged to feed
Documentation:
- docs-site/features/onboarding/: overview, steps, scheduler-tasks, technical-reference (4 pages)
- docs-site/mkdocs.yml: added Onboarding System nav section
- docs-site/features/sif-agents/: overview, agent-directory, committee-system, content-guardian (4 pages)
- docs-site/features/team-activity/: overview, quality-audit, trend-signals, alert-system (4 pages)
- docs-site/features/todays-workflow/: updated overview, technical-architecture, workflow-guide, api-reference
This commit is contained in:
@@ -47,7 +47,10 @@ class WixAuthService:
|
||||
'code_verifier': code_verifier,
|
||||
}
|
||||
token_url = f'{self.base_url}/oauth2/token'
|
||||
logger.info(f"Wix token exchange: client_id={self.client_id}, redirect_uri={self.redirect_uri}, code_verifier_prefix={code_verifier[:10]}...")
|
||||
response = requests.post(token_url, headers=headers, data=data)
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Wix token exchange failed: {response.status_code} {response.text}")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
@@ -55,19 +55,20 @@ def get_wix_headers(
|
||||
if token.startswith('OauthNG.JWS.'):
|
||||
# Wix OAuth token - use Bearer prefix
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
logger.debug(f"Using Wix OAuth token with Bearer prefix (OauthNG.JWS. format detected)")
|
||||
logger.debug("Using Wix OAuth token with Bearer prefix (OauthNG.JWS. format detected)")
|
||||
elif token.startswith('IST.'):
|
||||
# Wix Headless API key - send as-is, no Bearer
|
||||
headers['Authorization'] = token
|
||||
logger.debug("Using Wix API key for authorization (IST. format detected)")
|
||||
else:
|
||||
# Count dots - JWT has exactly 2 dots
|
||||
# Standard JWT has exactly 2 dots separating header.payload.signature
|
||||
dot_count = token.count('.')
|
||||
|
||||
if dot_count == 2 and len(token) < 500:
|
||||
# Likely OAuth JWT token - use Bearer prefix
|
||||
if dot_count == 2:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
logger.debug(f"Using OAuth Bearer token (JWT format detected)")
|
||||
logger.debug("Using OAuth Bearer token (JWT format: 2 dots detected)")
|
||||
else:
|
||||
# Likely API key - use directly without Bearer prefix
|
||||
headers['Authorization'] = token
|
||||
logger.debug(f"Using API key for authorization (non-JWT format detected)")
|
||||
logger.debug("Using token as-is (non-JWT format detected)")
|
||||
|
||||
if client_id:
|
||||
headers['wix-client-id'] = client_id
|
||||
@@ -125,8 +126,10 @@ def should_use_api_key(access_token: Optional[str] = None) -> bool:
|
||||
access_token = str(access_token)
|
||||
|
||||
token = access_token.strip()
|
||||
if token.count('.') != 2 or len(token) > 500:
|
||||
if token.startswith('OauthNG.JWS.'):
|
||||
return False
|
||||
if token.startswith('IST.'):
|
||||
return True
|
||||
|
||||
return False
|
||||
# Standard JWT has exactly 2 dots
|
||||
return token.count('.') != 2
|
||||
|
||||
|
||||
@@ -2,20 +2,22 @@ from typing import Any, Dict, List, Optional
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from .retry import wix_api_call_with_retry, WixAPIError
|
||||
|
||||
|
||||
class WixBlogService:
|
||||
"""Service for Wix Blog API operations with retry logic and error handling."""
|
||||
|
||||
def __init__(self, base_url: str, client_id: Optional[str]):
|
||||
self.base_url = base_url
|
||||
self.client_id = client_id
|
||||
|
||||
def headers(self, access_token: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
||||
"""Build headers with automatic token type detection."""
|
||||
h: Dict[str, str] = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
# Support both OAuth tokens and API keys
|
||||
# API keys don't use 'Bearer' prefix
|
||||
# Ensure access_token is a string (defensive check)
|
||||
if access_token:
|
||||
# Normalize token to string if needed
|
||||
if not isinstance(access_token, str):
|
||||
@@ -28,20 +30,18 @@ class WixBlogService:
|
||||
|
||||
token = access_token.strip()
|
||||
if token:
|
||||
# CRITICAL: Wix OAuth tokens can have format "OauthNG.JWS.xxx.yyy.zzz"
|
||||
# These should use "Bearer" prefix even though they have more than 2 dots
|
||||
if token.startswith('OauthNG.JWS.'):
|
||||
# Wix OAuth token - use Bearer prefix
|
||||
h['Authorization'] = f'Bearer {token}'
|
||||
logger.debug("Using Wix OAuth token with Bearer prefix (OauthNG.JWS. format detected)")
|
||||
elif '.' not in token or len(token) > 500:
|
||||
# Likely an API key - use directly without Bearer prefix
|
||||
elif token.startswith('IST.'):
|
||||
h['Authorization'] = token
|
||||
logger.debug("Using API key for authorization")
|
||||
else:
|
||||
# Standard JWT OAuth token (xxx.yyy.zzz format) - use Bearer prefix
|
||||
logger.debug("Using Wix API key for authorization (IST. format detected)")
|
||||
elif token.count('.') == 2:
|
||||
h['Authorization'] = f'Bearer {token}'
|
||||
logger.debug("Using OAuth Bearer token for authorization")
|
||||
logger.debug("Using OAuth Bearer token for authorization (JWT: 2 dots)")
|
||||
else:
|
||||
h['Authorization'] = token
|
||||
logger.debug("Using token as-is for authorization")
|
||||
|
||||
if self.client_id:
|
||||
h['wix-client-id'] = self.client_id
|
||||
@@ -50,12 +50,12 @@ class WixBlogService:
|
||||
return h
|
||||
|
||||
def create_draft_post(self, access_token: str, payload: Dict[str, Any], extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
"""Create draft post with consolidated logging"""
|
||||
"""Create draft post with retry logic and consolidated logging."""
|
||||
from .logger import wix_logger
|
||||
import json
|
||||
import traceback as tb
|
||||
|
||||
# Build payload summary for logging
|
||||
# Build payload summary for logging (safe, no sensitive data)
|
||||
payload_summary = {}
|
||||
if 'draftPost' in payload:
|
||||
dp = payload['draftPost']
|
||||
@@ -66,64 +66,114 @@ class WixBlogService:
|
||||
}
|
||||
|
||||
request_headers = self.headers(access_token, extra_headers)
|
||||
logger.debug(f"Wix API request headers: {list(request_headers.keys())}")
|
||||
if 'wix-site-id' in request_headers:
|
||||
logger.info(f"Wix API call includes wix-site-id: {request_headers['wix-site-id'][:8]}...")
|
||||
else:
|
||||
logger.warning("Wix API call MISSING wix-site-id header — this may fail for multi-site tokens")
|
||||
|
||||
url = f"{self.base_url}/blog/v3/draft-posts"
|
||||
|
||||
try:
|
||||
response = requests.post(f"{self.base_url}/blog/v3/draft-posts", headers=request_headers, json=payload)
|
||||
except TypeError as e:
|
||||
logger.error(f"TypeError during requests.post in create_draft_post: {e}")
|
||||
logger.error(f"Traceback: {tb.format_exc()}")
|
||||
logger.error(f"access_token type: {type(access_token)}")
|
||||
logger.error(f"payload type: {type(payload)}, keys: {list(payload.keys()) if isinstance(payload, dict) else 'N/A'}")
|
||||
result = wix_api_call_with_retry('POST', url, request_headers, json_payload=payload, max_attempts=3)
|
||||
wix_logger.log_api_call("POST", "/blog/v3/draft-posts", 200, payload_summary, None)
|
||||
return result
|
||||
except WixAPIError as e:
|
||||
wix_logger.log_api_call("POST", "/blog/v3/draft-posts", e.status_code or 500, payload_summary, e.response_body)
|
||||
logger.error(f"Wix create_draft_post failed after retries: HTTP {e.status_code} - {e.response_body}")
|
||||
raise
|
||||
except Exception as e:
|
||||
wix_logger.log_api_call("POST", "/blog/v3/draft-posts", 500, payload_summary, str(e)[:200])
|
||||
logger.error(f"Unexpected error in create_draft_post: {e}")
|
||||
raise
|
||||
|
||||
# Consolidated error logging
|
||||
error_body = None
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
error_body = response.json()
|
||||
except:
|
||||
error_body = {'message': response.text[:200]}
|
||||
|
||||
wix_logger.log_api_call("POST", "/blog/v3/draft-posts", response.status_code, payload_summary, error_body)
|
||||
|
||||
if response.status_code >= 400:
|
||||
# Only show detailed error info for debugging
|
||||
if response.status_code == 500:
|
||||
logger.debug(f" Full error: {json.dumps(error_body, indent=2) if isinstance(error_body, dict) else error_body}")
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def publish_draft(self, access_token: str, draft_post_id: str, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
response = requests.post(f"{self.base_url}/blog/v3/draft-posts/{draft_post_id}/publish", headers=self.headers(access_token, extra_headers))
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
"""Publish a draft post with retry logic."""
|
||||
url = f"{self.base_url}/blog/v3/draft-posts/{draft_post_id}/publish"
|
||||
headers = self.headers(access_token, extra_headers)
|
||||
|
||||
try:
|
||||
return wix_api_call_with_retry('POST', url, headers, max_attempts=3)
|
||||
except WixAPIError as e:
|
||||
logger.error(f"Wix publish_draft failed: HTTP {e.status_code} - {e.response_body}")
|
||||
raise
|
||||
|
||||
def list_categories(self, access_token: str, extra_headers: Optional[Dict[str, str]] = None) -> List[Dict[str, Any]]:
|
||||
response = requests.get(f"{self.base_url}/blog/v3/categories", headers=self.headers(access_token, extra_headers))
|
||||
response.raise_for_status()
|
||||
return response.json().get('categories', [])
|
||||
"""List blog categories with retry logic."""
|
||||
url = f"{self.base_url}/blog/v3/categories"
|
||||
headers = self.headers(access_token, extra_headers)
|
||||
|
||||
try:
|
||||
result = wix_api_call_with_retry('GET', url, headers, max_attempts=3)
|
||||
return result.get('categories', [])
|
||||
except WixAPIError as e:
|
||||
logger.error(f"Wix list_categories failed: HTTP {e.status_code}")
|
||||
raise
|
||||
|
||||
def create_category(self, access_token: str, label: str, description: Optional[str] = None, language: Optional[str] = None, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
def create_category(self, access_token: str, label: str, description: Optional[str] = None,
|
||||
language: Optional[str] = None, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
"""Create a blog category with retry logic."""
|
||||
url = f"{self.base_url}/blog/v3/categories"
|
||||
headers = self.headers(access_token, extra_headers)
|
||||
payload: Dict[str, Any] = {'category': {'label': label}, 'fieldsets': ['URL']}
|
||||
if description:
|
||||
payload['category']['description'] = description
|
||||
if language:
|
||||
payload['category']['language'] = language
|
||||
response = requests.post(f"{self.base_url}/blog/v3/categories", headers=self.headers(access_token, extra_headers), json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
return wix_api_call_with_retry('POST', url, headers, json_payload=payload, max_attempts=3)
|
||||
except WixAPIError as e:
|
||||
logger.error(f"Wix create_category failed: HTTP {e.status_code}")
|
||||
raise
|
||||
|
||||
def list_tags(self, access_token: str, extra_headers: Optional[Dict[str, str]] = None) -> List[Dict[str, Any]]:
|
||||
response = requests.get(f"{self.base_url}/blog/v3/tags", headers=self.headers(access_token, extra_headers))
|
||||
response.raise_for_status()
|
||||
return response.json().get('tags', [])
|
||||
"""List blog tags with retry logic."""
|
||||
url = f"{self.base_url}/blog/v3/tags"
|
||||
headers = self.headers(access_token, extra_headers)
|
||||
|
||||
try:
|
||||
result = wix_api_call_with_retry('GET', url, headers, max_attempts=3)
|
||||
return result.get('tags', [])
|
||||
except WixAPIError as e:
|
||||
logger.error(f"Wix list_tags failed: HTTP {e.status_code}")
|
||||
raise
|
||||
|
||||
def create_tag(self, access_token: str, label: str, language: Optional[str] = None, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
def create_tag(self, access_token: str, label: str, language: Optional[str] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
"""Create a blog tag with retry logic."""
|
||||
url = f"{self.base_url}/blog/v3/tags"
|
||||
headers = self.headers(access_token, extra_headers)
|
||||
payload: Dict[str, Any] = {'label': label, 'fieldsets': ['URL']}
|
||||
if language:
|
||||
payload['language'] = language
|
||||
response = requests.post(f"{self.base_url}/blog/v3/tags", headers=self.headers(access_token, extra_headers), json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
return wix_api_call_with_retry('POST', url, headers, json_payload=payload, max_attempts=3)
|
||||
except WixAPIError as e:
|
||||
logger.error(f"Wix create_tag failed: HTTP {e.status_code}")
|
||||
raise
|
||||
|
||||
def get_draft_post(self, access_token: str, draft_post_id: str,
|
||||
extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
"""Get a draft post by ID with retry logic."""
|
||||
url = f"{self.base_url}/blog/v3/draft-posts/{draft_post_id}"
|
||||
headers = self.headers(access_token, extra_headers)
|
||||
|
||||
try:
|
||||
return wix_api_call_with_retry('GET', url, headers, max_attempts=3)
|
||||
except WixAPIError as e:
|
||||
logger.error(f"Wix get_draft_post failed: HTTP {e.status_code}")
|
||||
raise
|
||||
|
||||
def update_draft_post(self, access_token: str, draft_post_id: str, payload: Dict[str, Any],
|
||||
extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
"""Update a draft post with retry logic."""
|
||||
url = f"{self.base_url}/blog/v3/draft-posts/{draft_post_id}"
|
||||
headers = self.headers(access_token, extra_headers)
|
||||
|
||||
try:
|
||||
return wix_api_call_with_retry('PUT', url, headers, json_payload=payload, max_attempts=3)
|
||||
except WixAPIError as e:
|
||||
logger.error(f"Wix update_draft_post failed: HTTP {e.status_code}")
|
||||
raise
|
||||
|
||||
@@ -5,6 +5,7 @@ Handles blog post creation, validation, and publishing to Wix.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
import requests
|
||||
@@ -193,6 +194,7 @@ def create_blog_post(
|
||||
tag_ids: List[str] = None,
|
||||
publish: bool = True,
|
||||
seo_metadata: Dict[str, Any] = None,
|
||||
site_id: str = None,
|
||||
import_image_func = None,
|
||||
lookup_categories_func = None,
|
||||
lookup_tags_func = None,
|
||||
@@ -220,111 +222,50 @@ def create_blog_post(
|
||||
Returns:
|
||||
Created blog post information
|
||||
"""
|
||||
if not member_id:
|
||||
raise ValueError("memberId is required for third-party apps creating blog posts")
|
||||
# ===== PRE-FLIGHT VALIDATION =====
|
||||
errors = []
|
||||
|
||||
# Ensure access_token is a string (handle cases where it might be int, dict, or other type)
|
||||
# Use normalize_token_string to handle various token formats (dict with accessToken.value, etc.)
|
||||
if not member_id:
|
||||
errors.append("memberId is required for third-party apps creating blog posts")
|
||||
|
||||
title_clean = str(title).strip() if title else ""
|
||||
if not title_clean:
|
||||
errors.append("Title is required")
|
||||
elif len(title_clean) > 200:
|
||||
errors.append(f"Title is too long ({len(title_clean)} chars, max 200)")
|
||||
|
||||
# Ensure access_token is a string
|
||||
normalized_token = normalize_token_string(access_token)
|
||||
if not normalized_token:
|
||||
raise ValueError("access_token is required and must be a valid string or token object")
|
||||
access_token = normalized_token.strip()
|
||||
if not access_token:
|
||||
raise ValueError("access_token cannot be empty")
|
||||
errors.append("access_token is required and must be a valid string or token object")
|
||||
else:
|
||||
access_token = normalized_token.strip()
|
||||
if not access_token:
|
||||
errors.append("access_token cannot be empty")
|
||||
|
||||
# BACK TO BASICS MODE: Try simplest possible structure FIRST
|
||||
# Since posting worked before Ricos/SEO, let's test with absolute minimum
|
||||
BACK_TO_BASICS_MODE = False # Disabled: full Ricos conversion now produces valid output
|
||||
content_clean = str(content).strip() if content else ""
|
||||
if not content_clean:
|
||||
logger.warning("Content was empty, using default text")
|
||||
content = "This is a post from ALwrity."
|
||||
elif len(content_clean) > 100000:
|
||||
errors.append(f"Content is too long ({len(content_clean)} chars, max 100,000)")
|
||||
|
||||
if errors:
|
||||
raise ValueError(f"Wix publish validation failed: {'; '.join(errors)}")
|
||||
|
||||
wix_logger.reset()
|
||||
wix_logger.log_operation_start("Blog Post Creation", title=title[:50] if title else None, member_id=member_id[:20] if member_id else None)
|
||||
|
||||
if BACK_TO_BASICS_MODE:
|
||||
logger.info("🔧 Wix: BACK TO BASICS MODE - Testing minimal structure")
|
||||
|
||||
# Import auth utilities for proper token handling
|
||||
from .auth_utils import get_wix_headers
|
||||
|
||||
# Create absolute minimal Ricos structure
|
||||
minimal_ricos = {
|
||||
'nodes': [{
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': [{
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [],
|
||||
'textData': {
|
||||
'text': (content[:500] if content else "This is a post from ALwrity.").strip(),
|
||||
'decorations': []
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
|
||||
# Extract wix-site-id from token if possible
|
||||
extra_headers = {}
|
||||
try:
|
||||
token_str = str(access_token)
|
||||
if token_str and token_str.startswith('OauthNG.JWS.'):
|
||||
import jwt
|
||||
import json
|
||||
jwt_part = token_str[12:]
|
||||
payload = jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
|
||||
data_payload = payload.get('data', {})
|
||||
if isinstance(data_payload, str):
|
||||
try:
|
||||
data_payload = json.loads(data_payload)
|
||||
except:
|
||||
pass
|
||||
instance_data = data_payload.get('instance', {})
|
||||
meta_site_id = instance_data.get('metaSiteId')
|
||||
if isinstance(meta_site_id, str) and meta_site_id:
|
||||
extra_headers['wix-site-id'] = meta_site_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build minimal payload
|
||||
minimal_blog_data = {
|
||||
'draftPost': {
|
||||
'title': str(title).strip() if title else "Untitled",
|
||||
'memberId': str(member_id).strip(),
|
||||
'richContent': minimal_ricos
|
||||
},
|
||||
'publish': False,
|
||||
'fieldsets': ['URL']
|
||||
}
|
||||
|
||||
try:
|
||||
from .blog import WixBlogService
|
||||
blog_service_test = WixBlogService('https://www.wixapis.com', None)
|
||||
result = blog_service_test.create_draft_post(access_token, minimal_blog_data, extra_headers if extra_headers else None)
|
||||
logger.success("✅✅✅ Wix: BACK TO BASICS SUCCEEDED! Issue is with Ricos/SEO structure")
|
||||
wix_logger.log_operation_result("Back to Basics Test", True, result)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Wix: BACK TO BASICS FAILED - {str(e)[:100]}")
|
||||
logger.error(" ⚠️ Issue is NOT with Ricos/SEO - likely permissions/token")
|
||||
wix_logger.add_error(f"Back to Basics: {str(e)[:100]}")
|
||||
|
||||
# Import auth utilities for proper token handling
|
||||
from .auth_utils import get_wix_headers
|
||||
|
||||
# Headers for blog post creation (use user's OAuth token)
|
||||
headers = get_wix_headers(access_token)
|
||||
|
||||
# Build valid Ricos rich content
|
||||
# Ensure content is not empty
|
||||
if not content or not content.strip():
|
||||
content = "This is a post from ALwrity."
|
||||
logger.warning("⚠️ Content was empty, using default text")
|
||||
|
||||
# Quick token/permission check (only log if issues found)
|
||||
has_blog_scope = None
|
||||
meta_site_id = None
|
||||
try:
|
||||
from .utils import decode_wix_token
|
||||
import json
|
||||
from .utils import decode_wix_token, extract_meta_from_token
|
||||
token_data = decode_wix_token(access_token)
|
||||
if 'scope' in token_data:
|
||||
scopes = token_data.get('scope')
|
||||
@@ -332,17 +273,9 @@ def create_blog_post(
|
||||
scope_list = scopes.split(',') if ',' in scopes else [scopes]
|
||||
has_blog_scope = any('BLOG' in s.upper() for s in scope_list)
|
||||
if not has_blog_scope:
|
||||
logger.error("❌ Wix: Token missing BLOG scopes - verify OAuth app permissions")
|
||||
if 'data' in token_data:
|
||||
data = token_data.get('data')
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
data = json.loads(data)
|
||||
except:
|
||||
pass
|
||||
if isinstance(data, dict) and 'instance' in data:
|
||||
instance = data.get('instance', {})
|
||||
meta_site_id = instance.get('metaSiteId')
|
||||
logger.error("Wix: Token missing BLOG scopes - verify OAuth app permissions")
|
||||
meta_info = extract_meta_from_token(access_token)
|
||||
meta_site_id = meta_info.get('metaSiteId')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -352,13 +285,12 @@ def create_blog_post(
|
||||
import requests
|
||||
test_response = requests.get(f"{base_url}/blog/v3/categories", headers=test_headers, timeout=5)
|
||||
if test_response.status_code == 403:
|
||||
logger.error("❌ Wix: Permission denied - OAuth app missing BLOG.CREATE-DRAFT")
|
||||
logger.error("Wix: Permission denied - OAuth app missing BLOG.CREATE-DRAFT")
|
||||
elif test_response.status_code == 401:
|
||||
logger.error("❌ Wix: Unauthorized - token may be expired")
|
||||
logger.error("Wix: Unauthorized - token may be expired")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Safely get token length (access_token is already validated as string above)
|
||||
token_length = len(access_token) if access_token else 0
|
||||
wix_logger.log_token_info(token_length, has_blog_scope, meta_site_id)
|
||||
|
||||
@@ -470,19 +402,20 @@ def create_blog_post(
|
||||
if cover_image_url and import_image_func:
|
||||
try:
|
||||
media_id = import_image_func(access_token, cover_image_url, f'Cover: {title}')
|
||||
# Ensure media_id is a string and not None
|
||||
if media_id and isinstance(media_id, str):
|
||||
# import_image_to_wix now returns Optional[str] — None means failure
|
||||
if media_id and isinstance(media_id, str) and media_id.strip():
|
||||
blog_data['draftPost']['media'] = {
|
||||
'wixMedia': {
|
||||
'image': {'id': str(media_id).strip()}
|
||||
'image': {'id': media_id.strip()}
|
||||
},
|
||||
'displayed': True,
|
||||
'custom': True
|
||||
}
|
||||
logger.info(f"Cover image imported: {media_id[:16]}...")
|
||||
else:
|
||||
logger.warning(f"Invalid media_id type or value: {type(media_id)}, skipping media")
|
||||
logger.warning(f"Cover image import returned no valid media_id (type={type(media_id)}). Continuing without cover image.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to import cover image: {e}")
|
||||
logger.warning(f"Cover image import failed (non-fatal): {e}. Continuing without cover image.")
|
||||
|
||||
# Handle categories - can be either IDs (list of strings) or names (for lookup)
|
||||
category_ids_to_use = None
|
||||
@@ -558,34 +491,33 @@ def create_blog_post(
|
||||
logger.debug("No SEO metadata provided to create_blog_post")
|
||||
|
||||
try:
|
||||
# Extract wix-site-id from token if possible
|
||||
# Extract wix-site-id from token, parameter, or env var
|
||||
extra_headers = {}
|
||||
try:
|
||||
wix_site_id = site_id or os.getenv('WIX_SITE_ID')
|
||||
if not wix_site_id:
|
||||
from .utils import extract_meta_from_token
|
||||
meta_info = extract_meta_from_token(access_token)
|
||||
wix_site_id = meta_info.get('metaSiteId')
|
||||
if wix_site_id:
|
||||
extra_headers['wix-site-id'] = wix_site_id
|
||||
logger.info(f"Using wix-site-id: {wix_site_id[:8]}... (source: {'param' if site_id else 'env' if os.getenv('WIX_SITE_ID') else 'token'})")
|
||||
else:
|
||||
token_str = str(access_token)
|
||||
if token_str and token_str.startswith('OauthNG.JWS.'):
|
||||
import jwt
|
||||
import json
|
||||
jwt_part = token_str[12:]
|
||||
payload = jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
|
||||
data_payload = payload.get('data', {})
|
||||
if isinstance(data_payload, str):
|
||||
try:
|
||||
data_payload = json.loads(data_payload)
|
||||
except:
|
||||
pass
|
||||
instance_data = data_payload.get('instance', {})
|
||||
meta_site_id = instance_data.get('metaSiteId')
|
||||
if isinstance(meta_site_id, str) and meta_site_id:
|
||||
extra_headers['wix-site-id'] = meta_site_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if token_str.startswith('IST.'):
|
||||
logger.error("❌ IST. API key requires WIX_SITE_ID environment variable or site_id parameter. "
|
||||
"The token's tenant.id is the account ID, not the site ID. "
|
||||
"Please set WIX_SITE_ID in your .env file to your Wix site's metaSiteId.")
|
||||
else:
|
||||
logger.warning("No wix-site-id found — API calls may fail if token requires it")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not extract wix-site-id from token: {e}")
|
||||
|
||||
try:
|
||||
# Validate payload structure before sending
|
||||
draft_post = blog_data.get('draftPost', {})
|
||||
if not isinstance(draft_post, dict):
|
||||
raise ValueError("draftPost must be a dict object")
|
||||
|
||||
# Validate richContent structure
|
||||
|
||||
if 'richContent' in draft_post:
|
||||
rc = draft_post['richContent']
|
||||
if not isinstance(rc, dict):
|
||||
@@ -595,8 +527,7 @@ def create_blog_post(
|
||||
if not isinstance(rc['nodes'], list):
|
||||
raise ValueError(f"richContent.nodes must be a list, got {type(rc['nodes'])}")
|
||||
logger.debug(f"✅ richContent validation passed: {len(rc.get('nodes', []))} nodes")
|
||||
|
||||
# Validate seoData structure if present
|
||||
|
||||
if 'seoData' in draft_post:
|
||||
seo = draft_post['seoData']
|
||||
if not isinstance(seo, dict):
|
||||
@@ -606,46 +537,40 @@ def create_blog_post(
|
||||
if 'settings' in seo and not isinstance(seo['settings'], dict):
|
||||
raise ValueError(f"seoData.settings must be a dict, got {type(seo.get('settings'))}")
|
||||
logger.debug(f"✅ seoData validation passed: {len(seo.get('tags', []))} tags")
|
||||
|
||||
# Final validation: Ensure no None values in any nested objects
|
||||
# Wix API rejects None values and expects proper types
|
||||
|
||||
try:
|
||||
validate_payload_no_none(blog_data, "blog_data")
|
||||
logger.debug("✅ Payload validation passed: No None values found")
|
||||
except ValueError as e:
|
||||
logger.error(f"❌ Payload validation failed: {e}")
|
||||
raise
|
||||
|
||||
# Log payload summary
|
||||
|
||||
logger.debug(f"Payload: draftPost keys={list(draft_post.keys())}, "
|
||||
f"nodes={len(draft_post.get('richContent', {}).get('nodes', []))}, "
|
||||
f"has_seo={'seoData' in draft_post}")
|
||||
|
||||
# Final deep validation: Serialize and deserialize to catch any JSON-serialization issues
|
||||
|
||||
try:
|
||||
import json
|
||||
json.dumps(blog_data, ensure_ascii=False)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.error(f"❌ Payload JSON serialization failed: {e}")
|
||||
raise ValueError(f"Payload contains non-serializable data: {e}")
|
||||
|
||||
# Clean up None values that Wix API would reject
|
||||
|
||||
rc = blog_data['draftPost']['richContent']
|
||||
for field in ['documentStyle', 'metadata']:
|
||||
if field in rc and (rc[field] is None or rc[field] == "" or not isinstance(rc[field], dict)):
|
||||
del rc[field]
|
||||
|
||||
|
||||
logger.info(f"📤 Publishing to Wix: title='{blog_data['draftPost'].get('title', '')}', "
|
||||
f"nodes={len(rc.get('nodes', []))}")
|
||||
|
||||
|
||||
result = blog_service.create_draft_post(access_token, blog_data, extra_headers or None)
|
||||
|
||||
# Log success
|
||||
|
||||
draft_post = result.get('draftPost', {})
|
||||
post_id = draft_post.get('id', 'N/A')
|
||||
wix_logger.log_operation_result("Create Draft Post", True, result)
|
||||
logger.success(f"✅ Wix: Blog post created - ID: {post_id}")
|
||||
|
||||
|
||||
return result
|
||||
except TypeError as e:
|
||||
import traceback
|
||||
|
||||
@@ -5,79 +5,71 @@ from typing import Any, Dict, List
|
||||
|
||||
def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse inline markdown formatting (bold, italic, links) into Ricos text nodes.
|
||||
Parse inline markdown formatting (bold, italic, links, code, strikethrough) into Ricos text nodes.
|
||||
Returns a list of text nodes with decorations.
|
||||
Handles: **bold**, *italic*, [links](url), `code`, and combinations.
|
||||
Handles: **bold**, *italic*, [links](url), `code`, ~strikethrough~, and combinations.
|
||||
"""
|
||||
if not text:
|
||||
return [{
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'nodes': [],
|
||||
'textData': {'text': '', 'decorations': []}
|
||||
}]
|
||||
|
||||
nodes = []
|
||||
|
||||
# Process text character by character to handle nested/adjacent formatting
|
||||
# This is more robust than regex for complex cases
|
||||
i = 0
|
||||
current_text = ''
|
||||
current_decorations = []
|
||||
|
||||
def flush_text():
|
||||
nonlocal current_text
|
||||
if current_text:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [],
|
||||
'textData': {'text': current_text, 'decorations': []}
|
||||
})
|
||||
current_text = ''
|
||||
|
||||
while i < len(text):
|
||||
# Check for bold **text** (must come before single * check)
|
||||
# Bold **text**
|
||||
if i < len(text) - 1 and text[i:i+2] == '**':
|
||||
# Save any accumulated text
|
||||
if current_text:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {
|
||||
'text': current_text,
|
||||
'decorations': current_decorations.copy()
|
||||
}
|
||||
})
|
||||
current_text = ''
|
||||
|
||||
# Find closing **
|
||||
flush_text()
|
||||
end_bold = text.find('**', i + 2)
|
||||
if end_bold != -1:
|
||||
bold_text = text[i + 2:end_bold]
|
||||
# Recursively parse the bold text for nested formatting
|
||||
bold_nodes = parse_markdown_inline(bold_text)
|
||||
# Add BOLD decoration to all text nodes within
|
||||
# Per Wix API: decorations are objects with 'type' field, not strings
|
||||
for node in bold_nodes:
|
||||
if node['type'] == 'TEXT':
|
||||
node_decorations = node['textData'].get('decorations', []).copy()
|
||||
# Check if BOLD decoration already exists
|
||||
has_bold = any(d.get('type') == 'BOLD' for d in node_decorations if isinstance(d, dict))
|
||||
if not has_bold:
|
||||
node_decorations.append({'type': 'BOLD'})
|
||||
node['textData']['decorations'] = node_decorations
|
||||
decs = node['textData'].get('decorations', []).copy()
|
||||
if not any(d.get('type') == 'BOLD' for d in decs if isinstance(d, dict)):
|
||||
decs.append({'type': 'BOLD'})
|
||||
node['textData']['decorations'] = decs
|
||||
nodes.append(node)
|
||||
i = end_bold + 2
|
||||
continue
|
||||
|
||||
# Check for link [text](url)
|
||||
# Strikethrough ~text~
|
||||
elif text[i] == '~':
|
||||
flush_text()
|
||||
end_strike = text.find('~', i + 1)
|
||||
if end_strike != -1:
|
||||
strike_text = text[i + 1:end_strike]
|
||||
strike_nodes = parse_markdown_inline(strike_text)
|
||||
for node in strike_nodes:
|
||||
if node['type'] == 'TEXT':
|
||||
decs = node['textData'].get('decorations', []).copy()
|
||||
if not any(d.get('type') == 'STRIKETHROUGH' for d in decs if isinstance(d, dict)):
|
||||
decs.append({'type': 'STRIKETHROUGH'})
|
||||
node['textData']['decorations'] = decs
|
||||
nodes.append(node)
|
||||
i = end_strike + 1
|
||||
continue
|
||||
|
||||
# Link [text](url)
|
||||
elif text[i] == '[':
|
||||
# Save any accumulated text
|
||||
if current_text:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {
|
||||
'text': current_text,
|
||||
'decorations': current_decorations.copy()
|
||||
}
|
||||
})
|
||||
current_text = ''
|
||||
current_decorations = []
|
||||
|
||||
# Find matching ]
|
||||
flush_text()
|
||||
link_end = text.find(']', i)
|
||||
if link_end != -1 and link_end < len(text) - 1 and text[link_end + 1] == '(':
|
||||
link_text = text[i + 1:link_end]
|
||||
@@ -85,12 +77,10 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
url_end = text.find(')', url_start)
|
||||
if url_end != -1:
|
||||
url = text[url_start:url_end]
|
||||
# Per Wix API: Links are decorations on TEXT nodes, not separate node types
|
||||
# Create TEXT node with LINK decoration
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'nodes': [],
|
||||
'textData': {
|
||||
'text': link_text,
|
||||
'decorations': [{
|
||||
@@ -98,7 +88,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
'linkData': {
|
||||
'link': {
|
||||
'url': url,
|
||||
'target': 'BLANK' # Wix API uses 'BLANK', not '_blank'
|
||||
'target': 'BLANK'
|
||||
}
|
||||
}
|
||||
}]
|
||||
@@ -107,33 +97,17 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
i = url_end + 1
|
||||
continue
|
||||
|
||||
# Check for code `text`
|
||||
# Inline code `text`
|
||||
elif text[i] == '`':
|
||||
# Save any accumulated text
|
||||
if current_text:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {
|
||||
'text': current_text,
|
||||
'decorations': current_decorations.copy()
|
||||
}
|
||||
})
|
||||
current_text = ''
|
||||
current_decorations = []
|
||||
|
||||
# Find closing `
|
||||
flush_text()
|
||||
code_end = text.find('`', i + 1)
|
||||
if code_end != -1:
|
||||
code_text = text[i + 1:code_end]
|
||||
# Per Wix API: CODE is not a valid decoration type, but we'll keep the structure
|
||||
# Note: Wix uses CODE_BLOCK nodes for code, not CODE decorations
|
||||
# For inline code, we'll just use plain text for now
|
||||
# Wix doesn't have a CODE decoration, but we can preserve the text
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'nodes': [],
|
||||
'textData': {
|
||||
'text': code_text,
|
||||
'decorations': [] # CODE is not a valid decoration in Wix API
|
||||
@@ -142,39 +116,21 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
i = code_end + 1
|
||||
continue
|
||||
|
||||
# Check for italic *text* (only if not part of **)
|
||||
# Italic *text* (must come after ** check)
|
||||
elif text[i] == '*' and (i == 0 or text[i-1] != '*') and (i == len(text) - 1 or text[i+1] != '*'):
|
||||
# Save any accumulated text
|
||||
if current_text:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {
|
||||
'text': current_text,
|
||||
'decorations': current_decorations.copy()
|
||||
}
|
||||
})
|
||||
current_text = ''
|
||||
current_decorations = []
|
||||
|
||||
# Find closing * (but not **)
|
||||
flush_text()
|
||||
italic_end = text.find('*', i + 1)
|
||||
if italic_end != -1:
|
||||
# Make sure it's not part of **
|
||||
if italic_end == len(text) - 1 or text[italic_end + 1] != '*':
|
||||
italic_text = text[i + 1:italic_end]
|
||||
italic_nodes = parse_markdown_inline(italic_text)
|
||||
# Add ITALIC decoration
|
||||
# Per Wix API: decorations are objects with 'type' field
|
||||
for node in italic_nodes:
|
||||
if node['type'] == 'TEXT':
|
||||
node_decorations = node['textData'].get('decorations', []).copy()
|
||||
# Check if ITALIC decoration already exists
|
||||
has_italic = any(d.get('type') == 'ITALIC' for d in node_decorations if isinstance(d, dict))
|
||||
if not has_italic:
|
||||
node_decorations.append({'type': 'ITALIC'})
|
||||
node['textData']['decorations'] = node_decorations
|
||||
decs = node['textData'].get('decorations', []).copy()
|
||||
if not any(d.get('type') == 'ITALIC' for d in decs if isinstance(d, dict)):
|
||||
decs.append({'type': 'ITALIC'})
|
||||
node['textData']['decorations'] = decs
|
||||
nodes.append(node)
|
||||
i = italic_end + 1
|
||||
continue
|
||||
@@ -183,58 +139,116 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
|
||||
current_text += text[i]
|
||||
i += 1
|
||||
|
||||
# Add any remaining text
|
||||
if current_text:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {
|
||||
'text': current_text,
|
||||
'decorations': current_decorations.copy()
|
||||
}
|
||||
})
|
||||
flush_text()
|
||||
|
||||
# If no nodes created, return single plain text node
|
||||
if not nodes:
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'textData': {
|
||||
'text': text,
|
||||
'decorations': []
|
||||
}
|
||||
'nodes': [],
|
||||
'textData': {'text': text, 'decorations': []}
|
||||
})
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
def _make_code_block_node(code_text: str, language: str = '') -> Dict[str, Any]:
|
||||
"""Create a Ricos CODE_BLOCK node."""
|
||||
lines = code_text.split('\n')
|
||||
text_nodes = []
|
||||
for line in lines:
|
||||
text_nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [],
|
||||
'textData': {'text': line, 'decorations': []}
|
||||
})
|
||||
|
||||
return {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'CODE_BLOCK',
|
||||
'nodes': text_nodes,
|
||||
'codeBlockData': {
|
||||
'language': language or 'text',
|
||||
'textWrap': True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _make_horizontal_rule_node() -> Dict[str, Any]:
|
||||
"""Create a Ricos DIVIDER node."""
|
||||
return {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'DIVIDER',
|
||||
'nodes': [],
|
||||
'dividerData': {
|
||||
'type': 'LINE',
|
||||
'lineStyle': {
|
||||
'width': 'LARGE',
|
||||
'alignment': 'CENTER'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert markdown content into valid Ricos JSON format.
|
||||
Supports headings, paragraphs, lists, bold, italic, links, and images.
|
||||
|
||||
Supports:
|
||||
- Headings (# to ######)
|
||||
- Paragraphs with inline formatting
|
||||
- Unordered lists (-, *)
|
||||
- Ordered lists (1., 2.)
|
||||
- Blockquotes (>)
|
||||
- Code blocks (```language ... ```)
|
||||
- Inline images ()
|
||||
- Horizontal rules (---, ***, ___)
|
||||
"""
|
||||
if not content:
|
||||
content = "This is a post from ALwrity."
|
||||
|
||||
nodes = []
|
||||
lines = content.split('\n')
|
||||
|
||||
i = 0
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
if not line:
|
||||
if not stripped:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
# Check for headings
|
||||
if line.startswith('#'):
|
||||
level = len(line) - len(line.lstrip('#'))
|
||||
heading_text = line.lstrip('# ').strip()
|
||||
# Code blocks (```language ... ```)
|
||||
if stripped.startswith('```'):
|
||||
language = stripped[3:].strip() or ''
|
||||
code_lines = []
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
if lines[i].strip() == '```':
|
||||
i += 1
|
||||
break
|
||||
code_lines.append(lines[i])
|
||||
i += 1
|
||||
code_text = '\n'.join(code_lines)
|
||||
if code_text.strip():
|
||||
nodes.append(_make_code_block_node(code_text, language))
|
||||
continue
|
||||
|
||||
# Horizontal rules
|
||||
if re.match(r'^(---+|\*\*\*|___+)$', stripped):
|
||||
nodes.append(_make_horizontal_rule_node())
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Headings
|
||||
if stripped.startswith('#'):
|
||||
level = len(stripped) - len(stripped.lstrip('#'))
|
||||
heading_text = stripped.lstrip('# ').strip()
|
||||
text_nodes = parse_markdown_inline(heading_text)
|
||||
nodes.append({
|
||||
'id': node_id,
|
||||
@@ -243,42 +257,38 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
'headingData': {'level': min(level, 6)}
|
||||
})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check for blockquotes
|
||||
elif line.startswith('>'):
|
||||
quote_text = line.lstrip('> ').strip()
|
||||
# Continue reading consecutive blockquote lines
|
||||
quote_lines = [quote_text]
|
||||
# Blockquotes
|
||||
if stripped.startswith('>'):
|
||||
quote_lines = [stripped.lstrip('> ').strip()]
|
||||
i += 1
|
||||
while i < len(lines) and lines[i].strip().startswith('>'):
|
||||
quote_lines.append(lines[i].strip().lstrip('> ').strip())
|
||||
i += 1
|
||||
quote_content = ' '.join(quote_lines)
|
||||
text_nodes = parse_markdown_inline(quote_content)
|
||||
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within BLOCKQUOTE
|
||||
# Wix API: omit empty data objects, don't include them as {}
|
||||
paragraph_node = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': text_nodes,
|
||||
}
|
||||
blockquote_node = {
|
||||
nodes.append({
|
||||
'id': node_id,
|
||||
'type': 'BLOCKQUOTE',
|
||||
'nodes': [paragraph_node],
|
||||
}
|
||||
nodes.append(blockquote_node)
|
||||
})
|
||||
continue
|
||||
|
||||
# Check for unordered lists (handle both '- ' and '* ' markers)
|
||||
elif (line.startswith('- ') or line.startswith('* ') or
|
||||
(line.startswith('-') and len(line) > 1 and line[1] != '-') or
|
||||
(line.startswith('*') and len(line) > 1 and line[1] != '*')):
|
||||
# Unordered lists
|
||||
if (stripped.startswith('- ') or stripped.startswith('* ') or
|
||||
(stripped.startswith('-') and len(stripped) > 1 and stripped[1] != '-') or
|
||||
(stripped.startswith('*') and len(stripped) > 1 and stripped[1] != '*')):
|
||||
list_items = []
|
||||
list_marker = '- ' if line.startswith('-') else '* '
|
||||
# Process list items
|
||||
list_marker = '- ' if stripped.startswith('-') else '* '
|
||||
|
||||
while i < len(lines):
|
||||
current_line = lines[i].strip()
|
||||
# Check if this is a list item
|
||||
is_list_item = (current_line.startswith('- ') or current_line.startswith('* ') or
|
||||
(current_line.startswith('-') and len(current_line) > 1 and current_line[1] != '-') or
|
||||
(current_line.startswith('*') and len(current_line) > 1 and current_line[1] != '*'))
|
||||
@@ -286,12 +296,9 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
if not is_list_item:
|
||||
break
|
||||
|
||||
# Extract item text (handle both '- ' and '-item' formats)
|
||||
if current_line.startswith('- ') or current_line.startswith('* '):
|
||||
item_text = current_line[2:].strip()
|
||||
elif current_line.startswith('-'):
|
||||
item_text = current_line[1:].strip()
|
||||
elif current_line.startswith('*'):
|
||||
elif current_line.startswith('-') or current_line.startswith('*'):
|
||||
item_text = current_line[1:].strip()
|
||||
else:
|
||||
item_text = current_line
|
||||
@@ -302,52 +309,41 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
# Check for nested items (indented with 2+ spaces)
|
||||
while i < len(lines):
|
||||
next_line = lines[i]
|
||||
# Must be indented and be a list marker
|
||||
if next_line.startswith(' ') and (next_line.strip().startswith('- ') or
|
||||
next_line.strip().startswith('* ') or
|
||||
(next_line.strip().startswith('-') and len(next_line.strip()) > 1) or
|
||||
(next_line.strip().startswith('*') and len(next_line.strip()) > 1)):
|
||||
if (next_line.startswith(' ') and
|
||||
(next_line.strip().startswith('- ') or next_line.strip().startswith('* '))):
|
||||
nested_text = next_line.strip()
|
||||
if nested_text.startswith('- ') or nested_text.startswith('* '):
|
||||
nested_text = nested_text[2:].strip()
|
||||
elif nested_text.startswith('-'):
|
||||
nested_text = nested_text[1:].strip()
|
||||
elif nested_text.startswith('*'):
|
||||
elif nested_text.startswith('-') or nested_text.startswith('*'):
|
||||
nested_text = nested_text[1:].strip()
|
||||
list_items.append(nested_text)
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
|
||||
# Build list items with proper formatting
|
||||
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within LIST_ITEM
|
||||
# NOTE: LIST_ITEM nodes do NOT have a data field per Wix API schema
|
||||
# Wix API: omit empty data objects, don't include them as {}
|
||||
list_node_items = []
|
||||
for item_text in list_items:
|
||||
item_node_id = str(uuid.uuid4())
|
||||
text_nodes = parse_markdown_inline(item_text)
|
||||
paragraph_node = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': text_nodes,
|
||||
}
|
||||
list_item_node = {
|
||||
'id': item_node_id,
|
||||
list_node_items.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'LIST_ITEM',
|
||||
'nodes': [paragraph_node]
|
||||
}
|
||||
list_node_items.append(list_item_node)
|
||||
})
|
||||
|
||||
bulleted_list_node = {
|
||||
nodes.append({
|
||||
'id': node_id,
|
||||
'type': 'BULLETED_LIST',
|
||||
'nodes': list_node_items,
|
||||
}
|
||||
nodes.append(bulleted_list_node)
|
||||
})
|
||||
continue
|
||||
|
||||
# Check for ordered lists
|
||||
elif re.match(r'^\d+\.\s+', line):
|
||||
# Ordered lists
|
||||
if re.match(r'^\d+\.\s+', stripped):
|
||||
list_items = []
|
||||
while i < len(lines) and re.match(r'^\d+\.\s+', lines[i].strip()):
|
||||
item_text = re.sub(r'^\d+\.\s+', '', lines[i].strip())
|
||||
@@ -359,35 +355,30 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
list_items.append(nested_text)
|
||||
i += 1
|
||||
|
||||
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within LIST_ITEM
|
||||
# NOTE: LIST_ITEM nodes do NOT have a data field per Wix API schema
|
||||
# Wix API: omit empty data objects, don't include them as {}
|
||||
list_node_items = []
|
||||
for item_text in list_items:
|
||||
item_node_id = str(uuid.uuid4())
|
||||
text_nodes = parse_markdown_inline(item_text)
|
||||
paragraph_node = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': text_nodes,
|
||||
}
|
||||
list_item_node = {
|
||||
'id': item_node_id,
|
||||
list_node_items.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'LIST_ITEM',
|
||||
'nodes': [paragraph_node]
|
||||
}
|
||||
list_node_items.append(list_item_node)
|
||||
})
|
||||
|
||||
ordered_list_node = {
|
||||
nodes.append({
|
||||
'id': node_id,
|
||||
'type': 'ORDERED_LIST',
|
||||
'nodes': list_node_items,
|
||||
}
|
||||
nodes.append(ordered_list_node)
|
||||
})
|
||||
continue
|
||||
|
||||
# Check for images
|
||||
elif line.startswith('!['):
|
||||
img_match = re.match(r'!\[([^\]]*)\]\(([^)]+)\)', line)
|
||||
# Images
|
||||
if stripped.startswith('!['):
|
||||
img_match = re.match(r'!\[([^\]]*)\]\(([^)]+)\)', stripped)
|
||||
if img_match:
|
||||
alt_text = img_match.group(1)
|
||||
img_url = img_match.group(2)
|
||||
@@ -407,62 +398,52 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
|
||||
}
|
||||
})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Regular paragraph
|
||||
else:
|
||||
# Collect consecutive non-empty lines as paragraph content
|
||||
para_lines = [line]
|
||||
para_lines = [stripped]
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
next_line = lines[i].strip()
|
||||
if not next_line:
|
||||
break
|
||||
# Stop if next line is a special markdown element
|
||||
if (next_line.startswith('#') or
|
||||
next_line.startswith('- ') or
|
||||
next_line.startswith('* ') or
|
||||
next_line.startswith('>') or
|
||||
next_line.startswith('![') or
|
||||
next_line.startswith('```') or
|
||||
re.match(r'^(---+|\*\*\*|___+)$', next_line) or
|
||||
re.match(r'^\d+\.\s+', next_line)):
|
||||
break
|
||||
para_lines.append(next_line)
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
next_line = lines[i].strip()
|
||||
if not next_line:
|
||||
break
|
||||
# Stop if next line is a special markdown element
|
||||
if (next_line.startswith('#') or
|
||||
next_line.startswith('- ') or
|
||||
next_line.startswith('* ') or
|
||||
next_line.startswith('>') or
|
||||
next_line.startswith('![') or
|
||||
re.match(r'^\d+\.\s+', next_line)):
|
||||
break
|
||||
para_lines.append(next_line)
|
||||
i += 1
|
||||
|
||||
para_text = ' '.join(para_lines)
|
||||
text_nodes = parse_markdown_inline(para_text)
|
||||
|
||||
# Only add paragraph if there are text nodes
|
||||
if text_nodes:
|
||||
paragraph_node = {
|
||||
'id': node_id,
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': text_nodes,
|
||||
}
|
||||
nodes.append(paragraph_node)
|
||||
|
||||
para_text = ' '.join(para_lines)
|
||||
text_nodes = parse_markdown_inline(para_text)
|
||||
|
||||
if text_nodes:
|
||||
nodes.append({
|
||||
'id': node_id,
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': text_nodes,
|
||||
})
|
||||
|
||||
# Ensure at least one node exists
|
||||
# Wix API: omit empty data objects, don't include them as {}
|
||||
if not nodes:
|
||||
fallback_paragraph = {
|
||||
nodes.append({
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'PARAGRAPH',
|
||||
'nodes': [{
|
||||
'id': str(uuid.uuid4()),
|
||||
'type': 'TEXT',
|
||||
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
|
||||
'nodes': [],
|
||||
'textData': {
|
||||
'text': content[:500] if content else "This is a post from ALwrity.",
|
||||
'decorations': []
|
||||
}
|
||||
}],
|
||||
}
|
||||
nodes.append(fallback_paragraph)
|
||||
})
|
||||
|
||||
# Per Wix Blog API documentation: richContent should ONLY contain 'nodes'
|
||||
# Do NOT include 'type', 'id', 'metadata', or 'documentStyle' at root level
|
||||
# These fields are for Ricos Document format, but Blog API expects just the nodes structure
|
||||
return {
|
||||
'nodes': nodes
|
||||
}
|
||||
|
||||
|
||||
return {'nodes': nodes}
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from .retry import wix_api_call_with_retry, WixAPIError
|
||||
|
||||
|
||||
class WixMediaService:
|
||||
"""Service for Wix Media Manager operations with retry logic and error handling."""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url
|
||||
|
||||
def import_image(self, access_token: str, image_url: str, display_name: str) -> Dict[str, Any]:
|
||||
def import_image(self, access_token: str, image_url: str, display_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Import external image to Wix Media Manager.
|
||||
|
||||
Official endpoint: https://www.wixapis.com/site-media/v1/files/import
|
||||
Reference: https://dev.wix.com/docs/rest/assets/media/media-manager/files/import-file
|
||||
|
||||
Args:
|
||||
access_token: Valid access token
|
||||
image_url: URL of the image to import
|
||||
display_name: Display name for the image
|
||||
|
||||
Returns:
|
||||
Media result dict with 'file' key, or None on failure
|
||||
|
||||
Raises:
|
||||
WixAPIError: On non-retryable failure or after retries exhausted
|
||||
"""
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
@@ -22,10 +38,54 @@ class WixMediaService:
|
||||
'mediaType': 'IMAGE',
|
||||
'displayName': display_name,
|
||||
}
|
||||
# Correct endpoint per Wix API documentation
|
||||
endpoint = f"{self.base_url}/site-media/v1/files/import"
|
||||
response = requests.post(endpoint, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
try:
|
||||
result = wix_api_call_with_retry(
|
||||
'POST', endpoint, headers, json_payload=payload, max_attempts=2
|
||||
)
|
||||
if result and 'file' in result and 'id' in result['file']:
|
||||
logger.info(f"Image imported successfully: {result['file']['id'][:16]}...")
|
||||
return result
|
||||
else:
|
||||
logger.warning(f"Image import returned unexpected structure: {list(result.keys()) if isinstance(result, dict) else type(result)}")
|
||||
return None
|
||||
except WixAPIError as e:
|
||||
if e.status_code == 403:
|
||||
logger.error(f"Image import forbidden (403): OAuth app may lack MEDIA.SITE_MEDIA_FILES_IMPORT scope")
|
||||
elif e.status_code == 400:
|
||||
logger.error(f"Image import bad request (400): {e.response_body}")
|
||||
elif e.status_code == 404:
|
||||
logger.error(f"Image import endpoint not found (404) — Wix Media API may not be available for this site")
|
||||
else:
|
||||
logger.error(f"Image import failed after retries: HTTP {e.status_code} - {e.response_body}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error importing image: {e}")
|
||||
raise
|
||||
|
||||
def get_image_url(self, access_token: str, media_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get public URL for a Wix media item.
|
||||
|
||||
Args:
|
||||
access_token: Valid access token
|
||||
media_id: Wix media ID
|
||||
|
||||
Returns:
|
||||
Public URL string, or None
|
||||
"""
|
||||
url = f"{self.base_url}/site-media/v1/files/{media_id}"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
try:
|
||||
result = wix_api_call_with_retry('GET', url, headers, max_attempts=2)
|
||||
if result and 'file' in result:
|
||||
return result['file'].get('url')
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get image URL for {media_id}: {e}")
|
||||
return None
|
||||
|
||||
168
backend/services/integrations/wix/retry.py
Normal file
168
backend/services/integrations/wix/retry.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Retry utilities for Wix API calls with exponential backoff.
|
||||
|
||||
Production-grade retry logic that respects Wix rate limits and handles
|
||||
transient failures gracefully.
|
||||
"""
|
||||
|
||||
import time
|
||||
import random
|
||||
from typing import Callable, TypeVar, Optional
|
||||
from loguru import logger
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class WixAPIError(Exception):
|
||||
"""Custom exception for Wix API errors with status code context."""
|
||||
|
||||
def __init__(self, message: str, status_code: Optional[int] = None, response_body: Optional[str] = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.response_body = response_body
|
||||
|
||||
def is_retryable(self) -> bool:
|
||||
"""Determine if this error is retryable based on status code."""
|
||||
if self.status_code is None:
|
||||
return True # Network errors are retryable
|
||||
# 429 = rate limit, 502/503/504 = gateway errors, 500 = internal server error (sometimes transient)
|
||||
return self.status_code in (429, 500, 502, 503, 504)
|
||||
|
||||
def is_rate_limit(self) -> bool:
|
||||
"""Check if this is a rate limit error."""
|
||||
return self.status_code == 429
|
||||
|
||||
|
||||
def with_retry(
|
||||
fn: Callable[[], T],
|
||||
max_attempts: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 30.0,
|
||||
retryable_exceptions: tuple = (Exception,),
|
||||
operation_name: str = "Wix API call"
|
||||
) -> T:
|
||||
"""
|
||||
Execute a function with exponential backoff retry logic.
|
||||
|
||||
Args:
|
||||
fn: Function to execute (should make the API call)
|
||||
max_attempts: Maximum number of attempts (default: 3)
|
||||
base_delay: Initial delay in seconds (default: 1.0)
|
||||
max_delay: Maximum delay in seconds (default: 30.0)
|
||||
retryable_exceptions: Tuple of exception types to retry on
|
||||
operation_name: Name for logging
|
||||
|
||||
Returns:
|
||||
Result of fn()
|
||||
|
||||
Raises:
|
||||
WixAPIError: If all retries are exhausted
|
||||
Exception: If a non-retryable exception occurs
|
||||
"""
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
return fn()
|
||||
except WixAPIError as e:
|
||||
last_exception = e
|
||||
if attempt >= max_attempts:
|
||||
break
|
||||
if not e.is_retryable():
|
||||
logger.warning(f"{operation_name}: non-retryable error (HTTP {e.status_code}), failing fast")
|
||||
raise
|
||||
|
||||
# Calculate delay with exponential backoff and jitter
|
||||
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
||||
# Add jitter (±25%) to prevent thundering herd
|
||||
jitter = delay * 0.25
|
||||
actual_delay = delay + random.uniform(-jitter, jitter)
|
||||
actual_delay = max(0.1, actual_delay) # Minimum 100ms delay
|
||||
|
||||
if e.is_rate_limit():
|
||||
# For rate limits, use a longer base delay
|
||||
actual_delay = max(actual_delay, 2.0)
|
||||
logger.warning(f"{operation_name}: rate limited (429), waiting {actual_delay:.1f}s before retry {attempt + 1}/{max_attempts}")
|
||||
else:
|
||||
logger.warning(f"{operation_name}: attempt {attempt}/{max_attempts} failed (HTTP {e.status_code}), waiting {actual_delay:.1f}s before retry")
|
||||
|
||||
time.sleep(actual_delay)
|
||||
|
||||
except retryable_exceptions as e:
|
||||
last_exception = e
|
||||
if attempt >= max_attempts:
|
||||
break
|
||||
|
||||
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
||||
jitter = delay * 0.25
|
||||
actual_delay = delay + random.uniform(-jitter, jitter)
|
||||
actual_delay = max(0.1, actual_delay)
|
||||
|
||||
logger.warning(f"{operation_name}: attempt {attempt}/{max_attempts} failed ({type(e).__name__}), waiting {actual_delay:.1f}s before retry")
|
||||
time.sleep(actual_delay)
|
||||
|
||||
# All retries exhausted
|
||||
if last_exception:
|
||||
if isinstance(last_exception, WixAPIError):
|
||||
raise last_exception
|
||||
raise WixAPIError(f"{operation_name}: failed after {max_attempts} attempts: {last_exception}")
|
||||
|
||||
raise WixAPIError(f"{operation_name}: failed after {max_attempts} attempts")
|
||||
|
||||
|
||||
def wix_api_call_with_retry(
|
||||
method: str,
|
||||
url: str,
|
||||
headers: dict,
|
||||
json_payload: Optional[dict] = None,
|
||||
max_attempts: int = 3
|
||||
) -> dict:
|
||||
"""
|
||||
Convenience wrapper for making Wix API calls with retry logic.
|
||||
|
||||
Args:
|
||||
method: HTTP method ('GET', 'POST', etc.)
|
||||
url: Full API URL
|
||||
headers: Request headers
|
||||
json_payload: Optional JSON payload for POST/PUT
|
||||
max_attempts: Maximum retry attempts
|
||||
|
||||
Returns:
|
||||
Parsed JSON response
|
||||
|
||||
Raises:
|
||||
WixAPIError: On failure after retries
|
||||
"""
|
||||
import requests
|
||||
|
||||
def _call():
|
||||
if method.upper() == 'GET':
|
||||
resp = requests.get(url, headers=headers, timeout=30)
|
||||
elif method.upper() == 'POST':
|
||||
resp = requests.post(url, headers=headers, json=json_payload, timeout=30)
|
||||
elif method.upper() == 'PUT':
|
||||
resp = requests.put(url, headers=headers, json=json_payload, timeout=30)
|
||||
elif method.upper() == 'DELETE':
|
||||
resp = requests.delete(url, headers=headers, timeout=30)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
if resp.status_code >= 400:
|
||||
body = None
|
||||
try:
|
||||
body = resp.text[:500]
|
||||
except:
|
||||
body = str(resp.content)[:500]
|
||||
raise WixAPIError(
|
||||
f"Wix API {method} {url} failed: HTTP {resp.status_code}",
|
||||
status_code=resp.status_code,
|
||||
response_body=body
|
||||
)
|
||||
|
||||
return resp.json()
|
||||
|
||||
return with_retry(
|
||||
_call,
|
||||
max_attempts=max_attempts,
|
||||
operation_name=f"Wix {method} {url.split('/')[-1]}"
|
||||
)
|
||||
@@ -85,24 +85,45 @@ def decode_wix_token(access_token: str) -> Dict[str, Any]:
|
||||
if token_str.startswith('OauthNG.JWS.'):
|
||||
jwt_part = token_str[12:]
|
||||
return jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
|
||||
if token_str.startswith('IST.'):
|
||||
jwt_part = token_str[4:]
|
||||
return jwt.decode(jwt_part, options={"verify_signature": False, "verify_aud": False})
|
||||
return jwt.decode(token_str, options={"verify_signature": False, "verify_aud": False})
|
||||
|
||||
|
||||
def _extract_data_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
data_payload = payload.get('data', {})
|
||||
if isinstance(data_payload, str):
|
||||
try:
|
||||
data_payload = json.loads(data_payload)
|
||||
except Exception:
|
||||
data_payload = {}
|
||||
return data_payload if isinstance(data_payload, dict) else {}
|
||||
|
||||
|
||||
def extract_meta_from_token(access_token: str) -> Dict[str, Optional[str]]:
|
||||
try:
|
||||
payload = decode_wix_token(access_token)
|
||||
data_payload = payload.get('data', {})
|
||||
if isinstance(data_payload, str):
|
||||
try:
|
||||
data_payload = json.loads(data_payload)
|
||||
except Exception:
|
||||
pass
|
||||
instance = (data_payload or {}).get('instance', {})
|
||||
return {
|
||||
data_payload = _extract_data_payload(payload)
|
||||
instance = (data_payload or {}).get('instance', {}) or {}
|
||||
result = {
|
||||
'siteMemberId': instance.get('siteMemberId'),
|
||||
'metaSiteId': instance.get('metaSiteId'),
|
||||
'permissions': instance.get('permissions'),
|
||||
}
|
||||
# Only fall back to tenant.id for OAuth tokens (not IST. API keys)
|
||||
# IST. tokens have tenant.id = account_id, which is NOT the site metaSiteId
|
||||
token_str = str(access_token)
|
||||
if not result.get('metaSiteId') and not token_str.startswith('IST.'):
|
||||
tenant = data_payload.get('tenant', {}) or {}
|
||||
tenant_id = tenant.get('id')
|
||||
if tenant_id:
|
||||
result['metaSiteId'] = tenant_id
|
||||
if not result.get('metaSiteId'):
|
||||
meta_site_id = payload.get('metaSiteId') or payload.get('site_id')
|
||||
if meta_site_id:
|
||||
result['metaSiteId'] = meta_site_id
|
||||
return result
|
||||
except Exception:
|
||||
return {'siteMemberId': None, 'metaSiteId': None, 'permissions': None}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user