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

1import io 

2import json 

3import logging 

4from datetime import date 

5from typing import Any 

6 

7import qrcode 

8import requests 

9from PIL import Image, ImageDraw, ImageFont 

10 

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) 

19 

20logger = logging.getLogger(__name__) 

21 

22API_BASE = "https://www.mypostcard.com/api/v1" 

23 

24 

25def _generate_back_left_side_png(verification_code: str) -> bytes: 

26 """ 

27 Generates the back left side image (780x1016 px PNG at 300 DPI). 

28 

29 Overlays a QR code and verification code onto the postcard-back-left.png template. 

30 """ 

31 metadata = get_postcard_metadata()["back_left"] 

32 

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) 

37 

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") 

43 

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"])) 

49 

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"]) 

52 

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 ) 

60 

61 buf = io.BytesIO() 

62 img.save(buf, format="PNG") 

63 buf.seek(0) 

64 return buf.getvalue() 

65 

66 

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 } 

73 

74 

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"]) 

83 

84 

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. 

90 

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 } 

106 

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 

126 

127 

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. 

140 

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 

150 

151 Returns: 

152 The MyPostcard job ID 

153 """ 

154 

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 

167 

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"]) 

173 

174 

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"]] 

190 

191 

192def download_pdf(job_id: int) -> bytes: 

193 """ 

194 Download the PDF for a given job ID. 

195 

196 Args: 

197 job_id: The MyPostcard job ID 

198 

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