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

132 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-18 21:57 +0000

1import logging 

2from datetime import datetime, date 

3from typing import List, Optional, TypeVar 

4from urllib.parse import parse_qs 

5from urllib.parse import urlparse 

6 

7from bs4 import BeautifulSoup, Tag 

8 

9from amazonorders.entity.item import Item 

10from amazonorders.entity.parsable import Parsable 

11from amazonorders.entity.recipient import Recipient 

12from amazonorders.entity.shipment import Shipment 

13from amazonorders.session import BASE_URL 

14 

15__author__ = "Alex Laird" 

16__copyright__ = "Copyright 2024, Alex Laird" 

17__version__ = "1.0.1" 

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 order_str = "Order #{}".format(self.order_number) 

83 

84 order_str += "\n Shipments: {}".format(self.shipments) 

85 order_str += "\n Order Details Link: {}".format(self.order_details_link) 

86 order_str += "\n Grand Total: {}".format(self.grand_total) 

87 order_str += "\n Order Placed Date: {}".format(self.order_placed_date) 

88 order_str += "\n Recipient: {}".format(self.recipient) 

89 if self.payment_method: 

90 order_str += "\n Payment Method: {}".format(self.payment_method) 

91 if self.payment_method_last_4: 

92 order_str += "\n Payment Method Last 4: {}".format(self.payment_method_last_4) 

93 if self.subtotal: 

94 order_str += "\n Subtotal: {}".format(self.subtotal) 

95 if self.shipping_total: 

96 order_str += "\n Shipping Total: {}".format(self.shipping_total) 

97 if self.subscription_discount: 

98 order_str += "\n Subscription Discount: {}".format(self.subscription_discount) 

99 if self.total_before_tax: 

100 order_str += "\n Total Before Tax: {}".format(self.total_before_tax) 

101 if self.estimated_tax: 

102 order_str += "\n Estimated Tax: {}".format(self.estimated_tax) 

103 if self.refund_total: 

104 order_str += "\n Refund Total: {}".format(self.refund_total) 

105 if self.order_shipped_date: 

106 order_str += "\n Order Shipped Date: {}".format(self.order_shipped_date) 

107 if self.refund_completed_date: 

108 order_str += "\n Refund Completed Date: {}".format(self.refund_completed_date) 

109 

110 return order_str 

111 

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

113 return [Shipment(x) for x in self.parsed.find_all("div", {"class": "shipment"})] 

114 

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

116 return [Item(x) for x in self.parsed.find_all("div", {"class": "yohtmlc-item"})] 

117 

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

119 tag = self.parsed.find("a", {"class": "yohtmlc-order-details-link"}) 

120 if tag: 

121 return "{}{}".format(BASE_URL, tag.attrs["href"]) 

122 elif self.order_number: 

123 return "{}/gp/your-account/order-details?orderID={}".format(BASE_URL, self.order_number) 

124 else: 

125 return None 

126 

127 def _parse_order_number(self) -> str: 

128 try: 

129 order_details_link = self._parse_order_details_link() 

130 except: 

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

132 order_details_link = None 

133 if order_details_link: 

134 parsed_url = urlparse(order_details_link) 

135 return parse_qs(parsed_url.query)["orderID"][0] 

136 else: 

137 tag = self.parsed.find("bdi", dir="ltr") 

138 return tag.text.strip() 

139 

140 def _parse_grand_total(self) -> float: 

141 tag = self.parsed.find("div", {"class": "yohtmlc-order-total"}) 

142 if tag: 

143 tag = tag.find("span", {"class": "value"}) 

144 else: 

145 for tag in self.parsed.find("div", id="od-subtotals").find_all("div", {"class": "a-row"}): 

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

147 tag = tag.find("div", {"class": "a-span-last"}) 

148 break 

149 return float(tag.text.strip().replace("$", "")) 

150 

151 def _parse_order_placed_date(self) -> date: 

152 tag = self.parsed.find("span", {"class": "order-date-invoice-item"}) 

153 if tag: 

154 date_str = tag.text.split("Ordered on")[1].strip() 

155 else: 

156 tag = self.parsed.find("div", {"class": "a-span3"}).find_all("span") 

157 date_str = tag[1].text.strip() 

158 return datetime.strptime(date_str, "%B %d, %Y").date() 

159 

160 def _parse_recipient(self) -> Recipient: 

161 tag = self.parsed.find("div", {"class": "displayAddressDiv"}) 

162 if not tag: 

163 script_id = self.parsed.find("div", 

164 id=lambda value: value and value.startswith("shipToInsertionNode")).attrs[ 

165 "id"] 

166 tag = self.parsed.find("script", 

167 id="shipToData-shippingAddress-{}".format(script_id.split("-")[2])) 

168 tag = BeautifulSoup(str(tag.contents[0]).strip(), "html.parser") 

169 return Recipient(tag) 

170 

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

172 tag = self.parsed.find("img", {"class": "pmts-payment-credit-card-instrument-logo"}) 

173 if tag: 

174 return tag.attrs["alt"] 

175 else: 

176 return None 

177 

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

179 tag = self.parsed.find("img", {"class": "pmts-payment-credit-card-instrument-logo"}) 

180 if tag: 

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

182 return ending_sibling.text.split("ending in")[1].strip() 

183 else: 

184 return None 

185 

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

187 for tag in self.parsed.find("div", id="od-subtotals").find_all("div", {"class": "a-row"}): 

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

189 return float(tag.find("div", {"class": "a-span-last"}).text.strip().replace("$", "")) 

190 

191 return None 

192 

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

194 for tag in self.parsed.find("div", id="od-subtotals").find_all("div", {"class": "a-row"}): 

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

196 return float(tag.find("div", {"class": "a-span-last"}).text.strip().replace("$", "")) 

197 

198 return None 

199 

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

201 for tag in self.parsed.find("div", id="od-subtotals").find_all("div", {"class": "a-row"}): 

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

203 return float(tag.find("div", {"class": "a-span-last"}).text.strip().replace("$", "")) 

204 

205 return None 

206 

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

208 for tag in self.parsed.find("div", id="od-subtotals").find_all("div", {"class": "a-row"}): 

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

210 return float(tag.find("div", {"class": "a-span-last"}).text.strip().replace("$", "")) 

211 

212 return None 

213 

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

215 for tag in self.parsed.find("div", id="od-subtotals").find_all("div", {"class": "a-row"}): 

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

217 return float(tag.find("div", {"class": "a-span-last"}).text.strip().replace("$", "")) 

218 

219 return None 

220 

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

222 for tag in self.parsed.find("div", id="od-subtotals").find_all("div", {"class": "a-row"}): 

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

224 return float(tag.find("div", {"class": "a-span-last"}).text.strip().replace("$", "")) 

225 

226 return None 

227 

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

229 # TODO: find a better way to do this 

230 if "Items shipped:" in self.parsed.text: 

231 date_str = self.parsed.text.split("Items shipped:")[1].strip().split("-")[0].strip() 

232 return datetime.strptime(date_str, "%B %d, %Y").date() 

233 else: 

234 return None 

235 

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

237 # TODO: find a better way to do this 

238 if "Refund: Completed" in self.parsed.text: 

239 date_str = self.parsed.text.split("Refund: Completed")[1].strip().split("-")[0].strip() 

240 return datetime.strptime(date_str, "%B %d, %Y").date() 

241 else: 

242 return None