feat: Add Poste.io and Chatwoot initial setup Playwright endpoints
- Add POST /tenants/{tenant_id}/poste/setup endpoint for Poste.io mail
server initial setup via Playwright browser automation
- Add POST /tenants/{tenant_id}/chatwoot/initial-setup endpoint for
Chatwoot super admin creation via Playwright
- Add health check functions for Poste.io and Chatwoot availability
- Add request models with email validation for both services
- Passwords auto-generated if not provided, returned in task result
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d85ab9c493
commit
9e8c9931b4
|
|
@ -100,3 +100,108 @@ async def create_chatwoot_setup_task(
|
|||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Setup via Playwright
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_chatwoot_initial_setup_step(
|
||||
*,
|
||||
base_url: str,
|
||||
admin_name: str,
|
||||
company_name: str,
|
||||
admin_email: str,
|
||||
admin_password: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a PLAYWRIGHT task payload for Chatwoot initial setup.
|
||||
|
||||
This creates the super admin account on a fresh Chatwoot installation.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for Chatwoot (e.g., "https://chatwoot.example.com")
|
||||
admin_name: Full name for the admin account
|
||||
company_name: Company/organization name
|
||||
admin_email: Email address for the admin account
|
||||
admin_password: Password for admin (auto-generated if None)
|
||||
|
||||
Returns:
|
||||
Task payload dict with type="PLAYWRIGHT"
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Extract domain from URL for allowlist
|
||||
parsed = urlparse(base_url)
|
||||
allowed_domain = parsed.netloc
|
||||
|
||||
inputs: dict[str, Any] = {
|
||||
"base_url": base_url,
|
||||
"admin_name": admin_name,
|
||||
"company_name": company_name,
|
||||
"admin_email": admin_email,
|
||||
}
|
||||
|
||||
# Only include password if provided
|
||||
if admin_password:
|
||||
inputs["admin_password"] = admin_password
|
||||
|
||||
return {
|
||||
"scenario": "chatwoot_initial_setup",
|
||||
"inputs": inputs,
|
||||
"options": {
|
||||
"allowed_domains": [allowed_domain],
|
||||
},
|
||||
"timeout": 120,
|
||||
}
|
||||
|
||||
|
||||
async def create_chatwoot_initial_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
base_url: str,
|
||||
admin_name: str,
|
||||
company_name: str,
|
||||
admin_email: str,
|
||||
admin_password: str | None = None,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a PLAYWRIGHT task for Chatwoot initial setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: UUID of the agent to assign the task to
|
||||
base_url: The base URL for Chatwoot
|
||||
admin_name: Full name for the admin account
|
||||
company_name: Company/organization name
|
||||
admin_email: Email address for the admin account
|
||||
admin_password: Password for admin (auto-generated if None)
|
||||
|
||||
Returns:
|
||||
The created Task object with type="PLAYWRIGHT"
|
||||
"""
|
||||
payload = build_chatwoot_initial_setup_step(
|
||||
base_url=base_url,
|
||||
admin_name=admin_name,
|
||||
company_name=company_name,
|
||||
admin_email=admin_email,
|
||||
admin_password=admin_password,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="PLAYWRIGHT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
"""Poste.io mail server deployment playbook.
|
||||
|
||||
Defines the steps required to:
|
||||
1. Perform initial setup via Playwright automation (configure hostname, create admin account)
|
||||
|
||||
Tenant servers must have stacks and env templates under /opt/letsbe.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.task import Task, TaskStatus
|
||||
|
||||
|
||||
# LetsBe standard paths
|
||||
POSTE_STACK_DIR = "/opt/letsbe/stacks/poste"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Setup via Playwright
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_poste_initial_setup_step(
|
||||
*,
|
||||
base_url: str,
|
||||
admin_email: str,
|
||||
admin_password: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a PLAYWRIGHT task payload for Poste.io initial setup.
|
||||
|
||||
This configures the mail server hostname and creates the admin account
|
||||
on a fresh Poste.io installation.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for Poste.io (e.g., "https://mail.example.com")
|
||||
admin_email: Email address for the admin account (e.g., admin@example.com)
|
||||
admin_password: Password for the admin account (auto-generated if None)
|
||||
|
||||
Returns:
|
||||
Task payload dict with type="PLAYWRIGHT"
|
||||
"""
|
||||
# Extract domain from URL for allowlist
|
||||
parsed = urlparse(base_url)
|
||||
allowed_domain = parsed.netloc # e.g., "mail.example.com"
|
||||
|
||||
inputs: dict[str, Any] = {
|
||||
"base_url": base_url,
|
||||
"admin_email": admin_email,
|
||||
}
|
||||
|
||||
# Only include password if provided - scenario will auto-generate if missing
|
||||
if admin_password:
|
||||
inputs["admin_password"] = admin_password
|
||||
|
||||
return {
|
||||
"scenario": "poste_initial_setup",
|
||||
"inputs": inputs,
|
||||
"options": {
|
||||
"allowed_domains": [allowed_domain],
|
||||
},
|
||||
"timeout": 120,
|
||||
}
|
||||
|
||||
|
||||
async def create_poste_initial_setup_task(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
base_url: str,
|
||||
admin_email: str,
|
||||
admin_password: str | None = None,
|
||||
) -> Task:
|
||||
"""
|
||||
Create and persist a PLAYWRIGHT task for Poste.io initial setup.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
tenant_id: UUID of the tenant
|
||||
agent_id: UUID of the agent to assign the task to
|
||||
base_url: The base URL for Poste.io
|
||||
admin_email: Email address for the admin account
|
||||
admin_password: Password for admin (auto-generated if None)
|
||||
|
||||
Returns:
|
||||
The created Task object with type="PLAYWRIGHT"
|
||||
"""
|
||||
payload = build_poste_initial_setup_step(
|
||||
base_url=base_url,
|
||||
admin_email=admin_email,
|
||||
admin_password=admin_password,
|
||||
)
|
||||
|
||||
task = Task(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
type="PLAYWRIGHT",
|
||||
payload=payload,
|
||||
status=TaskStatus.PENDING.value,
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
|
||||
return task
|
||||
|
|
@ -12,17 +12,21 @@ from app.db import AsyncSessionDep
|
|||
from app.models.agent import Agent
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
from app.playbooks.chatwoot import create_chatwoot_setup_task
|
||||
from app.playbooks.chatwoot import (
|
||||
create_chatwoot_initial_setup_task,
|
||||
create_chatwoot_setup_task,
|
||||
)
|
||||
from app.playbooks.nextcloud import (
|
||||
create_nextcloud_initial_setup_task,
|
||||
create_nextcloud_set_domain_task,
|
||||
)
|
||||
from app.playbooks.poste import create_poste_initial_setup_task
|
||||
from app.schemas.task import TaskResponse
|
||||
|
||||
router = APIRouter(prefix="/tenants/{tenant_id}", tags=["Playbooks"])
|
||||
|
||||
# Health check timeout for Nextcloud availability
|
||||
NEXTCLOUD_HEALTH_CHECK_TIMEOUT = 10.0
|
||||
HEALTH_CHECK_TIMEOUT = 10.0
|
||||
|
||||
|
||||
class ChatwootSetupRequest(BaseModel):
|
||||
|
|
@ -78,6 +82,68 @@ class NextcloudInitialSetupRequest(BaseModel):
|
|||
return v
|
||||
|
||||
|
||||
|
||||
|
||||
class PosteInitialSetupRequest(BaseModel):
|
||||
"""Request body for Poste.io initial setup via Playwright."""
|
||||
|
||||
admin_email: str = Field(
|
||||
...,
|
||||
min_length=5,
|
||||
description="Email address for the Poste.io admin account (e.g., admin@example.com)",
|
||||
)
|
||||
admin_password: str | None = Field(
|
||||
None,
|
||||
min_length=8,
|
||||
description="Password for admin account (auto-generated if not provided)",
|
||||
)
|
||||
|
||||
@field_validator("admin_email")
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
"""Validate that admin_email is a valid email address."""
|
||||
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||
if not re.match(email_pattern, v):
|
||||
raise ValueError("admin_email must be a valid email address")
|
||||
return v
|
||||
|
||||
|
||||
class ChatwootInitialSetupRequest(BaseModel):
|
||||
"""Request body for Chatwoot initial setup via Playwright."""
|
||||
|
||||
admin_name: str = Field(
|
||||
...,
|
||||
min_length=2,
|
||||
max_length=100,
|
||||
description="Full name for the Chatwoot admin account",
|
||||
)
|
||||
company_name: str = Field(
|
||||
...,
|
||||
min_length=2,
|
||||
max_length=100,
|
||||
description="Company/organization name",
|
||||
)
|
||||
admin_email: str = Field(
|
||||
...,
|
||||
min_length=5,
|
||||
description="Email address for the Chatwoot admin account",
|
||||
)
|
||||
admin_password: str | None = Field(
|
||||
None,
|
||||
min_length=8,
|
||||
description="Password for admin account (auto-generated if not provided)",
|
||||
)
|
||||
|
||||
@field_validator("admin_email")
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
"""Validate that admin_email is a valid email address."""
|
||||
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||
if not re.match(email_pattern, v):
|
||||
raise ValueError("admin_email must be a valid email address")
|
||||
return v
|
||||
|
||||
|
||||
# --- Helper functions ---
|
||||
|
||||
|
||||
|
|
@ -123,7 +189,7 @@ async def check_nextcloud_availability(base_url: str) -> bool:
|
|||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=NEXTCLOUD_HEALTH_CHECK_TIMEOUT,
|
||||
timeout=HEALTH_CHECK_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
response = await client.get(status_url)
|
||||
|
|
@ -146,6 +212,58 @@ async def check_nextcloud_availability(base_url: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
async def check_poste_availability(base_url: str) -> bool:
|
||||
"""
|
||||
Check if Poste.io is available at the given URL.
|
||||
|
||||
Performs a health check against the base URL to verify the service is up.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for Poste.io (e.g., "https://mail.example.com")
|
||||
|
||||
Returns:
|
||||
True if Poste.io responds, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=HEALTH_CHECK_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
response = await client.get(base_url.rstrip("/"))
|
||||
# Poste.io returns 200 on setup page or login page
|
||||
return response.status_code == 200
|
||||
|
||||
except (httpx.RequestError, httpx.TimeoutException):
|
||||
return False
|
||||
|
||||
|
||||
async def check_chatwoot_availability(base_url: str) -> bool:
|
||||
"""
|
||||
Check if Chatwoot is available at the given URL.
|
||||
|
||||
Performs a health check against the base URL to verify the service is up.
|
||||
|
||||
Args:
|
||||
base_url: The base URL for Chatwoot (e.g., "https://chatwoot.example.com")
|
||||
|
||||
Returns:
|
||||
True if Chatwoot responds, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=HEALTH_CHECK_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
response = await client.get(base_url.rstrip("/"))
|
||||
# Chatwoot returns 200 on setup page or login page
|
||||
return response.status_code == 200
|
||||
|
||||
except (httpx.RequestError, httpx.TimeoutException):
|
||||
return False
|
||||
|
||||
|
||||
# --- Route handlers ---
|
||||
|
||||
|
||||
|
|
@ -345,3 +463,188 @@ async def nextcloud_initial_setup(
|
|||
)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.post(
|
||||
"/poste/setup",
|
||||
response_model=TaskResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def poste_initial_setup(
|
||||
tenant_id: uuid.UUID,
|
||||
request: PosteInitialSetupRequest,
|
||||
db: AsyncSessionDep,
|
||||
) -> Task:
|
||||
"""
|
||||
Perform initial Poste.io setup via Playwright browser automation.
|
||||
|
||||
Creates a PLAYWRIGHT task that navigates to the Poste.io setup wizard,
|
||||
configures the mailserver hostname, and creates the admin account.
|
||||
This should only be called once on a fresh Poste.io installation.
|
||||
|
||||
**Prerequisites:**
|
||||
- Tenant must have a domain configured
|
||||
- Poste.io must be deployed and accessible at `https://mail.{domain}`
|
||||
- An online agent must be registered for the tenant
|
||||
|
||||
**Health Check:**
|
||||
Before creating the task, the endpoint checks if Poste.io is available.
|
||||
If not reachable, returns 409.
|
||||
|
||||
## Request Body
|
||||
- **admin_email**: Email address for the admin account (e.g., admin@example.com)
|
||||
- **admin_password**: Password for admin (auto-generated if not provided)
|
||||
|
||||
## Response
|
||||
Returns the created Task with type="PLAYWRIGHT" and status="pending".
|
||||
|
||||
The task result will contain the generated/provided credentials.
|
||||
|
||||
## Errors
|
||||
- **404**: Tenant not found
|
||||
- **400**: Tenant has no domain configured
|
||||
- **404**: No online agent found for tenant
|
||||
- **409**: Poste.io not available (health check failed)
|
||||
- **422**: Invalid email format
|
||||
"""
|
||||
# Validate tenant exists
|
||||
tenant = await get_tenant_by_id(db, tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {tenant_id} not found",
|
||||
)
|
||||
|
||||
# Validate tenant has domain configured
|
||||
if not tenant.domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Tenant {tenant_id} has no domain configured",
|
||||
)
|
||||
|
||||
# Auto-resolve agent (first online agent for tenant)
|
||||
agent = await get_online_agent_for_tenant(db, tenant_id)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No online agent found for tenant {tenant_id}",
|
||||
)
|
||||
|
||||
# Construct Poste.io base URL from tenant domain
|
||||
base_url = f"https://mail.{tenant.domain}"
|
||||
|
||||
# Health check: verify Poste.io is accessible
|
||||
is_available = await check_poste_availability(base_url)
|
||||
if not is_available:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Poste.io not available at {base_url}. "
|
||||
"Ensure Poste.io is deployed and accessible before running setup.",
|
||||
)
|
||||
|
||||
# Create the PLAYWRIGHT task
|
||||
task = await create_poste_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
base_url=base_url,
|
||||
admin_email=request.admin_email,
|
||||
admin_password=request.admin_password,
|
||||
)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chatwoot/initial-setup",
|
||||
response_model=TaskResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def chatwoot_initial_setup(
|
||||
tenant_id: uuid.UUID,
|
||||
request: ChatwootInitialSetupRequest,
|
||||
db: AsyncSessionDep,
|
||||
) -> Task:
|
||||
"""
|
||||
Perform initial Chatwoot setup via Playwright browser automation.
|
||||
|
||||
Creates a PLAYWRIGHT task that navigates to the Chatwoot setup wizard,
|
||||
creates the super admin account, unchecks newsletter subscription,
|
||||
and completes the setup. This should only be called once on a fresh
|
||||
Chatwoot installation.
|
||||
|
||||
**Prerequisites:**
|
||||
- Tenant must have a domain configured
|
||||
- Chatwoot must be deployed and accessible at `https://chatwoot.{domain}`
|
||||
- An online agent must be registered for the tenant
|
||||
|
||||
**Health Check:**
|
||||
Before creating the task, the endpoint checks if Chatwoot is available.
|
||||
If not reachable, returns 409.
|
||||
|
||||
## Request Body
|
||||
- **admin_name**: Full name for the admin account
|
||||
- **company_name**: Company/organization name
|
||||
- **admin_email**: Email address for the admin account
|
||||
- **admin_password**: Password for admin (auto-generated if not provided)
|
||||
|
||||
## Response
|
||||
Returns the created Task with type="PLAYWRIGHT" and status="pending".
|
||||
|
||||
The task result will contain the generated/provided credentials.
|
||||
|
||||
## Errors
|
||||
- **404**: Tenant not found
|
||||
- **400**: Tenant has no domain configured
|
||||
- **404**: No online agent found for tenant
|
||||
- **409**: Chatwoot not available (health check failed)
|
||||
- **422**: Invalid email format
|
||||
"""
|
||||
# Validate tenant exists
|
||||
tenant = await get_tenant_by_id(db, tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tenant {tenant_id} not found",
|
||||
)
|
||||
|
||||
# Validate tenant has domain configured
|
||||
if not tenant.domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Tenant {tenant_id} has no domain configured",
|
||||
)
|
||||
|
||||
# Auto-resolve agent (first online agent for tenant)
|
||||
agent = await get_online_agent_for_tenant(db, tenant_id)
|
||||
if agent is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No online agent found for tenant {tenant_id}",
|
||||
)
|
||||
|
||||
# Construct Chatwoot base URL from tenant domain
|
||||
base_url = f"https://chatwoot.{tenant.domain}"
|
||||
|
||||
# Health check: verify Chatwoot is accessible
|
||||
is_available = await check_chatwoot_availability(base_url)
|
||||
if not is_available:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Chatwoot not available at {base_url}. "
|
||||
"Ensure Chatwoot is deployed and accessible before running setup.",
|
||||
)
|
||||
|
||||
# Create the PLAYWRIGHT task
|
||||
task = await create_chatwoot_initial_setup_task(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
base_url=base_url,
|
||||
admin_name=request.admin_name,
|
||||
company_name=request.company_name,
|
||||
admin_email=request.admin_email,
|
||||
admin_password=request.admin_password,
|
||||
)
|
||||
|
||||
return task
|
||||
|
|
|
|||
Loading…
Reference in New Issue