CAP Composer Integration Guide¶
Overview¶
This document describes all steps needed to integrate the CAP (Common Alerting Protocol) Composer into FloodWatch. CAP Composer (cap-composer by WMO-RAF) is a Wagtail application that handles alert authoring, CAP 1.2 XML serialization, and distribution (RSS, GeoJSON, webhooks, MQTT).
The integration lets forecasters generate CAP flood alerts directly from their expert assessments in the FloodWatch map viewer.
Architecture decision: CAP Composer runs inside the existing eafw_cms Django process (not as a separate service), since it's a Wagtail app that uses Page models, BaseSiteSetting, and wagtail_hooks.
Prerequisites¶
| Item | Current | Required |
|---|---|---|
| Wagtail | >=6.3.6,<6.4 |
>=7.0,<7.1 |
| cap-composer source | Available at hazard_watch/cap-composer/capcomposer/ |
Same |
| Redis | Not deployed | Needed for Celery (Module 7 only) |
Module 1: Wagtail Upgrade + CAP App Installation¶
Risk: HIGH -- Wagtail 6->7 has breaking changes. Test thoroughly.
Step 1.1: Fix get_full_url deprecation in geomanager¶
wagtail.api.v2.utils.get_full_url was removed in Wagtail 7. The geomanager package imports it in 11+ files.
Create compat function in eafw_cms/geomanager/geomanager/utils/__init__.py:
from urllib.parse import urljoin
from django.conf import settings
def get_full_url(request, path):
"""
Compatibility shim replacing wagtail.api.v2.utils.get_full_url
which was removed in Wagtail 7.
"""
if not path:
path = ""
cms_base_url = getattr(settings, "CMS_BASE_URL", None)
if cms_base_url:
if path and not path.startswith("/"):
path = "/" + path
return urljoin(cms_base_url, path)
if request:
return request.build_absolute_uri(path)
return path
Replace imports in all these files (change from wagtail.api.v2.utils import get_full_url to from geomanager.utils import get_full_url):
geomanager/wagtail_hooks.py
geomanager/views/vector_tile.py
geomanager/views/vector_file.py
geomanager/views/tile_gl.py
geomanager/views/raster_file.py
geomanager/views/core.py
geomanager/models/boundary.py
geomanager/models/geomanager_settings.py
geomanager/models/vector_file.py
geomanager/models/raster_file.py
geomanager/models/tile_base.py
geomanager/models/wms.py
Also update geomanager/serializers/core.py to use the shared function:
# Replace the local _get_full_url with:
from geomanager.utils import get_full_url as _get_full_url
# Delete the inline _get_full_url function definition
Step 1.2: Update geomanager version constraint¶
In eafw_cms/geomanager/pyproject.toml, change:
Step 1.3: Update CMS dependencies¶
In eafw_cms/pyproject.toml:
[project]
dependencies = [
# ... existing deps ...
"wagtail>=7.0,<7.1", # WAS: "wagtail>=6.3.6,<6.4"
# CAP Composer dependencies (add these):
"capcomposer",
"wagtail-modelchooser>=4.0.1",
"wagtail-newsletter==0.2.2",
"paho-mqtt>=2.1.0",
"xmltodict>=0.14.2",
"pdf2image>=1.17.0",
"lxml>=5.4.0",
"signxml>=4.0.3",
"loguru>=0.7.3",
"feedparser>=6.0.11",
"staticmap==0.5.7",
"capvalidator==0.1.0.dev4",
"python-magic",
"mailchimp-marketing==3.0.80",
"mrml>=0.2",
"django-celery-beat",
]
[tool.uv.sources]
geomanager = { path = "./geomanager" }
capcomposer = { path = "../hazard_watch/cap-composer/capcomposer" } # ADD THIS
Step 1.4: Add cap-composer to Python path¶
In eafw_cms/geomanagerweb/settings/base.py, after the GEOMANAGER_PATH block:
CAP_COMPOSER_PATH = '/path/to/hazard_watch/cap-composer/capcomposer/src'
if CAP_COMPOSER_PATH not in sys.path:
sys.path.insert(0, CAP_COMPOSER_PATH)
Note: In Docker, this path should be set via env var or relative to the project root.
Step 1.5: Add CAP apps to INSTALLED_APPS¶
In eafw_cms/geomanagerweb/settings/base.py, add to the beginning of INSTALLED_APPS (after "geomanager"):
INSTALLED_APPS = [
"home",
"geomanager",
# CAP Composer apps
"capcomposer.capeditor",
"capcomposer.cap",
"wagtail_modelchooser",
"wagtail_newsletter",
"django_celery_beat",
# ... rest of existing apps ...
]
Step 1.6: Add CAP URL patterns¶
In eafw_cms/geomanagerweb/urls.py, add before the Wagtail catch-all:
urlpatterns = [
# ... existing patterns ...
path("", include("geomanager.urls")),
path("", include("django_nextjs.urls")),
# CAP Composer URLs (RSS, GeoJSON, XML feeds)
path("", include("capcomposer.cap.urls")),
]
This registers these endpoints (built into cap-composer):
- GET /api/cap/rss.xml -- RSS feed of CAP alerts
- GET /api/cap/alerts.geojson -- GeoJSON of active alerts
- GET /api/cap/<uuid>.xml -- Individual CAP XML alert
- GET /home-map-alerts/ -- Map alerts for homepage
- GET /latest-active-alert/ -- Latest active alert
Step 1.7: Install & verify¶
cd eafw_cms
uv sync
python manage.py check # Should pass with no errors
python manage.py migrate # Creates CAP tables in cms schema
# Test Wagtail admin loads, existing pages render, geomanager panels work
Known Wagtail 7 breaking changes to watch for¶
| Change | Impact | Fix |
|---|---|---|
wagtail.api.v2.utils.get_full_url removed |
geomanager (11 files) | Compat shim (Step 1.1) |
wagtail.contrib.forms may be deprecated |
Listed in INSTALLED_APPS | Remove if not using form submissions |
StreamField use_json_field required |
Already set in geomanager | No action needed |
Module 2: CAP Configuration Seed Data¶
Goal: Populate CAP settings so alert creation is possible from Wagtail admin.
Step 2.1: Create CapAlertListPage migration¶
Create eafw_cms/home/migrations/0037_create_cap_alert_list_page.py:
from django.db import migrations
def create_cap_alert_list_page(apps, schema_editor):
ContentType = apps.get_model("contenttypes", "ContentType")
Page = apps.get_model("wagtailcore", "Page")
CapAlertListPage = apps.get_model("cap", "CapAlertListPage")
# Get or create content type
ct, _ = ContentType.objects.get_or_create(
app_label="cap", model="capalertlistpage"
)
# Get root page
root_page = Page.objects.filter(depth=1).first()
if not root_page:
return
# Check if already exists
if CapAlertListPage.objects.exists():
return
# Create as child of root
list_page = CapAlertListPage(
title="CAP Alerts",
slug="cap-alerts",
heading="CAP Flood Alerts",
content_type=ct,
path=root_page.path + "0005", # Adjust based on existing children
depth=root_page.depth + 1,
)
root_page.add_child(instance=list_page)
list_page.save_revision().publish()
def reverse(apps, schema_editor):
CapAlertListPage = apps.get_model("cap", "CapAlertListPage")
CapAlertListPage.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
("home", "0036_update_alert_thresholds"),
("cap", "__first__"),
("wagtailcore", "__latest__"),
]
operations = [
migrations.RunPython(create_cap_alert_list_page, reverse),
]
Step 2.2: Create CAP Settings seed migration¶
Create eafw_cms/home/migrations/0038_seed_cap_settings.py:
from django.db import migrations
GHA_COUNTRIES = [
"Burundi", "Djibouti", "Eritrea", "Ethiopia", "Kenya",
"Rwanda", "Somalia", "South Sudan", "Sudan", "Tanzania", "Uganda",
]
LANGUAGES = [
("en", "English"),
("sw", "Swahili"),
("ar", "Arabic"),
("am", "Amharic"),
("fr", "French"),
("so", "Somali"),
]
HAZARD_TYPES = [
{"event": "Flood", "category": "Met", "icon": "flood"},
{"event": "Flash Flood", "category": "Met", "icon": "flash-flood"},
]
def seed_cap_settings(apps, schema_editor):
Site = apps.get_model("wagtailcore", "Site")
CapSetting = apps.get_model("capeditor", "CapSetting")
HazardEventTypes = apps.get_model("capeditor", "HazardEventType") # Check actual model name
AlertLanguage = apps.get_model("capeditor", "AlertLanguage")
PredefinedAlertArea = apps.get_model("capeditor", "PredefinedAlertArea")
site = Site.objects.filter(is_default_site=True).first()
if not site:
return
setting, created = CapSetting.objects.get_or_create(site=site)
if not created:
return
setting.sender = "info@icpac.net"
setting.sender_name = "ICPAC"
setting.save()
# Add hazard types
for i, hazard in enumerate(HAZARD_TYPES):
HazardEventTypes.objects.create(
cap_setting=setting,
event=hazard["event"],
category=hazard["category"],
icon=hazard.get("icon", ""),
sort_order=i,
)
# Add languages
for i, (code, name) in enumerate(LANGUAGES):
AlertLanguage.objects.create(
cap_setting=setting,
code=code,
name=name,
sort_order=i,
)
# Add predefined areas from gha.admin0
# NOTE: Query actual admin0 geometries from DB
# PredefinedAlertArea requires MultiPolygonField geometry
from django.db import connection
with connection.cursor() as cursor:
for i, country in enumerate(GHA_COUNTRIES):
cursor.execute(
"SELECT ST_AsText(geom) FROM gha.admin0 WHERE name_0 = %s LIMIT 1",
[country]
)
row = cursor.fetchone()
if row:
from django.contrib.gis.geos import GEOSGeometry
geom = GEOSGeometry(row[0])
if geom.geom_type == 'Polygon':
from django.contrib.gis.geos import MultiPolygon
geom = MultiPolygon(geom)
PredefinedAlertArea.objects.create(
cap_setting=setting,
name=country,
geom=geom,
sort_order=i,
)
class Migration(migrations.Migration):
dependencies = [
("home", "0037_create_cap_alert_list_page"),
("capeditor", "__first__"),
]
operations = [
migrations.RunPython(seed_cap_settings, migrations.RunPython.noop),
]
Important: The exact model names (
HazardEventTypevsHazardEventTypes,PredefinedAlertArea, field names likecap_settingvssetting) must be verified against the actual cap-composer models before running. Checkcapcomposer/src/capcomposer/capeditor/cap_settings.pyfor the actualrelated_nameand model class names.
Verification¶
- CAP Settings visible in Wagtail admin -> Settings -> CAP Settings
- Can manually create a test CAP alert through Wagtail admin
- CAP alert list page accessible at
/cap-alerts/
Module 3: Assessment-to-CAP Data Bridge¶
Goal: Python module that transforms FloodWatch assessment data into CAP alert drafts.
Step 3.1: Create the package structure¶
Step 3.2: mapper.py -- Risk level to CAP field mapping¶
RISK_TO_CAP = {
"emergency": {"severity": "Extreme", "urgency": "Immediate", "certainty": "Observed"},
"alarm": {"severity": "Severe", "urgency": "Expected", "certainty": "Likely"},
"warning": {"severity": "Moderate", "urgency": "Future", "certainty": "Possible"},
"normal": {"severity": "Minor", "urgency": "Past", "certainty": "Unlikely"},
}
def get_cap_fields(risk_level: str) -> dict:
"""Map FloodWatch risk level to CAP severity/urgency/certainty."""
return RISK_TO_CAP.get(risk_level.lower(), RISK_TO_CAP["normal"])
Step 3.3: gatherer.py -- Query assessment data¶
Queries gha.expert_assessments and gha.district_risk_levels tables. Returns a dataclass with all data needed to build a CAP alert.
from dataclasses import dataclass
from typing import List, Optional
from datetime import datetime
from django.db import connection
@dataclass
class DistrictRisk:
district_name: str
gid_2: str
risk_level: str # "warning", "alarm", "emergency"
@dataclass
class AssessmentForCAP:
assessment_id: int
forecast_date: datetime
country_name: str
country_code: str
expert_comment: str
overall_risk: str
district_risks: List[DistrictRisk]
def get_assessment_for_cap(assessment_id: int) -> Optional[AssessmentForCAP]:
"""Query DB for assessment data needed to create a CAP alert."""
with connection.cursor() as cursor:
cursor.execute("""
SELECT id, forecast_date, country_name, country_code,
expert_comment, overall_risk_level
FROM gha.expert_assessments
WHERE id = %s
""", [assessment_id])
row = cursor.fetchone()
if not row:
return None
cursor.execute("""
SELECT district_name, gid_2, risk_level
FROM gha.district_risk_levels
WHERE assessment_id = %s AND risk_level != 'normal'
""", [assessment_id])
districts = [
DistrictRisk(district_name=r[0], gid_2=r[1], risk_level=r[2])
for r in cursor.fetchall()
]
return AssessmentForCAP(
assessment_id=row[0],
forecast_date=row[1],
country_name=row[2],
country_code=row[3],
expert_comment=row[4],
overall_risk=row[5],
district_risks=districts,
)
Note: Verify actual column names in
gha.expert_assessmentsandgha.district_risk_levelstables before implementing. These are placeholder names based on the plan.
Step 3.4: area_builder.py -- Convert district geometries to CAP areas¶
from django.db import connection
def build_cap_areas(district_risks):
"""Build CAP area dicts from district risk data with geometries from gha.admin2."""
areas = []
for dr in district_risks:
with connection.cursor() as cursor:
cursor.execute("""
SELECT name_2, ST_AsGeoJSON(geom)
FROM gha.admin2
WHERE gid_2 = %s
""", [dr.gid_2])
row = cursor.fetchone()
if row:
import json
areas.append({
"areaDesc": row[0],
"polygon": json.loads(row[1]),
})
return areas
Step 3.5: creator.py -- Orchestrator¶
from datetime import timedelta
from django.utils import timezone
from .gatherer import get_assessment_for_cap
from .mapper import get_cap_fields
from .area_builder import build_cap_areas
def create_cap_draft_from_assessment(assessment_id, expires_hours=48):
"""Create a draft CAP alert from a saved expert assessment."""
assessment = get_assessment_for_cap(assessment_id)
if not assessment:
raise ValueError(f"Assessment {assessment_id} not found")
cap_fields = get_cap_fields(assessment.overall_risk)
areas = build_cap_areas(assessment.district_risks)
now = timezone.now()
alert_data = {
"sender": "info@icpac.net",
"sent": now.isoformat(),
"status": "Draft",
"msgType": "Alert",
"scope": "Public",
"info": [{
"language": "en",
"category": "Met",
"event": "Flood",
"responseType": ["Monitor"],
"urgency": cap_fields["urgency"],
"severity": cap_fields["severity"],
"certainty": cap_fields["certainty"],
"effective": now.isoformat(),
"onset": assessment.forecast_date.isoformat(),
"expires": (now + timedelta(hours=expires_hours)).isoformat(),
"senderName": "ICPAC",
"headline": f"Flood {assessment.overall_risk.title()} - {assessment.country_name}",
"description": assessment.expert_comment or f"Flood {assessment.overall_risk} level for {assessment.country_name}",
"instruction": "Monitor water levels and follow local authority guidance.",
"area": areas,
}],
}
# Import cap-composer's utility
from capcomposer.cap.utils import create_draft_alert_from_alert_data
cap_page = create_draft_alert_from_alert_data(alert_data)
return cap_page
Key reference: create_draft_alert_from_alert_data() expected format¶
Located at hazard_watch/cap-composer/capcomposer/src/capcomposer/cap/utils.py:185
Function signature:
def create_draft_alert_from_alert_data(
alert_data,
request=None,
update_event_list=False,
update_contact_list=False,
submit_for_moderation=False
)
Returns: CapAlertPage (draft, unpublished) or None if no CapAlertListPage exists.
Full alert_data dict structure:
{
"sender": str, # Email/identifier
"sent": str, # ISO datetime
"status": str, # "Draft"|"Actual"|"Test"|"Exercise"|"System"
"msgType": str, # "Alert"|"Update"|"Cancel"
"scope": str, # "Public"|"Restricted"|"Private"
"restriction": str, # Required if scope="Restricted"
"note": str, # Optional note
"info": [
{
"language": str, # ISO 639-1 code
"category": str, # "Geo"|"Met"|"Safety" etc.
"event": str, # "Flood", "Flash Flood"
"responseType": [str], # ["Monitor", "Evacuate", etc.]
"urgency": str, # "Immediate"|"Expected"|"Future"|"Past"|"Unknown"
"severity": str, # "Extreme"|"Severe"|"Moderate"|"Minor"|"Unknown"
"certainty": str, # "Observed"|"Likely"|"Possible"|"Unlikely"|"Unknown"
"effective": str, # ISO datetime
"onset": str, # ISO datetime
"expires": str, # ISO datetime
"senderName": str,
"headline": str,
"description": str,
"instruction": str,
"contact": str,
"audience": str,
"eventCode": [{"valueName": str, "value": str}],
"parameter": [{"valueName": str, "value": str}],
"resource": [{"uri": str, "resourceDesc": str}],
"area": [
{
"areaDesc": str,
"polygon": <GeoJSON geometry>,
"geocode": {"valueName": str, "value": str},
"circle": [str],
}
]
}
]
}
Module 4: CAP API Endpoints + Nginx Routing¶
Step 4.1: Django view for CAP draft creation¶
In eafw_cms/geomanagerweb/api.py, add:
from django.http import JsonResponse
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
import json
@method_decorator(csrf_exempt, name="dispatch")
class CAPDraftCreateView(View):
def post(self, request):
try:
data = json.loads(request.body)
assessment_id = data.get("assessment_id")
expires_hours = data.get("expires_hours", 48)
if not assessment_id:
return JsonResponse({"success": False, "message": "assessment_id required"}, status=400)
from home.cap_bridge.creator import create_cap_draft_from_assessment
cap_page = create_cap_draft_from_assessment(assessment_id, expires_hours)
if not cap_page:
return JsonResponse({"success": False, "message": "Failed to create draft"}, status=500)
return JsonResponse({
"success": True,
"cap_alert_id": cap_page.id,
"edit_url": f"/admin/pages/{cap_page.id}/edit/",
"message": "CAP alert draft created",
})
except Exception as e:
return JsonResponse({"success": False, "message": str(e)}, status=500)
Step 4.2: Register Django URL¶
In eafw_cms/geomanagerweb/urls.py, add:
from geomanagerweb.api import CAPDraftCreateView
urlpatterns = [
# ... existing patterns ...
path("cms-api/cap/create-draft/", CAPDraftCreateView.as_view(), name="cap_create_draft"),
]
Step 4.3: FastAPI CAP router¶
Create eafw_api/src/eafw_api/routers/v1/cap.py:
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import httpx
router = APIRouter(prefix="/cap", tags=["CAP Alerts"])
class CapDraftRequest(BaseModel):
assessment_id: int
expires_hours: int = 48
class CapDraftResponse(BaseModel):
success: bool
cap_alert_id: int | None = None
edit_url: str | None = None
message: str
@router.post("/draft", response_model=CapDraftResponse)
async def create_cap_draft(request: CapDraftRequest):
"""Proxy to Django CMS to create a CAP alert draft."""
async with httpx.AsyncClient() as client:
resp = await client.post(
"http://eafw_cms:8000/cms-api/cap/create-draft/",
json=request.model_dump(),
timeout=30.0,
)
return resp.json()
@router.get("/alerts")
async def get_cap_alerts():
"""Proxy to cap-composer's GeoJSON endpoint."""
async with httpx.AsyncClient() as client:
resp = await client.get(
"http://eafw_cms:8000/api/cap/alerts.geojson",
timeout=10.0,
)
return resp.json()
Register in eafw_api/src/eafw_api/main.py:
from eafw_api.routers.v1.cap import router as cap_router
app.include_router(cap_router, prefix="/api/v1")
Step 4.4: Nginx routing¶
Add to both eafw_docker/nginx/nginx.local.conf and nginx.staging.conf:
# CAP XML/RSS/GeoJSON feeds (served by Django/cap-composer)
location /api/cap/ {
proxy_pass http://eafw_cms:8000/api/cap/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
API contracts¶
POST /api/v1/cap/draft
Request: { "assessment_id": 123, "expires_hours": 48 }
Response: { "success": true, "cap_alert_id": 456, "edit_url": "/admin/pages/456/edit/", "message": "..." }
GET /api/v1/cap/alerts
Response: GeoJSON FeatureCollection of active CAP alerts
GET /api/cap/rss.xml -> RSS feed (direct from cap-composer)
GET /api/cap/alerts.geojson -> GeoJSON (direct from cap-composer)
GET /api/cap/<uuid>.xml -> Individual CAP XML (direct from cap-composer)
Module 5: CAP Alerts Display Widget (Frontend)¶
Step 5.1: Create widget files¶
eafw_mapviewer/src/components/flood-analysis/widgets/cap-alerts/
index.jsx # Main widget
cap-alert-list.jsx # Scrollable alert cards
cap-alert-detail.jsx # Expanded view
styles.scss
Step 5.2: CAP service¶
Create or update eafw_mapviewer/src/services/cap.js:
import request from "utils/request";
export const getActiveAlerts = (params = {}) =>
request.get("/api/v1/cap/alerts", { params });
export const createCapDraft = (assessmentId, options = {}) =>
request.post("/api/v1/cap/draft", {
assessment_id: assessmentId,
expires_hours: options.expiresHours || 48,
});
Step 5.3: Widget component¶
// index.jsx
import React, { useState, useEffect } from "react";
import { getActiveAlerts } from "services/cap";
import "./styles.scss";
const SEVERITY_COLORS = {
Extreme: "#d32f2f",
Severe: "#f57c00",
Moderate: "#fbc02d",
Minor: "#4caf50",
};
const CapAlertsWidget = ({ forecastDate, selectedCountry }) => {
const [alerts, setAlerts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getActiveAlerts({ country: selectedCountry, date: forecastDate })
.then((data) => setAlerts(data?.features || []))
.catch(() => setAlerts([]))
.finally(() => setLoading(false));
}, [forecastDate, selectedCountry]);
if (loading) return <div className="cap-alerts-loading">Loading alerts...</div>;
if (!alerts.length) return <div className="cap-alerts-empty">No active CAP alerts</div>;
return (
<div className="cap-alerts-widget">
<h4>Active CAP Alerts ({alerts.length})</h4>
{alerts.map((alert) => (
<div
key={alert.properties?.identifier}
className="cap-alert-card"
style={{ borderLeftColor: SEVERITY_COLORS[alert.properties?.severity] }}
>
<strong>{alert.properties?.headline}</strong>
<span className="severity">{alert.properties?.severity}</span>
<span className="expires">Expires: {alert.properties?.expires}</span>
</div>
))}
</div>
);
};
export default CapAlertsWidget;
Step 5.4: Register in widgets container¶
In eafw_mapviewer/src/components/flood-analysis/widgets/index.jsx, import and render:
import CapAlertsWidget from "./cap-alerts";
// In the render, between ExpertAssessment and ActionButtons:
<CapAlertsWidget
forecastDate={params?.forecast_date}
selectedCountry={selectedCountry?.code}
/>
Module 6: "Generate CAP Alert" Button¶
Step 6.1: Create button component¶
Create eafw_mapviewer/src/components/flood-analysis/widgets/expert-assessment/cap-draft-button.jsx:
import React, { useState } from "react";
import { createCapDraft } from "services/cap";
const CapDraftButton = ({ assessmentId, disabled }) => {
const [state, setState] = useState("idle"); // idle | loading | success | error
const [result, setResult] = useState(null);
const handleClick = async () => {
setState("loading");
try {
const data = await createCapDraft(assessmentId);
setResult(data);
setState("success");
} catch (err) {
setResult({ message: err.message });
setState("error");
}
};
if (state === "success" && result) {
return (
<div className="cap-draft-success">
Draft created.{" "}
<a href={result.edit_url} target="_blank" rel="noopener noreferrer">
Edit in CMS Admin →
</a>
</div>
);
}
return (
<button
className="cap-draft-button"
onClick={handleClick}
disabled={disabled || state === "loading"}
>
{state === "loading" ? "Creating..." : "Generate CAP Alert Draft"}
</button>
);
};
export default CapDraftButton;
Step 6.2: Add to expert assessment widget¶
In eafw_mapviewer/src/components/flood-analysis/widgets/expert-assessment/index.jsx, after assessment save success:
import CapDraftButton from "./cap-draft-button";
// After save success state:
{savedAssessmentId && (
<CapDraftButton assessmentId={savedAssessmentId} />
)}
UX flow¶
- Forecaster completes and saves expert assessment
- "Generate CAP Alert Draft" button appears
- Click -> loading state -> API call to
/api/v1/cap/draft - Success -> "Draft created. [Edit in CMS Admin ->]" link
- Forecaster opens Wagtail admin, reviews/edits, publishes
Module 7: Distribution Pipeline (Celery + Redis)¶
Step 7.1: Create Celery app¶
Create eafw_cms/geomanagerweb/celery.py:
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "geomanagerweb.settings.dev")
app = Celery("geomanagerweb")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
Step 7.2: Import celery app¶
In eafw_cms/geomanagerweb/__init__.py:
Step 7.3: Enable Celery settings¶
In eafw_cms/geomanagerweb/settings/base.py, uncomment:
CELERY_BROKER_URL = env.str("CELERY_BROKER_URL", "redis://eafw_redis:6379/0")
CELERY_RESULT_BACKEND = env.str("CELERY_RESULT_BACKEND", "redis://eafw_redis:6379/0")
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TIMEZONE = TIME_ZONE
Step 7.4: Add Docker services¶
In docker-compose.local.yml:
eafw_redis:
image: redis:7-alpine
container_name: eafw-redis
restart: unless-stopped
volumes:
- redis_data:/data
eafw_celery_worker:
image: eafw-cms
container_name: eafw-celery-worker
command: celery -A geomanagerweb worker -l info
depends_on:
- eafw_redis
- eafw_db
env_file:
- eafw_cms/.env
volumes:
- ./eafw_cms:/app
- media_data:/app/media
eafw_celery_beat:
image: eafw-cms
container_name: eafw-celery-beat
command: celery -A geomanagerweb beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
depends_on:
- eafw_redis
- eafw_db
env_file:
- eafw_cms/.env
volumes:
- ./eafw_cms:/app
volumes:
redis_data:
What this enables (built into cap-composer)¶
When a CAP alert is published (status=Actual, scope=Public), the page_published signal triggers:
- handle_publish_alert_to_mqtt.delay() -- publish to configured MQTT brokers
- handle_publish_alert_to_webhook.delay() -- fire configured webhooks
- handle_generate_multimedia.delay() -- generate PNG map + PDF preview
Webhook/MQTT brokers are configured through Wagtail admin UI (already built into cap-composer).
Implementation Order¶
| Phase | Module | Effort | Depends on |
|---|---|---|---|
| 1 | Module 1: Wagtail Upgrade | High risk, medium effort | Nothing |
| 2 | Module 2: Seed Data | Low effort | Module 1 |
| 3 | Module 3: Data Bridge | Medium effort | Module 2 |
| 3 | Module 4: API + Nginx | Medium effort | Module 2 |
| 3 | Module 5: Display Widget | Medium effort | Module 2 |
| 3 | Module 7: Distribution | Medium effort | Module 2 |
| 4 | Module 6: Generate Button | Low effort | Modules 3 + 4 |
Modules 3, 4, 5, 7 can be developed in parallel after Module 2.
Key Files Reference¶
| Purpose | Path |
|---|---|
| CMS dependencies | eafw_cms/pyproject.toml |
| CMS settings | eafw_cms/geomanagerweb/settings/base.py |
| CMS URLs | eafw_cms/geomanagerweb/urls.py |
| CMS API views | eafw_cms/geomanagerweb/api.py |
| Home models | eafw_cms/home/models.py |
| Home migrations | eafw_cms/home/migrations/ (next: 0037) |
| Geomanager package | eafw_cms/geomanager/ |
| Geomanager pyproject | eafw_cms/geomanager/pyproject.toml |
| cap-composer source | hazard_watch/cap-composer/capcomposer/src/capcomposer/ |
create_draft_alert_from_alert_data() |
hazard_watch/cap-composer/capcomposer/src/capcomposer/cap/utils.py:185 |
| CAP models | hazard_watch/cap-composer/capcomposer/src/capcomposer/cap/models.py |
| CAP settings model | hazard_watch/cap-composer/capcomposer/src/capcomposer/capeditor/cap_settings.py |
| CAP constants | hazard_watch/cap-composer/capcomposer/src/capcomposer/capeditor/constants.py |
| CAP URL patterns | hazard_watch/cap-composer/capcomposer/src/capcomposer/cap/urls.py |
| Expert assessment widget | eafw_mapviewer/src/components/flood-analysis/widgets/expert-assessment/index.jsx |
| Widgets container | eafw_mapviewer/src/components/flood-analysis/widgets/index.jsx |
| FastAPI routers | eafw_api/src/eafw_api/routers/v1/ |
| FastAPI main | eafw_api/src/eafw_api/main.py |
| Nginx local config | eafw_docker/nginx/nginx.local.conf |
| Nginx staging config | eafw_docker/nginx/nginx.staging.conf |
| Docker compose | docker-compose.local.yml |
Verification Checklist¶
After Module 1¶
- [ ]
python manage.py checkpasses - [ ]
python manage.py migratecreates CAP tables - [ ] Wagtail admin loads without errors
- [ ] Existing pages (HomePage, SituationReportPage) still render
- [ ]
geomanageradmin panels still work
After Module 2¶
- [ ] CAP Settings visible in Wagtail admin -> Settings -> CAP Settings
- [ ] Can manually create a test CAP alert through admin
- [ ] Alert list page accessible at
/cap-alerts/
After Module 3¶
- [ ] Unit tests for mapper pass
- [ ]
create_cap_draft_from_assessment(id)creates a draft CapAlertPage
After Module 4¶
- [ ]
curl /api/cap/alerts.geojsonreturns valid GeoJSON - [ ]
curl /api/cap/rss.xmlreturns valid RSS - [ ]
POST /api/v1/cap/draftcreates a draft in Wagtail
After Module 5¶
- [ ] Widget renders "No active alerts" when empty
- [ ] Widget shows alert cards when alerts exist
After Module 6¶
- [ ] Button appears after assessment save
- [ ] Creates draft with correct pre-filled data
- [ ] Edit link opens Wagtail page editor
After Module 7¶
- [ ] Publishing an alert triggers webhook (test with webhook.site)
- [ ] PNG/PDF generated in media directory