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
« 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
6from bs4 import BeautifulSoup, Tag
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
14__author__ = "Alex Laird"
15__copyright__ = "Copyright 2024, Alex Laird"
16__version__ = "1.0.5"
18logger = logging.getLogger(__name__)
20Entity = TypeVar('Entity', bound='Order')
23class Order(Parsable):
24 """
25 An Amazon Order.
26 """
28 def __init__(self,
29 parsed: Tag,
30 full_details: bool = False,
31 clone: Optional[Entity] = None) -> None:
32 super().__init__(parsed)
34 #: If the Orders full details were populated from its details page.
35 self.full_details: bool = full_details
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)
54 # Fields below this point are only populated if `full_details` is True
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
77 def __repr__(self) -> str:
78 return "<Order #{}: \"{}\">".format(self.order_number, self.items)
80 def __str__(self) -> str: # pragma: no cover
81 return "Order #{}: {}".format(self.order_number, self.items)
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
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
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
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()
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("$", ""))
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()
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)
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
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
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("$", ""))
166 return None
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("$", ""))
173 return None
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("$", ""))
180 return None
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("$", ""))
187 return None
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("$", ""))
194 return None
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("$", ""))
201 return None
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
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