Coverage for amazonorders/entity/order.py: 98.28%

174 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-01-30 14:24 +0000

1import json 

2import logging 

3from datetime import datetime, date 

4from typing import List, Optional, TypeVar 

5from urllib.parse import urlparse, parse_qs 

6 

7from bs4 import BeautifulSoup, Tag 

8 

9from amazonorders import constants 

10from amazonorders.entity.item import Item 

11from amazonorders.entity.parsable import Parsable 

12from amazonorders.entity.recipient import Recipient 

13from amazonorders.entity.shipment import Shipment 

14 

15__author__ = "Alex Laird" 

16__copyright__ = "Copyright 2024, Alex Laird" 

17__version__ = "1.0.7" 

18 

19logger = logging.getLogger(__name__) 

20 

21Entity = TypeVar('Entity', bound='Order') 

22 

23 

24class Order(Parsable): 

25 """ 

26 An Amazon Order. 

27 """ 

28 

29 def __init__(self, 

30 parsed: Tag, 

31 full_details: bool = False, 

32 clone: Optional[Entity] = None) -> None: 

33 super().__init__(parsed) 

34 

35 #: If the Orders full details were populated from its details page. 

36 self.full_details: bool = full_details 

37 

38 #: The Order Shipments. 

39 self.shipments: List[Shipment] = clone.shipments if clone else self._parse_shipments() 

40 #: The Order Items. 

41 self.items: List[Item] = clone.items if clone and not full_details else self._parse_items() 

42 #: The Order number. 

43 self.order_number: str = clone.order_number if clone else self.safe_parse(self._parse_order_number) 

44 #: The Order details link. 

45 self.order_details_link: Optional[str] = clone.order_details_link if clone else self.safe_parse( 

46 self._parse_order_details_link) 

47 #: The Order grand total. 

48 self.grand_total: float = clone.grand_total if clone else self.safe_parse(self._parse_grand_total) 

49 #: The Order placed date. 

50 self.order_placed_date: date = clone.order_placed_date if clone else self.safe_parse( 

51 self._parse_order_placed_date) 

52 #: The Order Recipients. 

53 self.recipient: Recipient = clone.recipient if clone else self.safe_parse(self._parse_recipient) 

54 

55 # Fields below this point are only populated if `full_details` is True 

56 

57 #: The Order payment method. 

58 self.payment_method: Optional[str] = self._parse_payment_method() if self.full_details else None 

59 #: The Order payment method's last 4 digits. 

60 self.payment_method_last_4: Optional[str] = self._parse_payment_method_last_4() if self.full_details else None 

61 #: The Order subtotal. 

62 self.subtotal: Optional[float] = self._parse_subtotal() if self.full_details else None 

63 #: The Order shipping total. 

64 self.shipping_total: Optional[float] = self._parse_shipping_total() if self.full_details else None 

65 #: The Order Subscribe & Save discount. 

66 self.subscription_discount: Optional[float] = self._parse_subscription_discount() if self.full_details else None 

67 #: The Order total before tax. 

68 self.total_before_tax: Optional[float] = self._parse_total_before_tax() if self.full_details else None 

69 #: The Order estimated tax. 

70 self.estimated_tax: Optional[float] = self._parse_estimated_tax() if self.full_details else None 

71 #: The Order refund total. 

72 self.refund_total: Optional[float] = self._parse_refund_total() if self.full_details else None 

73 #: The Order shipped date. 

74 self.order_shipped_date: Optional[date] = self._parse_order_shipping_date() if self.full_details else None 

75 #: The Order refund total. 

76 self.refund_completed_date: Optional[date] = self._parse_refund_completed_date() if self.full_details else None 

77 

78 def __repr__(self) -> str: 

79 return "<Order #{}: \"{}\">".format(self.order_number, self.items) 

80 

81 def __str__(self) -> str: # pragma: no cover 

82 return "Order #{}: {}".format(self.order_number, self.items) 

83 

84 def _parse_shipments(self) -> List[Shipment]: 

85 shipments = [Shipment(x) for x in self.parsed.select(constants.SHIPMENT_ENTITY_SELECTOR)] 

86 shipments.sort() 

87 return shipments 

88 

89 def _parse_items(self) -> List[Item]: 

90 items = [Item(x) for x in self.parsed.select(constants.ITEM_ENTITY_SELECTOR)] 

91 items.sort() 

92 return items 

93 

94 def _parse_order_details_link(self) -> Optional[str]: 

95 value = self.simple_parse(constants.FIELD_ORDER_DETAILS_LINK_SELECTOR, link=True) 

96 

97 if not value and self.order_number: 

98 value = "{}?orderID={}".format(constants.ORDER_DETAILS_URL, self.order_number) 

99 

100 return value 

101 

102 def _parse_order_number(self) -> str: 

103 try: 

104 order_details_link = self._parse_order_details_link() 

105 except: 

106 # We're not using safe_parse here because it's fine if this fails, no need for noise 

107 order_details_link = None 

108 

109 if order_details_link: 

110 parsed_url = urlparse(order_details_link) 

111 value = parse_qs(parsed_url.query)["orderID"][0] 

112 else: 

113 value = self.simple_parse(constants.FIELD_ORDER_NUMBER_SELECTOR, required=True) 

114 

115 return value 

116 

117 def _parse_grand_total(self) -> float: 

118 value = self.simple_parse(constants.FIELD_ORDER_GRAND_TOTAL_SELECTOR) 

119 

120 if not value: 

121 for tag in self.parsed.select(constants.FIELD_ORDER_SUBTOTALS_TAG_ITERATOR_SELECTOR): 

122 if "grand total" in tag.text.lower(): 

123 inner_tag = tag.select_one(constants.FIELD_ORDER_SUBTOTALS_INNER_TAG_SELECTOR) 

124 if inner_tag: 

125 value = inner_tag.text.strip() 

126 break 

127 

128 value = float(value.replace("$", "")) 

129 

130 return value 

131 

132 def _parse_order_placed_date(self) -> date: 

133 value = self.simple_parse(constants.FIELD_ORDER_PLACED_DATE_SELECTOR) 

134 

135 if "Ordered on" in value: 

136 split_str = "Ordered on" 

137 else: 

138 split_str = "Order placed" 

139 

140 value = value.split(split_str)[1].strip() 

141 value = datetime.strptime(value, "%B %d, %Y").date() 

142 

143 return value 

144 

145 def _parse_recipient(self) -> Recipient: 

146 value = self.parsed.select_one(constants.FIELD_ORDER_ADDRESS_SELECTOR) 

147 

148 if not value: 

149 value = self.parsed.select_one(constants.FIELD_ORDER_ADDRESS_FALLBACK_1_SELECTOR) 

150 

151 if value: 

152 inline_content = value.get("data-a-popover", {}).get("inlineContent") 

153 if inline_content: 

154 value = BeautifulSoup(json.loads(inline_content), "html.parser") 

155 

156 if not value: 

157 # TODO: there are multiple shipToData tags, we should double check we're picking the right one associated with the order 

158 parent_tag = self.parsed.find_parent().select_one(constants.FIELD_ORDER_ADDRESS_FALLBACK_2_SELECTOR) 

159 value = BeautifulSoup(str(parent_tag.contents[0]).strip(), "html.parser") 

160 

161 return Recipient(value) 

162 

163 def _parse_payment_method(self) -> Optional[str]: 

164 value = None 

165 

166 tag = self.parsed.select_one(constants.FIELD_ORDER_PAYMENT_METHOD_SELECTOR) 

167 if tag: 

168 value = tag["alt"] 

169 

170 return value 

171 

172 def _parse_payment_method_last_4(self) -> Optional[str]: 

173 value = None 

174 

175 tag = self.parsed.select_one(constants.FIELD_ORDER_PAYMENT_METHOD_LAST_4_SELECTOR) 

176 if tag: 

177 ending_sibling = tag.find_next_siblings()[-1] 

178 split_str = "ending in" 

179 if split_str in ending_sibling.text: 

180 value = ending_sibling.text.split(split_str)[1].strip() 

181 

182 return value 

183 

184 def _parse_subtotal(self) -> Optional[float]: 

185 value = None 

186 

187 for tag in self.parsed.select(constants.FIELD_ORDER_SUBTOTALS_TAG_ITERATOR_SELECTOR): 

188 if "subtotal" in tag.text.lower(): 

189 inner_tag = tag.select_one(constants.FIELD_ORDER_SUBTOTALS_INNER_TAG_SELECTOR) 

190 if inner_tag: 

191 value = float(inner_tag.text.strip().replace("$", "")) 

192 break 

193 

194 return value 

195 

196 def _parse_shipping_total(self) -> Optional[float]: 

197 value = None 

198 

199 for tag in self.parsed.select(constants.FIELD_ORDER_SUBTOTALS_TAG_ITERATOR_SELECTOR): 

200 if "shipping" in tag.text.lower(): 

201 inner_tag = tag.select_one(constants.FIELD_ORDER_SUBTOTALS_INNER_TAG_SELECTOR) 

202 if inner_tag: 

203 value = float(inner_tag.text.strip().replace("$", "")) 

204 break 

205 

206 return value 

207 

208 def _parse_subscription_discount(self) -> Optional[float]: 

209 value = None 

210 

211 for tag in self.parsed.select(constants.FIELD_ORDER_SUBTOTALS_TAG_ITERATOR_SELECTOR): 

212 if "subscribe" in tag.text.lower(): 

213 inner_tag = tag.select_one(constants.FIELD_ORDER_SUBTOTALS_INNER_TAG_SELECTOR) 

214 if inner_tag: 

215 value = float(inner_tag.text.strip().replace("$", "")) 

216 break 

217 

218 return value 

219 

220 def _parse_total_before_tax(self) -> Optional[float]: 

221 value = None 

222 

223 for tag in self.parsed.select(constants.FIELD_ORDER_SUBTOTALS_TAG_ITERATOR_SELECTOR): 

224 if "before tax" in tag.text.lower(): 

225 inner_tag = tag.select_one(constants.FIELD_ORDER_SUBTOTALS_INNER_TAG_SELECTOR) 

226 if inner_tag: 

227 value = float(inner_tag.text.strip().replace("$", "")) 

228 break 

229 

230 return value 

231 

232 def _parse_estimated_tax(self) -> Optional[float]: 

233 value = None 

234 

235 for tag in self.parsed.select(constants.FIELD_ORDER_SUBTOTALS_TAG_ITERATOR_SELECTOR): 

236 if "estimated tax" in tag.text.lower(): 

237 inner_tag = tag.select_one(constants.FIELD_ORDER_SUBTOTALS_INNER_TAG_SELECTOR) 

238 if inner_tag: 

239 value = float(inner_tag.text.strip().replace("$", "")) 

240 break 

241 

242 return value 

243 

244 def _parse_refund_total(self) -> Optional[float]: 

245 value = None 

246 

247 for tag in self.parsed.select(constants.FIELD_ORDER_SUBTOTALS_TAG_ITERATOR_SELECTOR): 

248 if "refund total" in tag.text.lower() and "tax refund" not in tag.text.lower(): 

249 inner_tag = tag.select_one(constants.FIELD_ORDER_SUBTOTALS_INNER_TAG_SELECTOR) 

250 if inner_tag: 

251 value = float(inner_tag.text.strip().replace("$", "")) 

252 break 

253 

254 return value 

255 

256 def _parse_order_shipping_date(self) -> Optional[date]: 

257 match_text = "Items shipped:" 

258 value = self.simple_parse(constants.FIELD_ORDER_SHIPPED_DATE_SELECTOR, text_contains=match_text) 

259 

260 if value: 

261 date_str = value.split(match_text)[1].strip().split("-")[0].strip() 

262 value = datetime.strptime(date_str, "%B %d, %Y").date() 

263 

264 return value 

265 

266 def _parse_refund_completed_date(self) -> Optional[date]: 

267 match_text = "Refund: Completed" 

268 value = self.simple_parse(constants.FIELD_ORDER_REFUND_COMPLETED_DATE, text_contains=match_text) 

269 

270 if value: 

271 date_str = value.split(match_text)[1].strip().split("-")[0].strip() 

272 value = datetime.strptime(date_str, "%B %d, %Y").date() 

273 

274 return value