Coverage for app / backend / src / couchers / postal / my_postcard.py: 75%
62 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 13:48 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 13:48 +0000
1import io
2import json
3import logging
4from datetime import date
5from typing import Any
7import qrcode
8import requests
9from PIL import Image, ImageDraw, ImageFont
11from couchers import urls
12from couchers.config import config
13from couchers.resources import (
14 get_postcard_back_left_template,
15 get_postcard_font,
16 get_postcard_front_image,
17 get_postcard_metadata,
18)
20logger = logging.getLogger(__name__)
22API_BASE = "https://www.mypostcard.com/api/v1"
25def _generate_back_left_side_png(verification_code: str) -> bytes:
26 """
27 Generates the back left side image (780x1016 px PNG at 300 DPI).
29 Overlays a QR code and verification code onto the postcard-back-left.png template.
30 """
31 metadata = get_postcard_metadata()["back_left"]
33 # Load template
34 template_bytes = get_postcard_back_left_template()
35 img = Image.open(io.BytesIO(template_bytes)).convert("RGBA")
36 draw = ImageDraw.Draw(img)
38 # Generate QR code
39 qr = qrcode.QRCode(box_size=10, border=0)
40 qr.add_data(urls.postal_verification_link(code=verification_code))
41 qr.make(fit=True)
42 qr_img: Image.Image = qr.make_image(fill_color="black", back_color="white").get_image().convert("RGBA")
44 # Size and paste the QR code
45 # QR code position: exact coordinates in image are (227, 419, 539, 731), extended by 5px in each direction
46 qr_size = metadata["qr_size"]
47 qr_img = qr_img.resize((qr_size, qr_size), Image.Resampling.NEAREST)
48 img.paste(qr_img, (metadata["qr_left"], metadata["qr_top"]))
50 # Verification code text center: box in image is (x=251, y=761, w=264, h=80), center is (383, 801)
51 font = ImageFont.truetype(io.BytesIO(get_postcard_font()), metadata["code_font_size"])
53 draw.text(
54 (metadata["code_center_x"], metadata["code_center_y"]),
55 verification_code,
56 fill=(255, 255, 255),
57 font=font,
58 anchor="mm",
59 )
61 buf = io.BytesIO()
62 img.save(buf, format="PNG")
63 buf.seek(0)
64 return buf.getvalue()
67def _credentials() -> dict[str, str]:
68 return {
69 "api_key": config["MYPOSTCARD_API_KEY"],
70 "username": config["MYPOSTCARD_USERNAME"],
71 "password": config["MYPOSTCARD_PASSWORD"],
72 }
75def _authenticate() -> str:
76 response = requests.post(
77 f"{API_BASE}/auth",
78 data=_credentials(),
79 timeout=30,
80 )
81 response.raise_for_status()
82 return str(response.json()["auth_token"])
85def _place_order(
86 auth_token: str, recipient_data: dict[str, str], front_page: bytes, back_left_side: bytes
87) -> dict[str, Any]:
88 """
89 Places a postcard order with MyPostcard API.
91 Args:
92 auth_token: Authentication token from _authenticate()
93 recipient_data: Recipient address fields
94 front_page: PNG image for the front of the postcard (1772x1264 px at 300 DPI)
95 back_left_side: PNG image for the left side of the back (780x1016 px at 300 DPI)
96 """
97 job_data = {
98 "job_details": {
99 "fontName": "StoneHandwriting",
100 "text": "",
101 "textColor": "blue",
102 "fontSize": "L",
103 },
104 "recipients": [recipient_data],
105 }
107 response = requests.post(
108 f"{API_BASE}/place_order",
109 data={
110 "api_key": config["MYPOSTCARD_API_KEY"],
111 "auth_token": auth_token,
112 "product_code": config["MYPOSTCARD_PRODUCT_CODE"],
113 "image_type": "png",
114 "job_data": json.dumps(job_data),
115 "campaign_id": config["MYPOSTCARD_CAMPAIGN_ID"],
116 },
117 files={
118 "photo": ("postcard.png", front_page, "image/png"),
119 "logo_addon": ("logo.png", back_left_side, "image/png"),
120 },
121 timeout=60,
122 )
123 response.raise_for_status()
124 result: dict[str, Any] = response.json()
125 return result
128def send_postcard(
129 recipient_name: str,
130 address_line_1: str,
131 address_line_2: str | None,
132 city: str,
133 state: str | None,
134 postal_code: str | None,
135 country: str,
136 verification_code: str,
137) -> int:
138 """
139 Sends a physical postcard with verification code via MyPostcard API.
141 Args:
142 recipient_name: Name to print on the postcard
143 address_line_1: Street address
144 address_line_2: Apartment/suite (optional)
145 city: City
146 state: State/province (optional)
147 postal_code: Postal code (optional)
148 country: ISO 3166-1 alpha-2 country code
149 verification_code: The 6-character code to print
151 Returns:
152 The MyPostcard job ID
153 """
155 recipient = {
156 "recipientName": recipient_name,
157 "addressLine1": address_line_1,
158 "city": city,
159 "countryiso": country,
160 }
161 if address_line_2:
162 recipient["addressLine2"] = address_line_2
163 if postal_code:
164 recipient["zip"] = postal_code
165 if state: 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true
166 recipient["state"] = state
168 result = _place_order(
169 _authenticate(), recipient, get_postcard_front_image(), _generate_back_left_side_png(verification_code)
170 )
171 logger.info(f"MyPostcard order placed successfully: {result}")
172 return int(result["job_id"])
175def get_order_ids(date_from: date, date_to: date) -> list[int]:
176 """
177 Fetch all order job IDs in a given time frame.
178 """
179 response = requests.post(
180 f"{API_BASE}/request_orders",
181 data={
182 **_credentials(),
183 "date_from": date_from.strftime("%Y-%m-%d"),
184 "date_to": date_to.strftime("%Y-%m-%d"),
185 },
186 timeout=30,
187 )
188 response.raise_for_status()
189 return [int(order["job_id"]) for order in response.json()["orders"]]
192def download_pdf(job_id: int) -> bytes:
193 """
194 Download the PDF for a given job ID.
196 Args:
197 job_id: The MyPostcard job ID
199 Returns:
200 PDF file contents as bytes
201 """
202 response = requests.post(
203 f"{API_BASE}/download_pdf",
204 data={
205 **_credentials(),
206 "job_id": job_id,
207 },
208 timeout=60,
209 )
210 response.raise_for_status()
211 return response.content