Coverage for amazonorders/entity/order.py: 98.28%
174 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-07 21:56 +0000
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-07 21:56 +0000
1import json
2import logging
3from datetime import datetime, date
4from typing import List, Optional, TypeVar
5from urllib.parse import urlparse, parse_qs
7from bs4 import BeautifulSoup, Tag
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
15__author__ = "Alex Laird"
16__copyright__ = "Copyright 2024, Alex Laird"
17__version__ = "1.0.7"
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. Only populated when ``full_details`` is ``True``.
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. Only populated when ``full_details`` is ``True``.
60 self.payment_method_last_4: Optional[str] = self._parse_payment_method_last_4() if self.full_details else None
61 #: The Order subtotal. Only populated when ``full_details`` is ``True``.
62 self.subtotal: Optional[float] = self._parse_subtotal() if self.full_details else None
63 #: The Order shipping total. Only populated when ``full_details`` is ``True``.
64 self.shipping_total: Optional[float] = self._parse_shipping_total() if self.full_details else None
65 #: The Order Subscribe & Save discount. Only populated when ``full_details`` is ``True``.
66 self.subscription_discount: Optional[float] = self._parse_subscription_discount() if self.full_details else None
67 #: The Order total before tax. Only populated when ``full_details`` is ``True``.
68 self.total_before_tax: Optional[float] = self._parse_total_before_tax() if self.full_details else None
69 #: The Order estimated tax. Only populated when ``full_details`` is ``True``.
70 self.estimated_tax: Optional[float] = self._parse_estimated_tax() if self.full_details else None
71 #: The Order refund total. Only populated when ``full_details`` is ``True``.
72 self.refund_total: Optional[float] = self._parse_refund_total() if self.full_details else None
73 #: The Order shipped date. Only populated when ``full_details`` is ``True``.
74 self.order_shipped_date: Optional[date] = self._parse_order_shipping_date() if self.full_details else None
75 #: The Order refund total. Only populated when ``full_details`` is ``True``.
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 return "Order #{}: {}".format(self.order_number, self.items)
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
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
94 def _parse_order_details_link(self) -> Optional[str]:
95 value = self.simple_parse(constants.FIELD_ORDER_DETAILS_LINK_SELECTOR, link=True)
97 if not value and self.order_number:
98 value = "{}?orderID={}".format(constants.ORDER_DETAILS_URL, self.order_number)
100 return value
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
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)
115 return value
117 def _parse_grand_total(self) -> float:
118 value = self.simple_parse(constants.FIELD_ORDER_GRAND_TOTAL_SELECTOR)
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
128 value = float(value.replace("$", ""))
130 return value
132 def _parse_order_placed_date(self) -> date:
133 value = self.simple_parse(constants.FIELD_ORDER_PLACED_DATE_SELECTOR)
135 if "Ordered on" in value:
136 split_str = "Ordered on"
137 else:
138 split_str = "Order placed"
140 value = value.split(split_str)[1].strip()
141 value = datetime.strptime(value, "%B %d, %Y").date()
143 return value
145 def _parse_recipient(self) -> Recipient:
146 value = self.parsed.select_one(constants.FIELD_ORDER_ADDRESS_SELECTOR)
148 if not value:
149 value = self.parsed.select_one(constants.FIELD_ORDER_ADDRESS_FALLBACK_1_SELECTOR)
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")
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")
161 return Recipient(value)
163 def _parse_payment_method(self) -> Optional[str]:
164 value = None
166 tag = self.parsed.select_one(constants.FIELD_ORDER_PAYMENT_METHOD_SELECTOR)
167 if tag:
168 value = tag["alt"]
170 return value
172 def _parse_payment_method_last_4(self) -> Optional[str]:
173 value = None
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()
182 return value
184 def _parse_subtotal(self) -> Optional[float]:
185 value = None
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
194 return value
196 def _parse_shipping_total(self) -> Optional[float]:
197 value = None
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
206 return value
208 def _parse_subscription_discount(self) -> Optional[float]:
209 value = None
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
218 return value
220 def _parse_total_before_tax(self) -> Optional[float]:
221 value = None
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
230 return value
232 def _parse_estimated_tax(self) -> Optional[float]:
233 value = None
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
242 return value
244 def _parse_refund_total(self) -> Optional[float]:
245 value = None
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
254 return value
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)
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()
264 return value
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)
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()
274 return value