yotpy
Yotpy: An easy-to-use Python wrapper for the Yotpo web API.
1""" 2Yotpy: An easy-to-use Python wrapper for the Yotpo web API. 3""" 4 5__version__ = "0.0.5" 6 7import asyncio 8import aiohttp 9from json import loads as json_loads 10from csv import DictWriter 11from io import StringIO 12from typing import AsyncGenerator, Iterator, Union, Optional, Callable, Union 13from html import unescape 14from urllib.parse import urlencode 15from math import ceil 16from itertools import chain 17 18 19class SessionNotCreatedError(Exception): 20 """Raised when the aiohttp.ClientSession has not been created before making a request.""" 21 def __init__(self, message="Session not created. Please use `async with` context manager to make requests."): 22 super().__init__(message) 23 24 25class PreflightException(Exception): 26 pass 27 28class UploadException(Exception): 29 pass 30 31class SendException(Exception): 32 pass 33 34class CustomException(Exception): 35 pass 36 37# TODO: Refactor naming for the below two classes 38class UserIdNotFound(Exception): 39 """Raised when the user ID cannot be retrieved.""" 40 pass 41 42class AppDataNotFound(Exception): 43 """Raised when the app data cannot be retrieved.""" 44 pass 45 46# TODO: More insightful sentiment analysis 47# TODO: Give better error messages for bad requests 48 49class JSONTransformer: 50 """ 51 A utility class for transforming JSON data into various formats. 52 """ 53 54 @staticmethod 55 def _stream_json_data(json: dict, keys: tuple) -> Iterator[dict[str, Union[tuple, str]]]: 56 """ 57 Stream the full key directory and its final value of a JSON object of arbitrary depth, including lists. 58 59 This method recursively traverses a nested JSON object and yields dictionaries containing the full key 60 directory (as tuples) and their associated values. It handles dictionaries and lists within the JSON object. 61 62 Example: 63 Input JSON: 64 ```json 65 { "a": 66 { "b": 67 [ { "c": "d" }, { "e": [1,2,3] } ] 68 }, 69 "x": { "y": "z" } 70 } 71 ``` 72 Streaming key directories and values: 73 ```python 74 >>> print(list(JSONTransformer._stream_json_data(json, tuple()))) 75 >>> [{"keys": ("a", "b", "0", "c"), "value": "d"}, 76 >>> {"keys": ("a", "b", "1", "e", "0"), "value": 1}, 77 >>> {"keys": ("a", "b", "1", "e", "1"), "value": 2}, 78 >>> {"keys": ("a", "b", "1", "e", "2"), "value": 3}, 79 >>> {"keys": ("x", "y"), "value": "z"}] 80 81 ``` 82 83 Args: 84 json (dict): The JSON object to stream. 85 keys (tuple): The current list of keys being traversed. 86 87 Yields: 88 dict[str, Union[tuple | str]]: A dictionary containing the current key "directory" (as a tuple) and its associated value. 89 90 Returns: 91 Iterator[dict[str, Union[tuple | str]]]: An iterator that yields dictionaries with the current key "directory" and its associated value. 92 93 Note: 94 Tuples have been used instead of lists for the keys argument, for performance at the cost of readability of this function. 95 """ 96 97 for key, value in json.items(): 98 if isinstance(value, dict): 99 # If the value is another dictionary, recursively call this method 100 # with the updated keys "list". 101 for result in JSONTransformer._stream_json_data(value, keys + (key,)): 102 yield result 103 elif isinstance(value, list): 104 # If the value is a list, iterate through the list and recursively 105 # call this method with the updated keys "list" and index as an additional key. 106 for index, item in enumerate(value): 107 if not isinstance(item, (dict, list)): 108 yield {'keys': keys + (key, str(index)), 'value': item} 109 continue 110 111 for result in JSONTransformer._stream_json_data(item, keys + (key, str(index))): 112 yield result 113 else: 114 # If the value is not a dictionary or list, yield a dictionary containing 115 # the current key "list" (as a tuple) and value. 116 yield {'keys': keys + (key,), 'value': value} 117 118 119 @staticmethod 120 def merge_on_key(key: str, list_1: list[dict], list_2: list[dict]) -> list[dict]: 121 """ 122 Merges two lists of dictionaries into a single list, based on matching dictionary keys. 123 124 Args: 125 key (str): The key to match the dictionaries on. 126 list_1 (list[dict]): The first list of dictionaries. 127 list_2 (list[dict]): The second list of dictionaries. 128 129 Returns: 130 list[dict]: A merged list of dictionaries containing merged disctionaries based on matching keys from both input lists. 131 """ 132 if not list_1 or not list_2: 133 raise Exception( 134 f"Cannot merge empty list(s).\nlist_1: length={len(list_1)}\nlist_2: length={len(list_2)}") 135 136 # Use list comprehension to merge dictionaries that have matching keys 137 return [ 138 dict(item_1, **item_2) 139 for item_1 in list_1 140 for item_2 in list_2 141 if item_1.get(key, 0) == item_2.get(key, 1) 142 ] 143 144 @staticmethod 145 def flatten(json: dict[str, any], sep: str, exclude: list[str] = [], include: list[str] = []) -> dict[str, any]: 146 """ 147 Flatten a nested JSON object into a dictionary with flattened keys. 148 149 This method takes a nested JSON object and converts it into a dictionary with 150 keys that represent the nested structure using a specified separator. Optionally, 151 you can provide a list of keys to exclude or include in the resulting dictionary. 152 153 Example: 154 Input JSON: 155 ```json 156 { "a": 157 { "b": 158 [ { "c": "d" }, { "e": [1,2,3] } ] 159 }, 160 "x": { "y": "z" } 161 } 162 ``` 163 Flattening with a "." separator: 164 ```python 165 >>> print(JSONTransformer.flatten(json, ".")) 166 >>> {"a.b.0.c": "d", 167 >>> "a.b.1.e.0": 1, 168 >>> "a.b.1.e.1": 2, 169 >>> "a.b.1.e.2": 3, 170 >>> "x.y": "z"} 171 ``` 172 Include and Exclude should be formatted with the complete nested key output in mind. 173 174 e.g `include=["a.b.0.c"]`, returns `{"a.b.0.c": "d"}` only. 175 176 Args: 177 json (dict): The JSON object to flatten. 178 sep (str): The separator to use for joining nested keys. 179 exclude (list[str]): If specified, exclude keys that match the specified keys and include all others. 180 include (list[str]): If specified, only include keys that match the specified keys. 181 182 Returns: 183 dict[str, str]: A flattened dictionary with keys representing the nested structure. 184 """ 185 186 # Stream the key-value pairs of the JSON object using the _stream_json_data method. 187 data = {} 188 for item in JSONTransformer._stream_json_data(json, tuple()): 189 keys = item['keys'] 190 key_str = sep.join(keys) 191 if (exclude and key_str in exclude) or (include and key_str not in include): 192 continue 193 # Check if the key string matches the specified key strings for content and title. 194 # If so, html.unescape the value, this is from Yotpo's side of things. 195 # This is done so NLTK is able to properly process the reviews. 196 if ("content", "title").count(key_str): 197 data[key_str] = unescape(item['value']) 198 else: 199 data[key_str] = item['value'] 200 return data 201 202 @staticmethod 203 def flatten_list(json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> list[dict[str, any]]: 204 """ 205 Flattens a list of JSON objects into a list of dictionaries with flattened keys. 206 207 This method takes a list of nested JSON objects and converts each JSON object into a 208 dictionary with keys representing the nested structure using a specified separator. 209 Optionally, you can provide a list of keys to exclude or include in the resulting dictionaries. 210 211 Example: 212 Input JSON list: 213 ```json 214 [ 215 { 216 "a": {"b": {"c": "d"}}, 217 "x": {"y": "z"} 218 }, 219 { 220 "p": {"q": {"r": "s"}}, 221 "u": {"v": "w"} 222 } 223 ] 224 ``` 225 Flattening with a "." separator: 226 ```python 227 >>> print(JSONTransformer.flatten_list(json_list, ".")) 228 >>> [ 229 >>> {"a.b.c": "d", "x.y": "z"}, 230 >>> {"p.q.r": "s", "u.v": "w"} 231 >>> ] 232 ``` 233 234 Args: 235 json_list (list[dict]): A list of JSON objects to flatten. 236 sep (str): The separator to use for joining nested keys. 237 exclude (list[str]): If specified, exclude keys that match the specified keys and include all others. 238 include (list[str]): If specified, only include keys that match the specified keys. 239 240 Returns: 241 list[dict[str, any]]: A list of flattened dictionaries with keys representing the nested structure. 242 """ 243 return [JSONTransformer.flatten(point, sep, exclude, include) for point in json_list] 244 245 @staticmethod 246 def get_headers(json_list: list[dict]) -> set[str]: 247 """ 248 Get a complete headers list from a list of JSON objects. 249 250 Args: 251 json_list (list[dict]): A list of JSON objects to iterate over. 252 253 Returns: 254 set[str]: A set of headers. 255 """ 256 return {key for item in json_list for key in item.keys()} 257 258 @staticmethod 259 def from_bigquery_iterator(iterator: Iterator, exclude: set[str] = {}, include: set[str] = {}) -> tuple[set, list]: 260 """ 261 Convert a BigQuery RowIterator into a set of headers and a list of rows. 262 263 Args: 264 iterator (Iterator): A BigQuery RowIterator object. 265 exclude (set[str], optional): A set of field names to exclude from the output. Defaults to an empty set. 266 include (set[str], optional): A set of field names to include in the output. If provided, only these fields 267 will be included. Defaults to an empty set. 268 269 Returns: 270 tuple[set, list]: A tuple containing a set of headers and a list of rows as dictionaries. 271 Headers are the field names included in the output, and rows are dictionaries 272 containing field values for each row in the RowIterator. 273 274 Note: 275 If both `exclude` and `include` are provided, the `include` set takes precedence. 276 """ 277 schema = list(iterator.schema) 278 headers = {field.name for field in schema if (include and field.name in include) or (exclude and field.name not in exclude)} 279 280 rows = [] 281 for row in iterator: 282 [row.pop(field.name, None) for field in schema if field.name not in headers] 283 rows.append(row) 284 285 return headers, rows 286 287 @staticmethod 288 def to_rows(json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> tuple[list, set]: 289 """ 290 Flatten a list of JSON objects into a list of rows and a set of headers. 291 292 This method takes a list of nested JSON objects and converts each JSON object into a 293 dictionary with keys representing the nested structure using a specified separator. 294 It then creates a list of rows, where each row contains the values from the flattened 295 dictionaries, and a set of headers representing the unique keys of the flattened dictionaries. 296 297 Args: 298 json_list (list[dict]): The list of JSON objects to flatten. 299 sep (str): The separator to use for joining nested keys. 300 exclude (list[str]): If specified, exclude keys that match the specified keys and include all others. 301 include (list[str]): If specified, only include keys that match the specified keys. 302 303 Note: 304 If both exclude and include are specified, exclude takes precedence. 305 This is done by using the following logic: 306 ``` 307 if key_str in exclude or (include and key_str not in include): 308 continue 309 ``` 310 311 Returns: 312 tuple[list, set]: A tuple containing the list of rows and the set of headers. 313 """ 314 rows, headers = [], set() 315 # Flatten the JSON object into a list of dictionaries using the flatten method. 316 for item in JSONTransformer.flatten_list(json_list, sep, exclude, include): 317 rows.append(item) 318 headers.update(item.keys()) 319 return rows, headers 320 321 322 @staticmethod 323 def to_csv_stringio(rows: list[dict], headers: set) -> StringIO: 324 """ 325 Convert a list of rows into a CSV formatted StringIO object. 326 327 This method takes a list of rows (dictionaries) and a set of headers, and writes them into 328 a CSV formatted StringIO object. It can be used to create a CSV file-like object without 329 creating an actual file on the filesystem. 330 331 Args: 332 rows (list[dict]): A list of rows to convert into a CSV formatted StringIO object. 333 headers (set): A set of headers to use for the CSV data. 334 335 Returns: 336 StringIO: A CSV formatted StringIO object. 337 """ 338 # Create a StringIO object to write the CSV data to. 339 csv_stringio = StringIO() 340 # Create a csv writer and write the rows to the StringIO object. 341 writer = DictWriter(csv_stringio, fieldnames=headers) 342 writer.writeheader() 343 writer.writerows(rows) 344 345 # Reset the StringIO object's position to the beginning 346 csv_stringio.seek(0) 347 348 return csv_stringio 349 350 351class YotpoAPIWrapper: 352 #NOTE: Update docstring if more methods are added to account for any added functionality outside of the defined scope. 353 """ 354 A class for interacting with the Yotpo API to fetch app and account information, review data, and send manual review requests. 355 356 The YotpoAPIWrapper uses the provided app_key and secret for authentication and constructs the necessary API endpoints for making requests. 357 358 Args: 359 app_key (str): The Yotpo app key for API authentication. 360 secret (str): The client secret to authenticate requests. 361 preferred_uid (Optional[int], optional): The user ID to use for fetching data relating to the Yotpo APP. Defaults to None. 362 363 Raises: 364 Exception: If either app_key or secret is not provided. 365 """ 366 367 # TODO: Update the explanation of the preferred_uid argument to be more accurate/helpful. 368 369 def __init__(self, app_key: str, secret: str, preferred_uid: Optional[int] = None) -> None: 370 if not app_key or not secret: 371 raise Exception(f"app_key(exists={bool(app_key)}) and secret(exists={bool(secret)}) are required.") 372 373 self._app_key = app_key 374 self._secret = secret 375 self.user_id = preferred_uid 376 377 async def __aenter__(self): 378 self.aiohttp_session = aiohttp.ClientSession() 379 self._utoken = await self._get_user_token() 380 381 self.app_endpoint = f"https://api.yotpo.com/v1/apps/{self._app_key}/reviews?utoken={self._utoken}&" 382 self.widget_endpoint = f"https://api.yotpo.com/v1/widget/{self._app_key}/reviews?utoken={self._utoken}&" 383 self.write_user_endpoint = f"https://api-write.yotpo.com/users/me?utoken={self._utoken}" 384 self.write_app_endpoint = f"https://api-write.yotpo.com/apps" 385 386 if self.user_id is None: 387 self.user_id = (await self.get_user())['id'] 388 389 return self 390 391 async def __aexit__(self, exc_type, exc, tb): 392 await self.aiohttp_session.close() 393 394 async def _get_user_token(self) -> str: 395 """ 396 Get the user access token using the provided secret. 397 398 Args: 399 app_key (str): The target app you want authenticated. 400 secret (str): The client secret to authenticate the request. 401 402 Returns: 403 str: The user access token. 404 405 Raises: 406 Exception: If the response status is not OK (200). 407 """ 408 url = "https://api.yotpo.com/oauth/token" 409 data = { 410 "grant_type": "client_credentials", 411 "client_id": self._app_key, 412 "client_secret": self._secret, 413 } 414 415 return (await self._post_request(url, data=data, parser=lambda x: x["access_token"])) 416 417 async def _options_request(self, url: str, method: str) -> bool: 418 """ 419 Asynchronously sends a preflight OPTIONS request to check if the given method is allowed for the given endpoint. 420 421 Args: 422 url (str): The API endpoint URL. 423 424 Returns: 425 bool: True if the method is allowed, False otherwise. 426 """ 427 if not hasattr(self, 'aiohttp_session'): 428 raise SessionNotCreatedError() 429 430 # Cheeky way to make sure `method` is at least syntactically correct. 431 if (method := method.upper()) not in ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE']: 432 raise Exception("Invalid HTTP method: ", method) 433 434 async with self.aiohttp_session.options(url) as response: 435 if response.ok: 436 allowed_methods = response.headers.get("Allow", "") 437 return method in allowed_methods.split(", ") 438 raise Exception(f"Error: {response.status} {response.reason} - {url}") 439 440 async def _get_request(self, url: str, parser: Callable[[dict], dict] = None, exception_type: Exception = CustomException, **kwargs) -> dict: 441 """ 442 Asynchronously sends a GET request to the specified URL and parses the response using the provided parser. 443 444 Args: 445 session (aiohttp.ClientSession): An aiohttp client session for making requests. 446 url (str): The URL to send the request to. 447 parser (Callable[[dict], dict]): A function to parse the response JSON. Defaults to json.loads. 448 exception_type (Union[CustomException, Exception]): The type of exception to raise when an error occurs. Defaults to CustomException. 449 **kwargs: Additional keyword arguments to be passed to the request object. 450 451 Returns: 452 dict: The parsed JSON response. 453 454 Raises: 455 exception_type: If the response status is not 200, an instance of the specified exception_type is raised with an error message. 456 """ 457 if not hasattr(self, 'aiohttp_session'): 458 raise SessionNotCreatedError() 459 460 async with self.aiohttp_session.get(url, **kwargs) as response: 461 if response.status == 200: 462 raw_data = json_loads(await response.read()) 463 if parser is None: 464 return raw_data 465 return parser(raw_data) 466 raise exception_type(f"Error: {response.status} | {response.reason} - {url}") 467 468 async def _post_request(self, url: str, data: dict, parser: Callable[[dict], dict] = None, exception_type: Exception = CustomException, **kwargs) -> dict: 469 """ 470 Asynchronously sends a POST request to the specified URL and parses the response using the provided parser. 471 472 Args: 473 session (aiohttp.ClientSession): An aiohttp client session for making requests. 474 url (str): The URL to send the request to. 475 data (dict): The data to send in the request body. 476 parser (Callable[[dict], dict]): A function to parse the response JSON. Defaults to json.loads. 477 exception_type (Union[CustomException, Exception]): The type of exception to raise when an error occurs. Defaults to CustomException. 478 **kwargs: Additional keyword arguments to be passed to the request object. 479 480 Returns: 481 dict: The parsed JSON response. 482 483 Raises: 484 exception_type: If the response status is not 200, an instance of the specified exception_type is raised with an error message. 485 """ 486 if not hasattr(self, 'aiohttp_session'): 487 raise SessionNotCreatedError() 488 489 async with self.aiohttp_session.post(url, data=data, **kwargs) as response: 490 if response.status == 200: 491 raw_data = json_loads(await response.read()) 492 if parser is None: 493 return raw_data 494 return parser(raw_data) 495 raise exception_type(f"Error: {response.status} | {response.reason} - {url}") 496 497 async def _pages(self, endpoint: str, start_page: int = 1) -> AsyncGenerator[tuple[str, int], None]: 498 """ 499 Asynchronously generate URLs for each page of results. 500 501 Args: 502 endpoint (str): The API endpoint to query. 503 start_page (int): The first page to generate. 504 505 Yields: 506 tuple[str, int] -> 507 str: The URL for the next page of results. 508 int: The current page number. 509 """ 510 last_page = ceil((await self.get_total_reviews()) / 100) 511 is_widget = "widget" in endpoint 512 for num in range(start_page, last_page + 1): 513 yield endpoint + urlencode(({("per_page" if is_widget else "count"): 100, "page": num})), num 514 515 async def get_user(self) -> dict: 516 """ 517 Asynchronously fetches user data from the user endpoint. 518 519 This function returns various user-related data, such as name, email, display_name, company, position, and other 520 metadata like sign_in_count and package details. This data can be useful for understanding user profiles, 521 managing access, or customizing the user experience based on the retrieved information. 522 523 Returns: 524 dict: A dictionary containing user information, including personal details, activity statistics, and package details. 525 526 527 Example: 528 ```python 529 >>> yotpo = YotpoAPIWrapper(app_key, secret) 530 >>> async with yotpo as yp: 531 >>> user = await yp.get_user() 532 ``` 533 534 Raises: 535 Exception: If the request to the user endpoint returns a non-OK status. 536 """ 537 url = self.write_user_endpoint 538 539 return await self._get_request(url, parser=lambda data: data['response']['user']) 540 541 async def get_app(self) -> dict: 542 """ 543 Asynchronously fetches app data for the app associated with the preferred user ID. 544 545 Returns: 546 dict: A dictionary containing app information derived from the specified user. The app information includes 547 details such as app_key, domain, name, url, account type, users, reminders, custom design, created 548 and updated timestamps, tracking code status, account emails, package details, enabled product 549 categories, features usage summary, data for events, category, installed status, organization, 550 and associated apps. 551 552 Raises: 553 AppDataNotFound: If the request to the app endpoint returns a non-OK status. 554 """ 555 # NOTE: The user_id does not appear to actually matter at all, as it still returns data even if it is not a valid user ID. 556 # I suspect `user_id` is just used to track which user is making the request. 557 558 # TODO: If the above is true, we can probably abstract away the `user_id` parameter and just use the user ID from the user endpoint. 559 # And if need be we can add some configuration to allow the user to specify a user ID if they want to. 560 561 url = f"https://api-write.yotpo.com/apps/{self._app_key}?user_id={self.user_id}&utoken={self._utoken}" 562 563 return await self._get_request(url, parser=lambda data: data['response']['app'], exception_type=AppDataNotFound) 564 565 async def get_total_reviews(self) -> int: 566 """ 567 Asynchronously fetches the total number of reviews for an app. 568 569 This method first retrieves the user and their user ID, then fetches the app data 570 associated with that user. Finally, it returns the total number of reviews for the app. 571 572 Returns: 573 int: The total number of reviews for the app. 574 575 Raises: 576 AppDataNotFound: If unable to get the app data. 577 """ 578 app = await self.get_app() 579 580 return app['data_for_events']['total_reviews'] 581 582 async def get_templates(self) -> dict: 583 """ 584 Asynchronously fetch the email templates associated with the Yotpo account. 585 586 This method retrieves the app data, extracts the email templates, and returns them as a dictionary. 587 The primary use case for this function is to obtain the template IDs required for the 'send_review_request' method. 588 589 The returned dictionary contains template objects with properties such as 'id' (template_id), 590 'email_type', 'data', and others. For example: 591 ```python 592 { 593 'id': 10291872, 594 'account_id': 9092811, 595 'email_type_id': 31, 596 'email_type': { 597 'id': 31, 598 'name': 'mail_post_service', 599 'template': 'testimonials_request' 600 }, 601 'data': { 602 'subject': 'Tell us what you think - {company_name}', 603 'header': 'Hello {user},\\n We love having you as a customer and would really appreciate it if you filled out the form below.', 604 'bottom': 'Thanks so much! <br> - {company_name}' 605 }, 606 'formless_call_to_action': '', 607 'days_delay': 3, 608 'email_submission_type_id': 9 609 } 610 ``` 611 Returns: 612 dict: A dictionary containing the email templates, keyed by their names. 613 """ 614 app = await self.get_app() 615 templates = app['account_emails'] 616 return templates 617 618 619 async def fetch_review_page(self, url: str) -> list[dict]: 620 """ 621 Asynchronously fetch a single review page from the specified URL. 622 623 This function fetches review data from the provided URL and parses the response based on whether 624 the URL is for the widget endpoint or not. 625 626 Args: 627 url (str): The URL from which to fetch review data. 628 629 Returns: 630 list[dict]: A list of review dictionaries. 631 632 """ 633 is_widget = "widget" in url 634 return await self._get_request(url, parser=lambda data: data['response']['reviews'] if is_widget else data['reviews']) 635 636 637 async def fetch_all_reviews(self, published: bool = True) -> list[dict]: 638 """ 639 Asynchronously fetch all reviews from the specified endpoints. 640 641 This function fetches review data from the app and widget endpoints if `published` is set to True, 642 and only from the app endpoint if `published` is set to False. The fetched reviews are then merged 643 based on their "id" attribute. 644 645 Args: 646 published (bool, optional): Determines if the function should fetch reviews from both 647 the app and widget endpoints or just the app endpoint. 648 Defaults to True. 649 650 Returns: 651 list[dict]: A list of merged review dictionaries. 652 653 Raises: 654 Exception: If one or more requests fail. 655 """ 656 reviews = [] 657 for endpoint in ([self.app_endpoint, self.widget_endpoint] if published else [self.app_endpoint]): 658 review_requests = [] 659 async for url, _ in self._pages(endpoint): 660 task = asyncio.create_task(self.fetch_review_page(url)) 661 review_requests.append(task) 662 663 print(f"Gathering {len(review_requests)} review requests from {endpoint}...") 664 results = await asyncio.gather(*review_requests, return_exceptions=True) 665 666 if any([isinstance(result, Exception) for result in results]): 667 raise Exception("One or more requests failed.") 668 else: 669 # Flatten the list of lists into one big list using itertools.chain 670 results = list(chain.from_iterable(results)) 671 reviews.append(results) 672 673 return JSONTransformer.merge_on_key("id", *reviews) 674 675 676 async def send_review_request(self, template_id: int | str, csv_stringio: StringIO, spam_check: bool = False): 677 """ 678 Asynchronously send a "manual" review request to Yotpo using a specific email template. 679 680 This method takes a template_id, a CSV formatted StringIO object containing the review 681 request data, and an optional spam_check flag. It uploads the review data to Yotpo and 682 sends the review request using the specified email template. 683 684 Args: 685 template_id (int | str): The ID of the email template to use for the review request. 686 csv_stringio (StringIO): A StringIO object containing the review request data in CSV format. 687 (Use the `JSONTransformer` class to generate this.) 688 spam_check (bool, optional): Whether or not to enable the built-in Yotpo spam check. Defaults to False. 689 690 Returns: 691 dict: A dictionary containing the response data. 692 693 Raises: 694 Exception: If any response status is not 200. 695 UploadException: If the uploaded file is not valid. 696 """ 697 upload_url = f"{self.write_app_endpoint}/{self._app_key}/account_emails/{template_id}/upload_mailing_list" 698 send_url = f"{self.write_app_endpoint}/{self._app_key}/account_emails/{template_id}/send_burst_email" 699 700 upload_response = await self._post_request(upload_url, {"file": csv_stringio, "utoken": self._utoken}) 701 upload_data = upload_response['response']['response'] 702 703 if upload_data['is_valid_file']: 704 send_response = await self._post_request(send_url, {"file_path": upload_data['file_path'], "utoken": self._utoken, "activate_spam_limitations": spam_check}) 705 else: 706 raise UploadException("Error: Uploaded file is not valid") 707 708 return {"upload": upload_response, "send": send_response}
20class SessionNotCreatedError(Exception): 21 """Raised when the aiohttp.ClientSession has not been created before making a request.""" 22 def __init__(self, message="Session not created. Please use `async with` context manager to make requests."): 23 super().__init__(message)
Raised when the aiohttp.ClientSession has not been created before making a request.
Inherited Members
- builtins.BaseException
- with_traceback
- add_note
Common base class for all non-exit exceptions.
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
Common base class for all non-exit exceptions.
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
Common base class for all non-exit exceptions.
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
Common base class for all non-exit exceptions.
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
Raised when the user ID cannot be retrieved.
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
Raised when the app data cannot be retrieved.
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- add_note
50class JSONTransformer: 51 """ 52 A utility class for transforming JSON data into various formats. 53 """ 54 55 @staticmethod 56 def _stream_json_data(json: dict, keys: tuple) -> Iterator[dict[str, Union[tuple, str]]]: 57 """ 58 Stream the full key directory and its final value of a JSON object of arbitrary depth, including lists. 59 60 This method recursively traverses a nested JSON object and yields dictionaries containing the full key 61 directory (as tuples) and their associated values. It handles dictionaries and lists within the JSON object. 62 63 Example: 64 Input JSON: 65 ```json 66 { "a": 67 { "b": 68 [ { "c": "d" }, { "e": [1,2,3] } ] 69 }, 70 "x": { "y": "z" } 71 } 72 ``` 73 Streaming key directories and values: 74 ```python 75 >>> print(list(JSONTransformer._stream_json_data(json, tuple()))) 76 >>> [{"keys": ("a", "b", "0", "c"), "value": "d"}, 77 >>> {"keys": ("a", "b", "1", "e", "0"), "value": 1}, 78 >>> {"keys": ("a", "b", "1", "e", "1"), "value": 2}, 79 >>> {"keys": ("a", "b", "1", "e", "2"), "value": 3}, 80 >>> {"keys": ("x", "y"), "value": "z"}] 81 82 ``` 83 84 Args: 85 json (dict): The JSON object to stream. 86 keys (tuple): The current list of keys being traversed. 87 88 Yields: 89 dict[str, Union[tuple | str]]: A dictionary containing the current key "directory" (as a tuple) and its associated value. 90 91 Returns: 92 Iterator[dict[str, Union[tuple | str]]]: An iterator that yields dictionaries with the current key "directory" and its associated value. 93 94 Note: 95 Tuples have been used instead of lists for the keys argument, for performance at the cost of readability of this function. 96 """ 97 98 for key, value in json.items(): 99 if isinstance(value, dict): 100 # If the value is another dictionary, recursively call this method 101 # with the updated keys "list". 102 for result in JSONTransformer._stream_json_data(value, keys + (key,)): 103 yield result 104 elif isinstance(value, list): 105 # If the value is a list, iterate through the list and recursively 106 # call this method with the updated keys "list" and index as an additional key. 107 for index, item in enumerate(value): 108 if not isinstance(item, (dict, list)): 109 yield {'keys': keys + (key, str(index)), 'value': item} 110 continue 111 112 for result in JSONTransformer._stream_json_data(item, keys + (key, str(index))): 113 yield result 114 else: 115 # If the value is not a dictionary or list, yield a dictionary containing 116 # the current key "list" (as a tuple) and value. 117 yield {'keys': keys + (key,), 'value': value} 118 119 120 @staticmethod 121 def merge_on_key(key: str, list_1: list[dict], list_2: list[dict]) -> list[dict]: 122 """ 123 Merges two lists of dictionaries into a single list, based on matching dictionary keys. 124 125 Args: 126 key (str): The key to match the dictionaries on. 127 list_1 (list[dict]): The first list of dictionaries. 128 list_2 (list[dict]): The second list of dictionaries. 129 130 Returns: 131 list[dict]: A merged list of dictionaries containing merged disctionaries based on matching keys from both input lists. 132 """ 133 if not list_1 or not list_2: 134 raise Exception( 135 f"Cannot merge empty list(s).\nlist_1: length={len(list_1)}\nlist_2: length={len(list_2)}") 136 137 # Use list comprehension to merge dictionaries that have matching keys 138 return [ 139 dict(item_1, **item_2) 140 for item_1 in list_1 141 for item_2 in list_2 142 if item_1.get(key, 0) == item_2.get(key, 1) 143 ] 144 145 @staticmethod 146 def flatten(json: dict[str, any], sep: str, exclude: list[str] = [], include: list[str] = []) -> dict[str, any]: 147 """ 148 Flatten a nested JSON object into a dictionary with flattened keys. 149 150 This method takes a nested JSON object and converts it into a dictionary with 151 keys that represent the nested structure using a specified separator. Optionally, 152 you can provide a list of keys to exclude or include in the resulting dictionary. 153 154 Example: 155 Input JSON: 156 ```json 157 { "a": 158 { "b": 159 [ { "c": "d" }, { "e": [1,2,3] } ] 160 }, 161 "x": { "y": "z" } 162 } 163 ``` 164 Flattening with a "." separator: 165 ```python 166 >>> print(JSONTransformer.flatten(json, ".")) 167 >>> {"a.b.0.c": "d", 168 >>> "a.b.1.e.0": 1, 169 >>> "a.b.1.e.1": 2, 170 >>> "a.b.1.e.2": 3, 171 >>> "x.y": "z"} 172 ``` 173 Include and Exclude should be formatted with the complete nested key output in mind. 174 175 e.g `include=["a.b.0.c"]`, returns `{"a.b.0.c": "d"}` only. 176 177 Args: 178 json (dict): The JSON object to flatten. 179 sep (str): The separator to use for joining nested keys. 180 exclude (list[str]): If specified, exclude keys that match the specified keys and include all others. 181 include (list[str]): If specified, only include keys that match the specified keys. 182 183 Returns: 184 dict[str, str]: A flattened dictionary with keys representing the nested structure. 185 """ 186 187 # Stream the key-value pairs of the JSON object using the _stream_json_data method. 188 data = {} 189 for item in JSONTransformer._stream_json_data(json, tuple()): 190 keys = item['keys'] 191 key_str = sep.join(keys) 192 if (exclude and key_str in exclude) or (include and key_str not in include): 193 continue 194 # Check if the key string matches the specified key strings for content and title. 195 # If so, html.unescape the value, this is from Yotpo's side of things. 196 # This is done so NLTK is able to properly process the reviews. 197 if ("content", "title").count(key_str): 198 data[key_str] = unescape(item['value']) 199 else: 200 data[key_str] = item['value'] 201 return data 202 203 @staticmethod 204 def flatten_list(json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> list[dict[str, any]]: 205 """ 206 Flattens a list of JSON objects into a list of dictionaries with flattened keys. 207 208 This method takes a list of nested JSON objects and converts each JSON object into a 209 dictionary with keys representing the nested structure using a specified separator. 210 Optionally, you can provide a list of keys to exclude or include in the resulting dictionaries. 211 212 Example: 213 Input JSON list: 214 ```json 215 [ 216 { 217 "a": {"b": {"c": "d"}}, 218 "x": {"y": "z"} 219 }, 220 { 221 "p": {"q": {"r": "s"}}, 222 "u": {"v": "w"} 223 } 224 ] 225 ``` 226 Flattening with a "." separator: 227 ```python 228 >>> print(JSONTransformer.flatten_list(json_list, ".")) 229 >>> [ 230 >>> {"a.b.c": "d", "x.y": "z"}, 231 >>> {"p.q.r": "s", "u.v": "w"} 232 >>> ] 233 ``` 234 235 Args: 236 json_list (list[dict]): A list of JSON objects to flatten. 237 sep (str): The separator to use for joining nested keys. 238 exclude (list[str]): If specified, exclude keys that match the specified keys and include all others. 239 include (list[str]): If specified, only include keys that match the specified keys. 240 241 Returns: 242 list[dict[str, any]]: A list of flattened dictionaries with keys representing the nested structure. 243 """ 244 return [JSONTransformer.flatten(point, sep, exclude, include) for point in json_list] 245 246 @staticmethod 247 def get_headers(json_list: list[dict]) -> set[str]: 248 """ 249 Get a complete headers list from a list of JSON objects. 250 251 Args: 252 json_list (list[dict]): A list of JSON objects to iterate over. 253 254 Returns: 255 set[str]: A set of headers. 256 """ 257 return {key for item in json_list for key in item.keys()} 258 259 @staticmethod 260 def from_bigquery_iterator(iterator: Iterator, exclude: set[str] = {}, include: set[str] = {}) -> tuple[set, list]: 261 """ 262 Convert a BigQuery RowIterator into a set of headers and a list of rows. 263 264 Args: 265 iterator (Iterator): A BigQuery RowIterator object. 266 exclude (set[str], optional): A set of field names to exclude from the output. Defaults to an empty set. 267 include (set[str], optional): A set of field names to include in the output. If provided, only these fields 268 will be included. Defaults to an empty set. 269 270 Returns: 271 tuple[set, list]: A tuple containing a set of headers and a list of rows as dictionaries. 272 Headers are the field names included in the output, and rows are dictionaries 273 containing field values for each row in the RowIterator. 274 275 Note: 276 If both `exclude` and `include` are provided, the `include` set takes precedence. 277 """ 278 schema = list(iterator.schema) 279 headers = {field.name for field in schema if (include and field.name in include) or (exclude and field.name not in exclude)} 280 281 rows = [] 282 for row in iterator: 283 [row.pop(field.name, None) for field in schema if field.name not in headers] 284 rows.append(row) 285 286 return headers, rows 287 288 @staticmethod 289 def to_rows(json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> tuple[list, set]: 290 """ 291 Flatten a list of JSON objects into a list of rows and a set of headers. 292 293 This method takes a list of nested JSON objects and converts each JSON object into a 294 dictionary with keys representing the nested structure using a specified separator. 295 It then creates a list of rows, where each row contains the values from the flattened 296 dictionaries, and a set of headers representing the unique keys of the flattened dictionaries. 297 298 Args: 299 json_list (list[dict]): The list of JSON objects to flatten. 300 sep (str): The separator to use for joining nested keys. 301 exclude (list[str]): If specified, exclude keys that match the specified keys and include all others. 302 include (list[str]): If specified, only include keys that match the specified keys. 303 304 Note: 305 If both exclude and include are specified, exclude takes precedence. 306 This is done by using the following logic: 307 ``` 308 if key_str in exclude or (include and key_str not in include): 309 continue 310 ``` 311 312 Returns: 313 tuple[list, set]: A tuple containing the list of rows and the set of headers. 314 """ 315 rows, headers = [], set() 316 # Flatten the JSON object into a list of dictionaries using the flatten method. 317 for item in JSONTransformer.flatten_list(json_list, sep, exclude, include): 318 rows.append(item) 319 headers.update(item.keys()) 320 return rows, headers 321 322 323 @staticmethod 324 def to_csv_stringio(rows: list[dict], headers: set) -> StringIO: 325 """ 326 Convert a list of rows into a CSV formatted StringIO object. 327 328 This method takes a list of rows (dictionaries) and a set of headers, and writes them into 329 a CSV formatted StringIO object. It can be used to create a CSV file-like object without 330 creating an actual file on the filesystem. 331 332 Args: 333 rows (list[dict]): A list of rows to convert into a CSV formatted StringIO object. 334 headers (set): A set of headers to use for the CSV data. 335 336 Returns: 337 StringIO: A CSV formatted StringIO object. 338 """ 339 # Create a StringIO object to write the CSV data to. 340 csv_stringio = StringIO() 341 # Create a csv writer and write the rows to the StringIO object. 342 writer = DictWriter(csv_stringio, fieldnames=headers) 343 writer.writeheader() 344 writer.writerows(rows) 345 346 # Reset the StringIO object's position to the beginning 347 csv_stringio.seek(0) 348 349 return csv_stringio
A utility class for transforming JSON data into various formats.
120 @staticmethod 121 def merge_on_key(key: str, list_1: list[dict], list_2: list[dict]) -> list[dict]: 122 """ 123 Merges two lists of dictionaries into a single list, based on matching dictionary keys. 124 125 Args: 126 key (str): The key to match the dictionaries on. 127 list_1 (list[dict]): The first list of dictionaries. 128 list_2 (list[dict]): The second list of dictionaries. 129 130 Returns: 131 list[dict]: A merged list of dictionaries containing merged disctionaries based on matching keys from both input lists. 132 """ 133 if not list_1 or not list_2: 134 raise Exception( 135 f"Cannot merge empty list(s).\nlist_1: length={len(list_1)}\nlist_2: length={len(list_2)}") 136 137 # Use list comprehension to merge dictionaries that have matching keys 138 return [ 139 dict(item_1, **item_2) 140 for item_1 in list_1 141 for item_2 in list_2 142 if item_1.get(key, 0) == item_2.get(key, 1) 143 ]
Merges two lists of dictionaries into a single list, based on matching dictionary keys.
Arguments:
- key (str): The key to match the dictionaries on.
- list_1 (list[dict]): The first list of dictionaries.
- list_2 (list[dict]): The second list of dictionaries.
Returns:
list[dict]: A merged list of dictionaries containing merged disctionaries based on matching keys from both input lists.
145 @staticmethod 146 def flatten(json: dict[str, any], sep: str, exclude: list[str] = [], include: list[str] = []) -> dict[str, any]: 147 """ 148 Flatten a nested JSON object into a dictionary with flattened keys. 149 150 This method takes a nested JSON object and converts it into a dictionary with 151 keys that represent the nested structure using a specified separator. Optionally, 152 you can provide a list of keys to exclude or include in the resulting dictionary. 153 154 Example: 155 Input JSON: 156 ```json 157 { "a": 158 { "b": 159 [ { "c": "d" }, { "e": [1,2,3] } ] 160 }, 161 "x": { "y": "z" } 162 } 163 ``` 164 Flattening with a "." separator: 165 ```python 166 >>> print(JSONTransformer.flatten(json, ".")) 167 >>> {"a.b.0.c": "d", 168 >>> "a.b.1.e.0": 1, 169 >>> "a.b.1.e.1": 2, 170 >>> "a.b.1.e.2": 3, 171 >>> "x.y": "z"} 172 ``` 173 Include and Exclude should be formatted with the complete nested key output in mind. 174 175 e.g `include=["a.b.0.c"]`, returns `{"a.b.0.c": "d"}` only. 176 177 Args: 178 json (dict): The JSON object to flatten. 179 sep (str): The separator to use for joining nested keys. 180 exclude (list[str]): If specified, exclude keys that match the specified keys and include all others. 181 include (list[str]): If specified, only include keys that match the specified keys. 182 183 Returns: 184 dict[str, str]: A flattened dictionary with keys representing the nested structure. 185 """ 186 187 # Stream the key-value pairs of the JSON object using the _stream_json_data method. 188 data = {} 189 for item in JSONTransformer._stream_json_data(json, tuple()): 190 keys = item['keys'] 191 key_str = sep.join(keys) 192 if (exclude and key_str in exclude) or (include and key_str not in include): 193 continue 194 # Check if the key string matches the specified key strings for content and title. 195 # If so, html.unescape the value, this is from Yotpo's side of things. 196 # This is done so NLTK is able to properly process the reviews. 197 if ("content", "title").count(key_str): 198 data[key_str] = unescape(item['value']) 199 else: 200 data[key_str] = item['value'] 201 return data
Flatten a nested JSON object into a dictionary with flattened keys.
This method takes a nested JSON object and converts it into a dictionary with keys that represent the nested structure using a specified separator. Optionally, you can provide a list of keys to exclude or include in the resulting dictionary.
Example:
Input JSON:
{ "a": { "b": [ { "c": "d" }, { "e": [1,2,3] } ] }, "x": { "y": "z" } }
Flattening with a "." separator:
>>> print(JSONTransformer.flatten(json, ".")) >>> {"a.b.0.c": "d", >>> "a.b.1.e.0": 1, >>> "a.b.1.e.1": 2, >>> "a.b.1.e.2": 3, >>> "x.y": "z"}
Include and Exclude should be formatted with the complete nested key output in mind.
e.g
include=["a.b.0.c"]
, returns{"a.b.0.c": "d"}
only.
Arguments:
- json (dict): The JSON object to flatten.
- sep (str): The separator to use for joining nested keys.
- exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
- include (list[str]): If specified, only include keys that match the specified keys.
Returns:
dict[str, str]: A flattened dictionary with keys representing the nested structure.
203 @staticmethod 204 def flatten_list(json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> list[dict[str, any]]: 205 """ 206 Flattens a list of JSON objects into a list of dictionaries with flattened keys. 207 208 This method takes a list of nested JSON objects and converts each JSON object into a 209 dictionary with keys representing the nested structure using a specified separator. 210 Optionally, you can provide a list of keys to exclude or include in the resulting dictionaries. 211 212 Example: 213 Input JSON list: 214 ```json 215 [ 216 { 217 "a": {"b": {"c": "d"}}, 218 "x": {"y": "z"} 219 }, 220 { 221 "p": {"q": {"r": "s"}}, 222 "u": {"v": "w"} 223 } 224 ] 225 ``` 226 Flattening with a "." separator: 227 ```python 228 >>> print(JSONTransformer.flatten_list(json_list, ".")) 229 >>> [ 230 >>> {"a.b.c": "d", "x.y": "z"}, 231 >>> {"p.q.r": "s", "u.v": "w"} 232 >>> ] 233 ``` 234 235 Args: 236 json_list (list[dict]): A list of JSON objects to flatten. 237 sep (str): The separator to use for joining nested keys. 238 exclude (list[str]): If specified, exclude keys that match the specified keys and include all others. 239 include (list[str]): If specified, only include keys that match the specified keys. 240 241 Returns: 242 list[dict[str, any]]: A list of flattened dictionaries with keys representing the nested structure. 243 """ 244 return [JSONTransformer.flatten(point, sep, exclude, include) for point in json_list]
Flattens a list of JSON objects into a list of dictionaries with flattened keys.
This method takes a list of nested JSON objects and converts each JSON object into a dictionary with keys representing the nested structure using a specified separator. Optionally, you can provide a list of keys to exclude or include in the resulting dictionaries.
Example:
Input JSON list:
[ { "a": {"b": {"c": "d"}}, "x": {"y": "z"} }, { "p": {"q": {"r": "s"}}, "u": {"v": "w"} } ]
Flattening with a "." separator:
>>> print(JSONTransformer.flatten_list(json_list, ".")) >>> [ >>> {"a.b.c": "d", "x.y": "z"}, >>> {"p.q.r": "s", "u.v": "w"} >>> ]
Arguments:
- json_list (list[dict]): A list of JSON objects to flatten.
- sep (str): The separator to use for joining nested keys.
- exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
- include (list[str]): If specified, only include keys that match the specified keys.
Returns:
list[dict[str, any]]: A list of flattened dictionaries with keys representing the nested structure.
246 @staticmethod 247 def get_headers(json_list: list[dict]) -> set[str]: 248 """ 249 Get a complete headers list from a list of JSON objects. 250 251 Args: 252 json_list (list[dict]): A list of JSON objects to iterate over. 253 254 Returns: 255 set[str]: A set of headers. 256 """ 257 return {key for item in json_list for key in item.keys()}
Get a complete headers list from a list of JSON objects.
Arguments:
- json_list (list[dict]): A list of JSON objects to iterate over.
Returns:
set[str]: A set of headers.
259 @staticmethod 260 def from_bigquery_iterator(iterator: Iterator, exclude: set[str] = {}, include: set[str] = {}) -> tuple[set, list]: 261 """ 262 Convert a BigQuery RowIterator into a set of headers and a list of rows. 263 264 Args: 265 iterator (Iterator): A BigQuery RowIterator object. 266 exclude (set[str], optional): A set of field names to exclude from the output. Defaults to an empty set. 267 include (set[str], optional): A set of field names to include in the output. If provided, only these fields 268 will be included. Defaults to an empty set. 269 270 Returns: 271 tuple[set, list]: A tuple containing a set of headers and a list of rows as dictionaries. 272 Headers are the field names included in the output, and rows are dictionaries 273 containing field values for each row in the RowIterator. 274 275 Note: 276 If both `exclude` and `include` are provided, the `include` set takes precedence. 277 """ 278 schema = list(iterator.schema) 279 headers = {field.name for field in schema if (include and field.name in include) or (exclude and field.name not in exclude)} 280 281 rows = [] 282 for row in iterator: 283 [row.pop(field.name, None) for field in schema if field.name not in headers] 284 rows.append(row) 285 286 return headers, rows
Convert a BigQuery RowIterator into a set of headers and a list of rows.
Arguments:
- iterator (Iterator): A BigQuery RowIterator object.
- exclude (set[str], optional): A set of field names to exclude from the output. Defaults to an empty set.
- include (set[str], optional): A set of field names to include in the output. If provided, only these fields will be included. Defaults to an empty set.
Returns:
tuple[set, list]: A tuple containing a set of headers and a list of rows as dictionaries. Headers are the field names included in the output, and rows are dictionaries containing field values for each row in the RowIterator.
Note:
If both
exclude
andinclude
are provided, theinclude
set takes precedence.
288 @staticmethod 289 def to_rows(json_list: list[dict], sep: str, exclude: list[str] = [], include: list[str] = []) -> tuple[list, set]: 290 """ 291 Flatten a list of JSON objects into a list of rows and a set of headers. 292 293 This method takes a list of nested JSON objects and converts each JSON object into a 294 dictionary with keys representing the nested structure using a specified separator. 295 It then creates a list of rows, where each row contains the values from the flattened 296 dictionaries, and a set of headers representing the unique keys of the flattened dictionaries. 297 298 Args: 299 json_list (list[dict]): The list of JSON objects to flatten. 300 sep (str): The separator to use for joining nested keys. 301 exclude (list[str]): If specified, exclude keys that match the specified keys and include all others. 302 include (list[str]): If specified, only include keys that match the specified keys. 303 304 Note: 305 If both exclude and include are specified, exclude takes precedence. 306 This is done by using the following logic: 307 ``` 308 if key_str in exclude or (include and key_str not in include): 309 continue 310 ``` 311 312 Returns: 313 tuple[list, set]: A tuple containing the list of rows and the set of headers. 314 """ 315 rows, headers = [], set() 316 # Flatten the JSON object into a list of dictionaries using the flatten method. 317 for item in JSONTransformer.flatten_list(json_list, sep, exclude, include): 318 rows.append(item) 319 headers.update(item.keys()) 320 return rows, headers
Flatten a list of JSON objects into a list of rows and a set of headers.
This method takes a list of nested JSON objects and converts each JSON object into a dictionary with keys representing the nested structure using a specified separator. It then creates a list of rows, where each row contains the values from the flattened dictionaries, and a set of headers representing the unique keys of the flattened dictionaries.
Arguments:
- json_list (list[dict]): The list of JSON objects to flatten.
- sep (str): The separator to use for joining nested keys.
- exclude (list[str]): If specified, exclude keys that match the specified keys and include all others.
- include (list[str]): If specified, only include keys that match the specified keys.
Note:
If both exclude and include are specified, exclude takes precedence. This is done by using the following logic:
if key_str in exclude or (include and key_str not in include): continue
Returns:
tuple[list, set]: A tuple containing the list of rows and the set of headers.
323 @staticmethod 324 def to_csv_stringio(rows: list[dict], headers: set) -> StringIO: 325 """ 326 Convert a list of rows into a CSV formatted StringIO object. 327 328 This method takes a list of rows (dictionaries) and a set of headers, and writes them into 329 a CSV formatted StringIO object. It can be used to create a CSV file-like object without 330 creating an actual file on the filesystem. 331 332 Args: 333 rows (list[dict]): A list of rows to convert into a CSV formatted StringIO object. 334 headers (set): A set of headers to use for the CSV data. 335 336 Returns: 337 StringIO: A CSV formatted StringIO object. 338 """ 339 # Create a StringIO object to write the CSV data to. 340 csv_stringio = StringIO() 341 # Create a csv writer and write the rows to the StringIO object. 342 writer = DictWriter(csv_stringio, fieldnames=headers) 343 writer.writeheader() 344 writer.writerows(rows) 345 346 # Reset the StringIO object's position to the beginning 347 csv_stringio.seek(0) 348 349 return csv_stringio
Convert a list of rows into a CSV formatted StringIO object.
This method takes a list of rows (dictionaries) and a set of headers, and writes them into a CSV formatted StringIO object. It can be used to create a CSV file-like object without creating an actual file on the filesystem.
Arguments:
- rows (list[dict]): A list of rows to convert into a CSV formatted StringIO object.
- headers (set): A set of headers to use for the CSV data.
Returns:
StringIO: A CSV formatted StringIO object.
352class YotpoAPIWrapper: 353 #NOTE: Update docstring if more methods are added to account for any added functionality outside of the defined scope. 354 """ 355 A class for interacting with the Yotpo API to fetch app and account information, review data, and send manual review requests. 356 357 The YotpoAPIWrapper uses the provided app_key and secret for authentication and constructs the necessary API endpoints for making requests. 358 359 Args: 360 app_key (str): The Yotpo app key for API authentication. 361 secret (str): The client secret to authenticate requests. 362 preferred_uid (Optional[int], optional): The user ID to use for fetching data relating to the Yotpo APP. Defaults to None. 363 364 Raises: 365 Exception: If either app_key or secret is not provided. 366 """ 367 368 # TODO: Update the explanation of the preferred_uid argument to be more accurate/helpful. 369 370 def __init__(self, app_key: str, secret: str, preferred_uid: Optional[int] = None) -> None: 371 if not app_key or not secret: 372 raise Exception(f"app_key(exists={bool(app_key)}) and secret(exists={bool(secret)}) are required.") 373 374 self._app_key = app_key 375 self._secret = secret 376 self.user_id = preferred_uid 377 378 async def __aenter__(self): 379 self.aiohttp_session = aiohttp.ClientSession() 380 self._utoken = await self._get_user_token() 381 382 self.app_endpoint = f"https://api.yotpo.com/v1/apps/{self._app_key}/reviews?utoken={self._utoken}&" 383 self.widget_endpoint = f"https://api.yotpo.com/v1/widget/{self._app_key}/reviews?utoken={self._utoken}&" 384 self.write_user_endpoint = f"https://api-write.yotpo.com/users/me?utoken={self._utoken}" 385 self.write_app_endpoint = f"https://api-write.yotpo.com/apps" 386 387 if self.user_id is None: 388 self.user_id = (await self.get_user())['id'] 389 390 return self 391 392 async def __aexit__(self, exc_type, exc, tb): 393 await self.aiohttp_session.close() 394 395 async def _get_user_token(self) -> str: 396 """ 397 Get the user access token using the provided secret. 398 399 Args: 400 app_key (str): The target app you want authenticated. 401 secret (str): The client secret to authenticate the request. 402 403 Returns: 404 str: The user access token. 405 406 Raises: 407 Exception: If the response status is not OK (200). 408 """ 409 url = "https://api.yotpo.com/oauth/token" 410 data = { 411 "grant_type": "client_credentials", 412 "client_id": self._app_key, 413 "client_secret": self._secret, 414 } 415 416 return (await self._post_request(url, data=data, parser=lambda x: x["access_token"])) 417 418 async def _options_request(self, url: str, method: str) -> bool: 419 """ 420 Asynchronously sends a preflight OPTIONS request to check if the given method is allowed for the given endpoint. 421 422 Args: 423 url (str): The API endpoint URL. 424 425 Returns: 426 bool: True if the method is allowed, False otherwise. 427 """ 428 if not hasattr(self, 'aiohttp_session'): 429 raise SessionNotCreatedError() 430 431 # Cheeky way to make sure `method` is at least syntactically correct. 432 if (method := method.upper()) not in ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE']: 433 raise Exception("Invalid HTTP method: ", method) 434 435 async with self.aiohttp_session.options(url) as response: 436 if response.ok: 437 allowed_methods = response.headers.get("Allow", "") 438 return method in allowed_methods.split(", ") 439 raise Exception(f"Error: {response.status} {response.reason} - {url}") 440 441 async def _get_request(self, url: str, parser: Callable[[dict], dict] = None, exception_type: Exception = CustomException, **kwargs) -> dict: 442 """ 443 Asynchronously sends a GET request to the specified URL and parses the response using the provided parser. 444 445 Args: 446 session (aiohttp.ClientSession): An aiohttp client session for making requests. 447 url (str): The URL to send the request to. 448 parser (Callable[[dict], dict]): A function to parse the response JSON. Defaults to json.loads. 449 exception_type (Union[CustomException, Exception]): The type of exception to raise when an error occurs. Defaults to CustomException. 450 **kwargs: Additional keyword arguments to be passed to the request object. 451 452 Returns: 453 dict: The parsed JSON response. 454 455 Raises: 456 exception_type: If the response status is not 200, an instance of the specified exception_type is raised with an error message. 457 """ 458 if not hasattr(self, 'aiohttp_session'): 459 raise SessionNotCreatedError() 460 461 async with self.aiohttp_session.get(url, **kwargs) as response: 462 if response.status == 200: 463 raw_data = json_loads(await response.read()) 464 if parser is None: 465 return raw_data 466 return parser(raw_data) 467 raise exception_type(f"Error: {response.status} | {response.reason} - {url}") 468 469 async def _post_request(self, url: str, data: dict, parser: Callable[[dict], dict] = None, exception_type: Exception = CustomException, **kwargs) -> dict: 470 """ 471 Asynchronously sends a POST request to the specified URL and parses the response using the provided parser. 472 473 Args: 474 session (aiohttp.ClientSession): An aiohttp client session for making requests. 475 url (str): The URL to send the request to. 476 data (dict): The data to send in the request body. 477 parser (Callable[[dict], dict]): A function to parse the response JSON. Defaults to json.loads. 478 exception_type (Union[CustomException, Exception]): The type of exception to raise when an error occurs. Defaults to CustomException. 479 **kwargs: Additional keyword arguments to be passed to the request object. 480 481 Returns: 482 dict: The parsed JSON response. 483 484 Raises: 485 exception_type: If the response status is not 200, an instance of the specified exception_type is raised with an error message. 486 """ 487 if not hasattr(self, 'aiohttp_session'): 488 raise SessionNotCreatedError() 489 490 async with self.aiohttp_session.post(url, data=data, **kwargs) as response: 491 if response.status == 200: 492 raw_data = json_loads(await response.read()) 493 if parser is None: 494 return raw_data 495 return parser(raw_data) 496 raise exception_type(f"Error: {response.status} | {response.reason} - {url}") 497 498 async def _pages(self, endpoint: str, start_page: int = 1) -> AsyncGenerator[tuple[str, int], None]: 499 """ 500 Asynchronously generate URLs for each page of results. 501 502 Args: 503 endpoint (str): The API endpoint to query. 504 start_page (int): The first page to generate. 505 506 Yields: 507 tuple[str, int] -> 508 str: The URL for the next page of results. 509 int: The current page number. 510 """ 511 last_page = ceil((await self.get_total_reviews()) / 100) 512 is_widget = "widget" in endpoint 513 for num in range(start_page, last_page + 1): 514 yield endpoint + urlencode(({("per_page" if is_widget else "count"): 100, "page": num})), num 515 516 async def get_user(self) -> dict: 517 """ 518 Asynchronously fetches user data from the user endpoint. 519 520 This function returns various user-related data, such as name, email, display_name, company, position, and other 521 metadata like sign_in_count and package details. This data can be useful for understanding user profiles, 522 managing access, or customizing the user experience based on the retrieved information. 523 524 Returns: 525 dict: A dictionary containing user information, including personal details, activity statistics, and package details. 526 527 528 Example: 529 ```python 530 >>> yotpo = YotpoAPIWrapper(app_key, secret) 531 >>> async with yotpo as yp: 532 >>> user = await yp.get_user() 533 ``` 534 535 Raises: 536 Exception: If the request to the user endpoint returns a non-OK status. 537 """ 538 url = self.write_user_endpoint 539 540 return await self._get_request(url, parser=lambda data: data['response']['user']) 541 542 async def get_app(self) -> dict: 543 """ 544 Asynchronously fetches app data for the app associated with the preferred user ID. 545 546 Returns: 547 dict: A dictionary containing app information derived from the specified user. The app information includes 548 details such as app_key, domain, name, url, account type, users, reminders, custom design, created 549 and updated timestamps, tracking code status, account emails, package details, enabled product 550 categories, features usage summary, data for events, category, installed status, organization, 551 and associated apps. 552 553 Raises: 554 AppDataNotFound: If the request to the app endpoint returns a non-OK status. 555 """ 556 # NOTE: The user_id does not appear to actually matter at all, as it still returns data even if it is not a valid user ID. 557 # I suspect `user_id` is just used to track which user is making the request. 558 559 # TODO: If the above is true, we can probably abstract away the `user_id` parameter and just use the user ID from the user endpoint. 560 # And if need be we can add some configuration to allow the user to specify a user ID if they want to. 561 562 url = f"https://api-write.yotpo.com/apps/{self._app_key}?user_id={self.user_id}&utoken={self._utoken}" 563 564 return await self._get_request(url, parser=lambda data: data['response']['app'], exception_type=AppDataNotFound) 565 566 async def get_total_reviews(self) -> int: 567 """ 568 Asynchronously fetches the total number of reviews for an app. 569 570 This method first retrieves the user and their user ID, then fetches the app data 571 associated with that user. Finally, it returns the total number of reviews for the app. 572 573 Returns: 574 int: The total number of reviews for the app. 575 576 Raises: 577 AppDataNotFound: If unable to get the app data. 578 """ 579 app = await self.get_app() 580 581 return app['data_for_events']['total_reviews'] 582 583 async def get_templates(self) -> dict: 584 """ 585 Asynchronously fetch the email templates associated with the Yotpo account. 586 587 This method retrieves the app data, extracts the email templates, and returns them as a dictionary. 588 The primary use case for this function is to obtain the template IDs required for the 'send_review_request' method. 589 590 The returned dictionary contains template objects with properties such as 'id' (template_id), 591 'email_type', 'data', and others. For example: 592 ```python 593 { 594 'id': 10291872, 595 'account_id': 9092811, 596 'email_type_id': 31, 597 'email_type': { 598 'id': 31, 599 'name': 'mail_post_service', 600 'template': 'testimonials_request' 601 }, 602 'data': { 603 'subject': 'Tell us what you think - {company_name}', 604 'header': 'Hello {user},\\n We love having you as a customer and would really appreciate it if you filled out the form below.', 605 'bottom': 'Thanks so much! <br> - {company_name}' 606 }, 607 'formless_call_to_action': '', 608 'days_delay': 3, 609 'email_submission_type_id': 9 610 } 611 ``` 612 Returns: 613 dict: A dictionary containing the email templates, keyed by their names. 614 """ 615 app = await self.get_app() 616 templates = app['account_emails'] 617 return templates 618 619 620 async def fetch_review_page(self, url: str) -> list[dict]: 621 """ 622 Asynchronously fetch a single review page from the specified URL. 623 624 This function fetches review data from the provided URL and parses the response based on whether 625 the URL is for the widget endpoint or not. 626 627 Args: 628 url (str): The URL from which to fetch review data. 629 630 Returns: 631 list[dict]: A list of review dictionaries. 632 633 """ 634 is_widget = "widget" in url 635 return await self._get_request(url, parser=lambda data: data['response']['reviews'] if is_widget else data['reviews']) 636 637 638 async def fetch_all_reviews(self, published: bool = True) -> list[dict]: 639 """ 640 Asynchronously fetch all reviews from the specified endpoints. 641 642 This function fetches review data from the app and widget endpoints if `published` is set to True, 643 and only from the app endpoint if `published` is set to False. The fetched reviews are then merged 644 based on their "id" attribute. 645 646 Args: 647 published (bool, optional): Determines if the function should fetch reviews from both 648 the app and widget endpoints or just the app endpoint. 649 Defaults to True. 650 651 Returns: 652 list[dict]: A list of merged review dictionaries. 653 654 Raises: 655 Exception: If one or more requests fail. 656 """ 657 reviews = [] 658 for endpoint in ([self.app_endpoint, self.widget_endpoint] if published else [self.app_endpoint]): 659 review_requests = [] 660 async for url, _ in self._pages(endpoint): 661 task = asyncio.create_task(self.fetch_review_page(url)) 662 review_requests.append(task) 663 664 print(f"Gathering {len(review_requests)} review requests from {endpoint}...") 665 results = await asyncio.gather(*review_requests, return_exceptions=True) 666 667 if any([isinstance(result, Exception) for result in results]): 668 raise Exception("One or more requests failed.") 669 else: 670 # Flatten the list of lists into one big list using itertools.chain 671 results = list(chain.from_iterable(results)) 672 reviews.append(results) 673 674 return JSONTransformer.merge_on_key("id", *reviews) 675 676 677 async def send_review_request(self, template_id: int | str, csv_stringio: StringIO, spam_check: bool = False): 678 """ 679 Asynchronously send a "manual" review request to Yotpo using a specific email template. 680 681 This method takes a template_id, a CSV formatted StringIO object containing the review 682 request data, and an optional spam_check flag. It uploads the review data to Yotpo and 683 sends the review request using the specified email template. 684 685 Args: 686 template_id (int | str): The ID of the email template to use for the review request. 687 csv_stringio (StringIO): A StringIO object containing the review request data in CSV format. 688 (Use the `JSONTransformer` class to generate this.) 689 spam_check (bool, optional): Whether or not to enable the built-in Yotpo spam check. Defaults to False. 690 691 Returns: 692 dict: A dictionary containing the response data. 693 694 Raises: 695 Exception: If any response status is not 200. 696 UploadException: If the uploaded file is not valid. 697 """ 698 upload_url = f"{self.write_app_endpoint}/{self._app_key}/account_emails/{template_id}/upload_mailing_list" 699 send_url = f"{self.write_app_endpoint}/{self._app_key}/account_emails/{template_id}/send_burst_email" 700 701 upload_response = await self._post_request(upload_url, {"file": csv_stringio, "utoken": self._utoken}) 702 upload_data = upload_response['response']['response'] 703 704 if upload_data['is_valid_file']: 705 send_response = await self._post_request(send_url, {"file_path": upload_data['file_path'], "utoken": self._utoken, "activate_spam_limitations": spam_check}) 706 else: 707 raise UploadException("Error: Uploaded file is not valid") 708 709 return {"upload": upload_response, "send": send_response}
A class for interacting with the Yotpo API to fetch app and account information, review data, and send manual review requests.
The YotpoAPIWrapper uses the provided app_key and secret for authentication and constructs the necessary API endpoints for making requests.
Arguments:
- app_key (str): The Yotpo app key for API authentication.
- secret (str): The client secret to authenticate requests.
- preferred_uid (Optional[int], optional): The user ID to use for fetching data relating to the Yotpo APP. Defaults to None.
Raises:
- Exception: If either app_key or secret is not provided.
370 def __init__(self, app_key: str, secret: str, preferred_uid: Optional[int] = None) -> None: 371 if not app_key or not secret: 372 raise Exception(f"app_key(exists={bool(app_key)}) and secret(exists={bool(secret)}) are required.") 373 374 self._app_key = app_key 375 self._secret = secret 376 self.user_id = preferred_uid
516 async def get_user(self) -> dict: 517 """ 518 Asynchronously fetches user data from the user endpoint. 519 520 This function returns various user-related data, such as name, email, display_name, company, position, and other 521 metadata like sign_in_count and package details. This data can be useful for understanding user profiles, 522 managing access, or customizing the user experience based on the retrieved information. 523 524 Returns: 525 dict: A dictionary containing user information, including personal details, activity statistics, and package details. 526 527 528 Example: 529 ```python 530 >>> yotpo = YotpoAPIWrapper(app_key, secret) 531 >>> async with yotpo as yp: 532 >>> user = await yp.get_user() 533 ``` 534 535 Raises: 536 Exception: If the request to the user endpoint returns a non-OK status. 537 """ 538 url = self.write_user_endpoint 539 540 return await self._get_request(url, parser=lambda data: data['response']['user'])
Asynchronously fetches user data from the user endpoint.
This function returns various user-related data, such as name, email, display_name, company, position, and other metadata like sign_in_count and package details. This data can be useful for understanding user profiles, managing access, or customizing the user experience based on the retrieved information.
Returns:
dict: A dictionary containing user information, including personal details, activity statistics, and package details.
Example:
>>> yotpo = YotpoAPIWrapper(app_key, secret) >>> async with yotpo as yp: >>> user = await yp.get_user()
Raises:
- Exception: If the request to the user endpoint returns a non-OK status.
542 async def get_app(self) -> dict: 543 """ 544 Asynchronously fetches app data for the app associated with the preferred user ID. 545 546 Returns: 547 dict: A dictionary containing app information derived from the specified user. The app information includes 548 details such as app_key, domain, name, url, account type, users, reminders, custom design, created 549 and updated timestamps, tracking code status, account emails, package details, enabled product 550 categories, features usage summary, data for events, category, installed status, organization, 551 and associated apps. 552 553 Raises: 554 AppDataNotFound: If the request to the app endpoint returns a non-OK status. 555 """ 556 # NOTE: The user_id does not appear to actually matter at all, as it still returns data even if it is not a valid user ID. 557 # I suspect `user_id` is just used to track which user is making the request. 558 559 # TODO: If the above is true, we can probably abstract away the `user_id` parameter and just use the user ID from the user endpoint. 560 # And if need be we can add some configuration to allow the user to specify a user ID if they want to. 561 562 url = f"https://api-write.yotpo.com/apps/{self._app_key}?user_id={self.user_id}&utoken={self._utoken}" 563 564 return await self._get_request(url, parser=lambda data: data['response']['app'], exception_type=AppDataNotFound)
Asynchronously fetches app data for the app associated with the preferred user ID.
Returns:
dict: A dictionary containing app information derived from the specified user. The app information includes details such as app_key, domain, name, url, account type, users, reminders, custom design, created and updated timestamps, tracking code status, account emails, package details, enabled product categories, features usage summary, data for events, category, installed status, organization, and associated apps.
Raises:
- AppDataNotFound: If the request to the app endpoint returns a non-OK status.
566 async def get_total_reviews(self) -> int: 567 """ 568 Asynchronously fetches the total number of reviews for an app. 569 570 This method first retrieves the user and their user ID, then fetches the app data 571 associated with that user. Finally, it returns the total number of reviews for the app. 572 573 Returns: 574 int: The total number of reviews for the app. 575 576 Raises: 577 AppDataNotFound: If unable to get the app data. 578 """ 579 app = await self.get_app() 580 581 return app['data_for_events']['total_reviews']
Asynchronously fetches the total number of reviews for an app.
This method first retrieves the user and their user ID, then fetches the app data associated with that user. Finally, it returns the total number of reviews for the app.
Returns:
int: The total number of reviews for the app.
Raises:
- AppDataNotFound: If unable to get the app data.
583 async def get_templates(self) -> dict: 584 """ 585 Asynchronously fetch the email templates associated with the Yotpo account. 586 587 This method retrieves the app data, extracts the email templates, and returns them as a dictionary. 588 The primary use case for this function is to obtain the template IDs required for the 'send_review_request' method. 589 590 The returned dictionary contains template objects with properties such as 'id' (template_id), 591 'email_type', 'data', and others. For example: 592 ```python 593 { 594 'id': 10291872, 595 'account_id': 9092811, 596 'email_type_id': 31, 597 'email_type': { 598 'id': 31, 599 'name': 'mail_post_service', 600 'template': 'testimonials_request' 601 }, 602 'data': { 603 'subject': 'Tell us what you think - {company_name}', 604 'header': 'Hello {user},\\n We love having you as a customer and would really appreciate it if you filled out the form below.', 605 'bottom': 'Thanks so much! <br> - {company_name}' 606 }, 607 'formless_call_to_action': '', 608 'days_delay': 3, 609 'email_submission_type_id': 9 610 } 611 ``` 612 Returns: 613 dict: A dictionary containing the email templates, keyed by their names. 614 """ 615 app = await self.get_app() 616 templates = app['account_emails'] 617 return templates
Asynchronously fetch the email templates associated with the Yotpo account.
This method retrieves the app data, extracts the email templates, and returns them as a dictionary. The primary use case for this function is to obtain the template IDs required for the 'send_review_request' method.
The returned dictionary contains template objects with properties such as 'id' (template_id), 'email_type', 'data', and others. For example:
{
'id': 10291872,
'account_id': 9092811,
'email_type_id': 31,
'email_type': {
'id': 31,
'name': 'mail_post_service',
'template': 'testimonials_request'
},
'data': {
'subject': 'Tell us what you think - {company_name}',
'header': 'Hello {user},\n We love having you as a customer and would really appreciate it if you filled out the form below.',
'bottom': 'Thanks so much! <br> - {company_name}'
},
'formless_call_to_action': '',
'days_delay': 3,
'email_submission_type_id': 9
}
Returns:
dict: A dictionary containing the email templates, keyed by their names.
620 async def fetch_review_page(self, url: str) -> list[dict]: 621 """ 622 Asynchronously fetch a single review page from the specified URL. 623 624 This function fetches review data from the provided URL and parses the response based on whether 625 the URL is for the widget endpoint or not. 626 627 Args: 628 url (str): The URL from which to fetch review data. 629 630 Returns: 631 list[dict]: A list of review dictionaries. 632 633 """ 634 is_widget = "widget" in url 635 return await self._get_request(url, parser=lambda data: data['response']['reviews'] if is_widget else data['reviews'])
Asynchronously fetch a single review page from the specified URL.
This function fetches review data from the provided URL and parses the response based on whether the URL is for the widget endpoint or not.
Arguments:
- url (str): The URL from which to fetch review data.
Returns:
list[dict]: A list of review dictionaries.
638 async def fetch_all_reviews(self, published: bool = True) -> list[dict]: 639 """ 640 Asynchronously fetch all reviews from the specified endpoints. 641 642 This function fetches review data from the app and widget endpoints if `published` is set to True, 643 and only from the app endpoint if `published` is set to False. The fetched reviews are then merged 644 based on their "id" attribute. 645 646 Args: 647 published (bool, optional): Determines if the function should fetch reviews from both 648 the app and widget endpoints or just the app endpoint. 649 Defaults to True. 650 651 Returns: 652 list[dict]: A list of merged review dictionaries. 653 654 Raises: 655 Exception: If one or more requests fail. 656 """ 657 reviews = [] 658 for endpoint in ([self.app_endpoint, self.widget_endpoint] if published else [self.app_endpoint]): 659 review_requests = [] 660 async for url, _ in self._pages(endpoint): 661 task = asyncio.create_task(self.fetch_review_page(url)) 662 review_requests.append(task) 663 664 print(f"Gathering {len(review_requests)} review requests from {endpoint}...") 665 results = await asyncio.gather(*review_requests, return_exceptions=True) 666 667 if any([isinstance(result, Exception) for result in results]): 668 raise Exception("One or more requests failed.") 669 else: 670 # Flatten the list of lists into one big list using itertools.chain 671 results = list(chain.from_iterable(results)) 672 reviews.append(results) 673 674 return JSONTransformer.merge_on_key("id", *reviews)
Asynchronously fetch all reviews from the specified endpoints.
This function fetches review data from the app and widget endpoints if published
is set to True,
and only from the app endpoint if published
is set to False. The fetched reviews are then merged
based on their "id" attribute.
Arguments:
- published (bool, optional): Determines if the function should fetch reviews from both the app and widget endpoints or just the app endpoint. Defaults to True.
Returns:
list[dict]: A list of merged review dictionaries.
Raises:
- Exception: If one or more requests fail.
677 async def send_review_request(self, template_id: int | str, csv_stringio: StringIO, spam_check: bool = False): 678 """ 679 Asynchronously send a "manual" review request to Yotpo using a specific email template. 680 681 This method takes a template_id, a CSV formatted StringIO object containing the review 682 request data, and an optional spam_check flag. It uploads the review data to Yotpo and 683 sends the review request using the specified email template. 684 685 Args: 686 template_id (int | str): The ID of the email template to use for the review request. 687 csv_stringio (StringIO): A StringIO object containing the review request data in CSV format. 688 (Use the `JSONTransformer` class to generate this.) 689 spam_check (bool, optional): Whether or not to enable the built-in Yotpo spam check. Defaults to False. 690 691 Returns: 692 dict: A dictionary containing the response data. 693 694 Raises: 695 Exception: If any response status is not 200. 696 UploadException: If the uploaded file is not valid. 697 """ 698 upload_url = f"{self.write_app_endpoint}/{self._app_key}/account_emails/{template_id}/upload_mailing_list" 699 send_url = f"{self.write_app_endpoint}/{self._app_key}/account_emails/{template_id}/send_burst_email" 700 701 upload_response = await self._post_request(upload_url, {"file": csv_stringio, "utoken": self._utoken}) 702 upload_data = upload_response['response']['response'] 703 704 if upload_data['is_valid_file']: 705 send_response = await self._post_request(send_url, {"file_path": upload_data['file_path'], "utoken": self._utoken, "activate_spam_limitations": spam_check}) 706 else: 707 raise UploadException("Error: Uploaded file is not valid") 708 709 return {"upload": upload_response, "send": send_response}
Asynchronously send a "manual" review request to Yotpo using a specific email template.
This method takes a template_id, a CSV formatted StringIO object containing the review request data, and an optional spam_check flag. It uploads the review data to Yotpo and sends the review request using the specified email template.
Arguments:
- template_id (int | str): The ID of the email template to use for the review request.
- csv_stringio (StringIO): A StringIO object containing the review request data in CSV format.
(Use the
JSONTransformer
class to generate this.) - spam_check (bool, optional): Whether or not to enable the built-in Yotpo spam check. Defaults to False.
Returns:
dict: A dictionary containing the response data.
Raises:
- Exception: If any response status is not 200.
- UploadException: If the uploaded file is not valid.