Coverage for amazonorders/entity/order.py: 93.94%
132 statements
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-18 14:34 +0000
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-18 14:34 +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
7from bs4 import BeautifulSoup, Tag
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
15__author__ = "Alex Laird"
16__copyright__ = "Copyright 2024, Alex Laird"
17__version__ = "1.0.1"
19logger = logging.getLogger(__name__)
21Entity = TypeVar('Entity', bound='Order')
24class Order(Parsable):
25 """
26 An Amazon Order.
27 """
29 def __init__(self,
30 parsed: Tag,
31 full_details: bool = False,
32 clone: Optional[Entity] = None) -> None:
33 super().__init__(parsed)
35 #: If the Orders full details were populated from its details page.
36 self.full_details: bool = full_details
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)
55 # Fields below this point are only populated if `full_details` is True
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
78 def __repr__(self) -> str:
79 return "<Order #{}: \"{}\">".format(self.order_number, self.items)
81 def __str__(self) -> str: # pragma: no cover
82 order_str = "Order #{}".format(self.order_number)
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)
110 return order_str
112 def _parse_shipments(self) -> List[Shipment]:
113 return [Shipment(x) for x in self.parsed.find_all("div", {"class": "shipment"})]
115 def _parse_items(self) -> List[Item]:
116 return [Item(x) for x in self.parsed.find_all("div", {"class": "yohtmlc-item"})]
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
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()
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("$", ""))
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()
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)
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
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
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("$", ""))
191 return None
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("$", ""))
198 return None
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("$", ""))
205 return None
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("$", ""))
212 return None
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("$", ""))
219 return None
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("$", ""))
226 return None
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
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