feat: Scaffold Novi-Mailman Bridge integration
- app/methods/e_novi_mailman_methods.py: full sync engine, per-member sync helper, webhook handler, and Mailman 3 REST subscribe/unsubscribe - app/routers/api_v3_actions_e_novi_mailman.py: test_connection, list inspection, full sync trigger, and Novi webhook receiver endpoints - registry.py: registered at /v3/action/e_novi_mailman - TODO: marked as scaffolded, pending Novi field verification + data_store setup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
116
app/routers/api_v3_actions_e_novi_mailman.py
Normal file
116
app/routers/api_v3_actions_e_novi_mailman.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from fastapi import APIRouter, Body, Depends, Query
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
|
||||
from app.lib_general import log
|
||||
from app.lib_general_v3 import AccountContext, get_account_context, DelayParams
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
from app.methods.e_novi_mailman_methods import (
|
||||
test_novi_connection,
|
||||
test_mailman_connection,
|
||||
get_mailman_lists,
|
||||
get_novi_members,
|
||||
sync_novi_to_mailman,
|
||||
handle_novi_webhook,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Connection Tests ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get('/test_connection/novi', response_model=Resp_Body_Base)
|
||||
async def test_novi(
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Verify Novi AMS API credentials stored in data_store."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
result = test_novi_connection()
|
||||
if result.get('ok'):
|
||||
return mk_resp(data=result)
|
||||
return mk_resp(data=result, status_code=401, status_message="Novi connection failed.")
|
||||
|
||||
|
||||
@router.get('/test_connection/mailman', response_model=Resp_Body_Base)
|
||||
async def test_mailman(
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Verify Mailman 3 REST API credentials stored in data_store."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
result = test_mailman_connection()
|
||||
if result.get('ok'):
|
||||
return mk_resp(data=result)
|
||||
return mk_resp(data=result, status_code=401, status_message="Mailman connection failed.")
|
||||
|
||||
|
||||
# ── Inspection / Preview ──────────────────────────────────────────────────
|
||||
|
||||
@router.get('/mailman/lists', response_model=Resp_Body_Base)
|
||||
async def list_mailman_lists(
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Return all mailing lists from this Mailman 3 instance."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
data = get_mailman_lists()
|
||||
if data is not None:
|
||||
return mk_resp(data=data)
|
||||
return mk_resp(data=False, status_code=500, status_message="Failed to fetch Mailman lists.")
|
||||
|
||||
|
||||
@router.get('/novi/members', response_model=Resp_Body_Base)
|
||||
async def list_novi_members(
|
||||
status_filter: Optional[str] = Query(None, description="Novi membership status filter (e.g. 'Active', 'Lapsed')"),
|
||||
page_size: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Fetch a page of Novi AMS members. Useful for inspecting data before a full sync."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
data = get_novi_members(status_filter=status_filter, page_size=page_size, offset=offset)
|
||||
if data is not None:
|
||||
return mk_resp(data={"count": len(data), "members": data})
|
||||
return mk_resp(data=False, status_code=500, status_message="Failed to fetch members from Novi.")
|
||||
|
||||
|
||||
# ── Sync ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post('/sync', response_model=Resp_Body_Base)
|
||||
async def sync_full(
|
||||
list_id: str = Query(..., description="Target Mailman list ID, e.g. 'members@yourdomain.org'"),
|
||||
active_status: str = Query('Active', description="Novi membership status that maps to 'subscribed'"),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Full sync: pull all Novi members and reconcile Mailman subscription state.
|
||||
Active members are subscribed; all others are unsubscribed.
|
||||
This is the manual / scheduled trigger — the webhook handles real-time updates.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
result = sync_novi_to_mailman(list_id=list_id, active_status=active_status)
|
||||
if result:
|
||||
return mk_resp(data=result, status_message="Novi → Mailman sync complete.")
|
||||
return mk_resp(data=False, status_code=500, status_message="Sync failed.")
|
||||
|
||||
|
||||
# ── Webhook ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post('/webhook/novi', response_model=Resp_Body_Base, include_in_schema=True)
|
||||
async def novi_membership_webhook(
|
||||
payload: dict = Body(...),
|
||||
):
|
||||
"""
|
||||
Receives Novi AMS membership webhook events and immediately updates
|
||||
the corresponding Mailman subscription — no auth required (Novi pushes to this endpoint).
|
||||
|
||||
TODO: Add HMAC signature verification once Novi webhook secret is configured.
|
||||
"""
|
||||
log.info(f"Novi webhook received: EventType={payload.get('EventType')} Email={payload.get('Member', {}).get('Email')}")
|
||||
result = handle_novi_webhook(payload)
|
||||
if result:
|
||||
return mk_resp(data=result)
|
||||
return mk_resp(data=False, status_code=400, status_message="Webhook payload could not be processed.")
|
||||
@@ -7,7 +7,7 @@ from app.routers import (
|
||||
event_device, event_exhibit, event_exhibit_tracking, event_file, event_importing,
|
||||
event_location, event_person,
|
||||
event_presentation, event_presenter, event_session,
|
||||
flask_cfg, hosted_file, api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_e_zoom, lookup, lookup_v3,
|
||||
flask_cfg, hosted_file, api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, lookup, lookup_v3,
|
||||
organization, page, person,
|
||||
person_user, qr, site, site_domain, user,
|
||||
util_email, websockets, websockets_redis, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
|
||||
@@ -51,6 +51,7 @@ def setup_routers(app: FastAPI):
|
||||
app.include_router(api_v3_actions_hosted_file.router, prefix='/v3/action/hosted_file', tags=['Hosted File (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_event_file.router, prefix='/v3/action/event_file', tags=['Event File (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_e_novi_mailman.router, prefix='/v3/action/e_novi_mailman', tags=['Novi-Mailman Bridge (V3 Actions)'])
|
||||
app.include_router(lookup.router, prefix='/lu', tags=['Lookup'])
|
||||
app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3'])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user