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

135 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-25 22:50 +0000

1import logging 

2from datetime import datetime, date 

3from typing import List, Optional, TypeVar 

4from urllib.parse import urlparse, parse_qs 

5 

6from bs4 import BeautifulSoup, Tag 

7 

8from amazonorders.entity.item import Item 

9from amazonorders.entity.parsable import Parsable 

10from amazonorders.entity.recipient import Recipient 

11from amazonorders.entity.shipment import Shipment 

12from amazonorders.session import BASE_URL 

13 

14__author__ = "Alex Laird" 

15__copyright__ = "Copyright 2024, Alex Laird" 

16__version__ = "1.0.5" 

17 

18logger = logging.getLogger(__name__) 

19 

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

21 

22 

23class Order(Parsable): 

24 """ 

25 An Amazon Order. 

26 """ 

27 

28 def __init__(self, 

29 parsed: Tag, 

30 full_details: bool = False, 

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

32 super().__init__(parsed) 

33 

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

35 self.full_details: bool = full_details 

36 

37 #: The Order Shipments. 

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

39 #: The Order Items. 

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

41 #: The Order number. 

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

43 #: The Order details link. 

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

45 self._parse_order_details_link) 

46 #: The Order grand total. 

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

48 #: The Order placed date. 

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

50 self._parse_order_placed_date) 

51 #: The Order Recipients. 

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

53 

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

55 

56 #: The Order payment method. 

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

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

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

60 #: The Order subtotal. 

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

62 #: The Order shipping total. 

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

64 #: The Order Subscribe & Save discount. 

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

66 #: The Order total before tax. 

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

68 #: The Order estimated tax. 

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

70 #: The Order refund total. 

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

72 #: The Order shipped date. 

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

74 #: The Order refund total. 

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

76 

77 def __repr__(self) -> str: 

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

79 

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

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

82 

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

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

85 shipments.sort() 

86 return shipments 

87 

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

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

90 items.sort() 

91 return items 

92 

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

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

95 if tag: 

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

97 elif self.order_number: 

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

99 else: 

100 return None 

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 if order_details_link: 

109 parsed_url = urlparse(order_details_link) 

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

111 else: 

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

113 return tag.text.strip() 

114 

115 def _parse_grand_total(self) -> float: 

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

117 if tag: 

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

119 else: 

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

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

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

123 break 

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

125 

126 def _parse_order_placed_date(self) -> date: 

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

128 if tag: 

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

130 else: 

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

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

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

134 

135 def _parse_recipient(self) -> Recipient: 

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

137 if not tag: 

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

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

140 "id"] 

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

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

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

144 return Recipient(tag) 

145 

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

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

148 if tag: 

149 return tag.attrs["alt"] 

150 else: 

151 return None 

152 

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

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

155 if tag: 

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

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

158 else: 

159 return None 

160 

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

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

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

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

165 

166 return None 

167 

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

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

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

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

172 

173 return None 

174 

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

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

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

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

179 

180 return None 

181 

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

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

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

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

186 

187 return None 

188 

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

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

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

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

193 

194 return None 

195 

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

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

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

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

200 

201 return None 

202 

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

204 # TODO: find a better way to do this 

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

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

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

208 else: 

209 return None 

210 

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

212 # TODO: find a better way to do this 

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

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

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

216 else: 

217 return None