Add PCM carrier-route postcard pick-and-send (#16)
## Summary Closes #15. Mirrors westfarn/web_django_template#11. - Compose can target the postcard mailing list **or** find and select one or two USPS carrier routes by ZIP (optional radius). - Estimated cost is shown per route and for the selection (PCM design pricing, else `PCM_POSTCARD_PRICE_*` fallback). - Selected routes order through PCM `POST /order/postcard/with-list-count` — occupant addresses are not imported into Contacts. - Not official EDDM postage; PCM FirstClass/Standard purchased occupant lists on carrier routes. ## Test plan - [ ] Campaign compose: mailing-list audience still quotes list size × per-piece cost. - [ ] Switch to USPS carrier routes, search ZIP 60540 (mocked or live PCM), pick 1–2 routes, confirm household count + estimated cost. - [ ] Saving without a selection is rejected; saving with a quote creates one contact-less Message and shows the route panel on the report. - [ ] Send places `/order/postcard/with-list-count` (not per-recipient `/order/postcard`). - [ ] `uv run python manage.py test messaging.tests.CarrierRoutePostcardTests messaging.tests.PostcardDesignPickTests` Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
1 parent
a867a57750
commit
045e956011
17 files changed
+1211
-28
No files matched your search
@@ -1,6 +1,7 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
import json
|
||||
|
||||
from contacts.models import Channel, ConsentRecord, Contact, Suppression
|
||||
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent
|
||||
@@ -1706,3 +1707,211 @@ class CampaignUtmLinkTests(TestCase):
|
||||
self.assertTrue(data["shortened"])
|
||||
self.assertEqual(data["display_url"], "https://piha.li/a3k9xm")
|
||||
self.assertIn("utm_medium=sms", data["target_url"])
|
||||
|
||||
|
||||
class CarrierRoutePostcardTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="routes", password="test-pass-123"
|
||||
)
|
||||
self.client = Client()
|
||||
self.client.login(username="routes", password="test-pass-123")
|
||||
self.contact = Contact.objects.create(
|
||||
email="mail@example.com",
|
||||
postal_address=Contact.make_postal_address(line1="9 Main"),
|
||||
)
|
||||
|
||||
def test_pick_unit_price_uses_break_qty_and_fallback(self):
|
||||
from messaging.providers.postcard import pcm as pcm_mod
|
||||
|
||||
price, source = pcm_mod.pick_unit_price(
|
||||
[
|
||||
{"mailClass": "Standard", "price": 0.8, "breakQty": 1},
|
||||
{"mailClass": "Standard", "price": 0.5, "breakQty": 200},
|
||||
],
|
||||
mail_class="Standard",
|
||||
quantity=250,
|
||||
)
|
||||
self.assertEqual(price, 0.5)
|
||||
self.assertEqual(source, "pcm")
|
||||
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.42"):
|
||||
price, source = pcm_mod.pick_unit_price(
|
||||
[], mail_class="Standard", quantity=10
|
||||
)
|
||||
self.assertEqual(price, 0.42)
|
||||
self.assertEqual(source, "fallback")
|
||||
|
||||
def test_quote_search_returns_route_costs(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
payload = {
|
||||
"listCountID": 11,
|
||||
"recordCount": 300,
|
||||
"breakdown": [
|
||||
{
|
||||
"breakdownType": "ZipCRRT",
|
||||
"data": [
|
||||
{"code": "60540:C001", "text": "NAPERVILLE", "total": 120},
|
||||
{"code": "60540:C002", "text": "NAPERVILLE", "total": 180},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.50"):
|
||||
with patch(
|
||||
"messaging.providers.postcard.pcm.design_pricing", return_value=[]
|
||||
):
|
||||
with patch(
|
||||
"messaging.providers.postcard.pcm.list_count_zipcode",
|
||||
return_value=payload,
|
||||
):
|
||||
response = self.client.post(
|
||||
reverse("messaging:postcard_quote"),
|
||||
data='{"action":"search","zip":"60540","size":"68"}',
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(len(data["routes"]), 2)
|
||||
self.assertEqual(data["routes"][0]["cost"], 60.0)
|
||||
self.assertEqual(data["mail_class"], "Standard")
|
||||
|
||||
def test_quote_select_and_save_draft(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
select_payload = {
|
||||
"listCountID": 99,
|
||||
"recordCount": 300,
|
||||
"breakdown": [
|
||||
{
|
||||
"breakdownType": "ZipCRRT",
|
||||
"data": [
|
||||
{"code": "60540:C001", "text": "A", "total": 120},
|
||||
{"code": "60540:C002", "text": "B", "total": 180},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.50"):
|
||||
with patch(
|
||||
"messaging.providers.postcard.pcm.design_pricing", return_value=[]
|
||||
):
|
||||
with patch(
|
||||
"messaging.providers.postcard.pcm.list_count_carrier_route",
|
||||
return_value=select_payload,
|
||||
):
|
||||
quoted = self.client.post(
|
||||
reverse("messaging:postcard_quote"),
|
||||
data=(
|
||||
'{"action":"select","size":"68",'
|
||||
'"routes":["60540:C001","60540:C002"]}'
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(quoted.status_code, 200)
|
||||
quote = quoted.json()
|
||||
self.assertEqual(quote["list_count_id"], 99)
|
||||
self.assertEqual(quote["total"], 150.0)
|
||||
|
||||
targeting = {
|
||||
"selected_codes": quote["selected_codes"],
|
||||
"list_count_id": quote["list_count_id"],
|
||||
"record_count": quote["record_count"],
|
||||
"mail_class": quote["mail_class"],
|
||||
"estimated": quote["estimated"],
|
||||
"route_rows": quote["routes"],
|
||||
}
|
||||
with patch(
|
||||
"messaging.views._fetch_pcm_designs",
|
||||
return_value=(
|
||||
[{"design_id": "42", "name": "Spring card", "size": "68"}],
|
||||
"",
|
||||
),
|
||||
):
|
||||
response = self.client.post(
|
||||
reverse("messaging:campaign_list"),
|
||||
{
|
||||
"name": "Farm drop",
|
||||
"audience": Campaign.Audience.CARRIER_ROUTE,
|
||||
"template_id": "d:42",
|
||||
"route_targeting": json.dumps(targeting),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
campaign = Campaign.objects.get(name="Farm drop")
|
||||
self.assertEqual(campaign.audience, Campaign.Audience.CARRIER_ROUTE)
|
||||
self.assertEqual(campaign.route_targeting["list_count_id"], 99)
|
||||
self.assertEqual(campaign.messages.count(), 1)
|
||||
self.assertIsNone(campaign.messages.get().contact_id)
|
||||
|
||||
def test_send_uses_list_count_endpoint(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
from messaging.providers.postcard import pcm as pcm_mod
|
||||
from messaging.services import enqueue_campaign_send
|
||||
|
||||
template = MessageTemplate.objects.create(
|
||||
name="Route design",
|
||||
channel=Channel.POSTCARD,
|
||||
body="Postcard",
|
||||
postcard_front={"design_id": 42, "size": "68", "provider": "pcm"},
|
||||
)
|
||||
campaign = create_campaign_draft(
|
||||
name="Routes",
|
||||
audience=Campaign.Audience.CARRIER_ROUTE,
|
||||
body="note",
|
||||
created_by=self.user,
|
||||
template=template,
|
||||
route_targeting={
|
||||
"selected_codes": ["60540:C001"],
|
||||
"list_count_id": 77,
|
||||
"record_count": 50,
|
||||
"mail_class": "Standard",
|
||||
},
|
||||
)
|
||||
pcm_mod.clear_token_cache()
|
||||
with self.settings(
|
||||
PCM_API_KEY="key",
|
||||
PCM_API_SECRET="secret",
|
||||
PCM_RETURN_ADDRESS=(
|
||||
'{"firstName":"Mo","lastName":"D","address":"1 Main",'
|
||||
'"city":"Naperville","state":"IL","zipCode":"60540"}'
|
||||
),
|
||||
):
|
||||
with patch("messaging.providers.postcard.pcm.login", return_value="tok"):
|
||||
with patch(
|
||||
"messaging.providers.postcard.pcm.pcm_request",
|
||||
return_value={"orderID": 555},
|
||||
) as req:
|
||||
enqueue_campaign_send(campaign)
|
||||
path = req.call_args.args[1]
|
||||
self.assertEqual(path, "/order/postcard/with-list-count")
|
||||
body = req.call_args.kwargs["json_body"]
|
||||
self.assertEqual(body["listCountID"], 77)
|
||||
self.assertEqual(body["recordCount"], 50)
|
||||
campaign.refresh_from_db()
|
||||
self.assertEqual(campaign.messages.get().provider_message_id, "555")
|
||||
|
||||
def test_mailing_list_postcard_still_creates_contacts(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"messaging.views._fetch_pcm_designs",
|
||||
return_value=(
|
||||
[{"design_id": "42", "name": "Spring card", "size": "46"}],
|
||||
"",
|
||||
),
|
||||
):
|
||||
response = self.client.post(
|
||||
reverse("messaging:campaign_list"),
|
||||
{
|
||||
"name": "List mailer",
|
||||
"audience": Campaign.Audience.POSTCARD_OPT_IN,
|
||||
"template_id": "d:42",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
campaign = Campaign.objects.get(name="List mailer")
|
||||
self.assertEqual(campaign.messages.count(), 1)
|
||||
self.assertEqual(campaign.messages.get().contact_id, self.contact.pk)
|
||||
Reference in new issue
Block a user