db.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. import logging
  2. from pathlib import Path
  3. from typing import NoReturn
  4. import arrow
  5. import pycountry
  6. import yaml
  7. from openpyxl import load_workbook
  8. from pydantic import ValidationError
  9. from app.git import git_add_commit
  10. from app.parser import read_dir, read_file, parse_file
  11. from app.schema import (
  12. CartOrderInfo,
  13. CartSession,
  14. CartSessionItem,
  15. DocumentCheckoutProductInfo,
  16. DocumentProduct,
  17. DocumentProductMeta,
  18. OrdersBlock,
  19. ShippingInfo,
  20. Settings,
  21. ProductShippingMeta,
  22. )
  23. from app.serializer import convert_pydantic_product_to_text, format_order_items, format_order_shipping_info
  24. logger = logging.getLogger(__name__)
  25. def check_product_availability(
  26. product: DocumentProduct,
  27. product_id: str,
  28. quantity: int,
  29. size: str | None,
  30. style: str | None,
  31. settings,
  32. ) -> tuple[bool, bool, int]:
  33. """Check if selected product, eventually with specified option size and style, is
  34. available by checking the product's own inventory table. Returns a tuple of:
  35. - is_product_available: do we have at least 1 copy of it in the inventory,
  36. - is_product_selectable: can we show it in the web view or is it soldout,
  37. - inventory_amount.
  38. """
  39. is_available = False
  40. is_selectable = False
  41. amount = 0
  42. if style or size:
  43. product_variation_id = get_variation_id(product, size, style)
  44. else:
  45. product_variation_id = None
  46. if product_variation_id:
  47. for variant in product.meta.inventory:
  48. if variant.product_variation_id == product_variation_id:
  49. is_available = variant.amount > 0
  50. is_selectable = (variant.amount - quantity) > 0
  51. amount = variant.amount
  52. break
  53. elif len(product.meta.inventory) > 1:
  54. for item in product.meta.inventory:
  55. if item.amount > 0:
  56. is_available, is_selectable, amount = True, True, 1
  57. else:
  58. is_available = product.meta.inventory[0].amount > 0
  59. is_selectable = (product.meta.inventory[0].amount - quantity) > 0
  60. amount = product.meta.inventory[0].amount
  61. return is_available, is_selectable, amount
  62. def update_product_inventory(
  63. filename: str,
  64. quantity: int,
  65. product_variation_id: str,
  66. settings
  67. ) -> NoReturn:
  68. """Read given Product document and update its inventory amount based
  69. on the given product's size and style. Afterwards write back
  70. document to disk.
  71. """
  72. doc = read_file(
  73. settings.git_repo,
  74. filename,
  75. settings.document_match,
  76. settings.block_types,
  77. "products",
  78. )
  79. inventory = doc.meta.inventory
  80. for model in inventory:
  81. if model.product_variation_id == product_variation_id:
  82. # run extra check to see if what would be decreased
  83. # would amount to at best 0 (eg. not going negative)
  84. if model.amount - quantity >= 0:
  85. model.amount = model.amount - quantity
  86. txt_doc = convert_pydantic_product_to_text(doc)
  87. filepath = f"products/{filename}/index.md"
  88. p = Path(f"{settings.git_repo}/{filepath}")
  89. if p.exists():
  90. with open(p, "w") as f:
  91. f.write(txt_doc)
  92. # update git repo
  93. commit_msg = "Update product inventory"
  94. git_add_commit(settings.git_repo, filepath, commit_msg)
  95. def create_variation_id(product: DocumentProduct):
  96. """
  97. Create a list of unique variation IDs mixing main product
  98. id with size and style for variation for specified product
  99. """
  100. if len(product.meta.inventory) > 1:
  101. variation_ids = []
  102. for variant in product.meta.inventory:
  103. if not variant.product_variation_id:
  104. unique_variation_id = f"{product.meta.product_id}_"
  105. if variant.size:
  106. unique_variation_id += f"_{variant.size}"
  107. if variant.style:
  108. unique_variation_id += f"_{variant.style}"
  109. variation_ids.append(unique_variation_id)
  110. else:
  111. variation_ids.append(None)
  112. return variation_ids
  113. return None # returns None in case there are no variations of the product or single variant only
  114. def get_products_id_db(settings) -> dict:
  115. """Returns collection of product_ids and paths associated with it"""
  116. product_data = {}
  117. filepath = f'{Path(settings.git_repo)}/products.yaml'
  118. p = Path(filepath)
  119. if p.exists():
  120. with open(p, 'r') as f:
  121. product_data = yaml.safe_load(f)
  122. return product_data
  123. def write_product_id_db(product:DocumentProduct, settings, products_data = {}):
  124. """Adds product_id - path relation to the products.md in product folder"""
  125. filepath = f'{Path(settings.git_repo)}/products.yaml'
  126. product_file_path = f'{Path(settings.git_repo)}/{product.meta.path}/index.md'
  127. p = Path(filepath)
  128. products_data.setdefault("products", {})
  129. if product.meta.product_id not in products_data["products"] or not p.exists():
  130. products_data["products"][product.meta.product_id] = product_file_path
  131. with open(p, "w") as f:
  132. yaml.dump(products_data, f, default_flow_style = False)
  133. def write_variation_id(product:DocumentProduct, settings):
  134. """ Generate and write IDs for variation of the product into file"""
  135. filepath = f'{Path(settings.git_repo)}/{product.meta.path}/index.md'
  136. product_file = parse_file(settings.git_repo, Path(filepath), settings.block_types)
  137. ids = create_variation_id(product)
  138. if ids:
  139. for i in range(0, len(product.meta.inventory)):
  140. if ids[i]:
  141. product_file.meta.inventory[i].product_variation_id = ids[i]
  142. txt_doc = convert_pydantic_product_to_text(product_file)
  143. p = Path(filepath)
  144. if p.exists():
  145. with open(p, "w") as f:
  146. f.write(txt_doc)
  147. # update git repo
  148. # commit_msg = "Update product inventory"
  149. # git_add_commit(settings.git_repo, filepath, commit_msg)
  150. def get_variation_id(product:DocumentProduct, size: str | None, style: str | None) -> str | None:
  151. """Returns variation ID for product that has size or style"""
  152. if size or style:
  153. for variant in product.meta.inventory:
  154. if variant.size == size and variant.style == style:
  155. return variant.product_variation_id
  156. return None
  157. def get_products_by_category(settings):
  158. """Helper function to product a list of products organized by their categories."""
  159. products = read_dir(
  160. settings.git_repo,
  161. settings.document_match,
  162. settings.document_exclude,
  163. settings.block_types,
  164. exclude_doc="products",
  165. tree="products",
  166. )
  167. categories = {}
  168. for p in products:
  169. categories[p.meta.category] = []
  170. for p in products:
  171. block_img = [block for block in p.blocks if block.type == "image"][0]
  172. p.blocks = []
  173. p.blocks.append(block_img)
  174. categories[p.meta.category].append(p)
  175. return categories
  176. def get_product_by_product_id(
  177. settings: Settings,
  178. product_id: str,
  179. products: dict,
  180. ) -> DocumentProduct:
  181. """Fetch a product by its product-id."""
  182. if products:
  183. product = parse_file(settings.git_repo, Path(products["products"][product_id]), settings.block_types)
  184. return product
  185. else:
  186. return None
  187. def get_docs_by_category(category: str, settings):
  188. """Read a directory of directories and return a sublist with only the
  189. documents matching the given category.
  190. """
  191. pages = read_dir(
  192. settings.git_repo,
  193. settings.document_match,
  194. settings.document_exclude,
  195. settings.block_types,
  196. exclude_doc=None,
  197. tree="pages",
  198. )
  199. return [p for p in pages if p.meta.category == category]
  200. def is_order_id_open(settings: dict[str, str | list[str]], order_id: str) -> bool:
  201. """Check whether given Order ID exists already in orders/index.md
  202. and order status is not `Open`. Return appropriate boolean value.
  203. """
  204. filename = "orders"
  205. orders = read_file(
  206. settings["git_repo"],
  207. filename,
  208. settings["document_match"],
  209. settings["block_types"],
  210. )
  211. if orders:
  212. customer_order = [
  213. order
  214. for order in orders.blocks
  215. if order.order_id == order_id and order.status != "Open"
  216. ]
  217. if len(customer_order) == 0:
  218. return True
  219. else:
  220. return False
  221. def prepare_order_info(
  222. order_meta: dict[str],
  223. payment_meta: dict[str],
  224. product_list: str,
  225. cart: CartSession,
  226. shipping_info: ShippingInfo,
  227. products_meta: list[DocumentProductMeta],
  228. ) -> str:
  229. """Prepare text to save into local-db that sums up the received
  230. order.
  231. """
  232. # -- content order
  233. cart_items = []
  234. cm = cart.meta
  235. ci = cart.items
  236. if len(ci.keys()) > 0:
  237. for k, v in ci.items():
  238. for meta in products_meta:
  239. cart_product_id = meta.product_id
  240. if cart_product_id == v.product_id:
  241. item = CartOrderInfo(
  242. code=meta.code,
  243. title=meta.title,
  244. quantity=v.quantity,
  245. price=v.price * v.quantity,
  246. options=v.options,
  247. )
  248. cart_items.append(item)
  249. items_table = format_order_items(cart_items)
  250. shipping_info_table = format_order_shipping_info(shipping_info)
  251. timestamp = arrow.now().format("YYYY-MM-DD HH:mm:ss ZZ")
  252. return (
  253. f"---\n"
  254. f"type: 'order'\n"
  255. f"date: {timestamp}\n"
  256. f"order_id: '{order_meta['id']}'\n"
  257. f"product_list: '{product_list}'\n"
  258. f"payment_provider: '{payment_meta['provider']}'\n"
  259. f"payment_reference: '{payment_meta['id']}'\n"
  260. f"status: '{order_meta['status']}'\n"
  261. f"items: \n{items_table}"
  262. f"currency: '{cm.currency}'\n"
  263. f"subtotal: '{cm.subtotal}'\n"
  264. f"total_weight: '{cm.total_weight}'\n"
  265. f"shipping: '{cm.shipping}'\n"
  266. f"total: '{cm.total}'\n"
  267. f"shipping_info: \n{shipping_info_table}"
  268. )
  269. def prepare_customer_order_data(
  270. git_repo: str,
  271. document_match: list[str],
  272. document_exclude: list[str],
  273. block_types: list[str],
  274. checkout_session_id: str,
  275. product_list: str,
  276. provider: str,
  277. order_id: str,
  278. cart: CartSession,
  279. shipping_info: ShippingInfo,
  280. ) -> dict[dict, str]:
  281. """Prepare customer order data and git commit message."""
  282. products = read_dir(
  283. git_repo,
  284. document_match,
  285. document_exclude,
  286. block_types,
  287. exclude_doc="products",
  288. tree="products",
  289. )
  290. products_meta = [product.meta for product in products]
  291. order_meta = {"id": order_id, "status": "Open"}
  292. payment_meta = {"id": checkout_session_id, "provider": provider}
  293. customer_order = prepare_order_info(
  294. order_meta, payment_meta, product_list, cart, shipping_info, products_meta
  295. )
  296. commit_msg = "Add order"
  297. return {"data": customer_order, "commit_msg": commit_msg}
  298. def save_shipping_info(order: dict[str], git_repo: str, filepath: str) -> NoReturn:
  299. """Save customer's shipping info data to local git repo."""
  300. # write new text file under <filepath> with the customer's order
  301. p = Path(f"{git_repo}/{filepath}")
  302. with open(p, "a") as f:
  303. f.write(order["data"])
  304. git_add_commit(git_repo, filepath, order["commit_msg"])
  305. def update_order_info(
  306. order_id: str, order_status: str, git_repo: str, filepath: str
  307. ) -> bool:
  308. """Update customer's order info after the payment has been processed."""
  309. p = Path(f"{git_repo}/{filepath}")
  310. try:
  311. with open(p, "r+") as f:
  312. order_content = f.read()
  313. orders = yaml.safe_load_all(order_content)
  314. updated_orders = []
  315. for order in orders:
  316. if order:
  317. if "type" in order and order["type"] == "meta":
  318. meta_block = f"---\n" f"{yaml.safe_dump(order)}"
  319. updated_orders.append(meta_block)
  320. elif "order_id" in order:
  321. if order["order_id"] == order_id:
  322. order["status"] = order_status
  323. # prepare each order entry by hand as pyyaml format things
  324. # in a way we don't like (ref multiline values).
  325. timestamp = arrow.get(order["date"]).format(
  326. "YYYY-MM-DD HH:mm:ss ZZ"
  327. )
  328. items_table = format_order_items(order['items'])
  329. shipping_info_table = format_order_shipping_info(ShippingInfo(**order['shipping_info']))
  330. updated_order = (
  331. f"---\n"
  332. f"type: 'order'\n"
  333. f"date: {timestamp}\n"
  334. f"order_id: '{order['order_id']}'\n"
  335. f"product_list: '{order['product_list']}'\n"
  336. f"payment_provider: '{order['payment_provider']}'\n"
  337. f"payment_reference: '{order['payment_reference']}'\n"
  338. f"status: '{order['status']}'\n"
  339. f"items: \n{items_table}"
  340. f"currency: '{order['currency']}'\n"
  341. f"subtotal: '{order['subtotal']}'\n"
  342. f"total_weight: '{order['total_weight']}'\n"
  343. f"shipping: '{order['shipping']}'\n"
  344. f"total: '{order['total']}'\n"
  345. f"shipping_info: \n{shipping_info_table}"
  346. )
  347. updated_orders.append(updated_order)
  348. new_orders = "".join(updated_orders)
  349. f.seek(0)
  350. f.write(new_orders)
  351. f.truncate()
  352. return True
  353. except FileNotFoundError:
  354. return False
  355. def update_status_order(
  356. event_type: str, client_reference_id: str, git_repo: str, local_db_filepath: str
  357. ) -> bool:
  358. """Helper function to select order's status before updating it."""
  359. status_index = {
  360. "checkout.session.completed": "Complete", # Stripe
  361. "checkout.session.async_payment_succeeded": "Complete", # Stripe
  362. "checkout.session.async_payment_failed": "Failed", # Stripe
  363. "checkout.session.expired": "Expired", # Stripe
  364. "finished": "Complete", # NOWPayments
  365. "expired": "Expired", # NOWPayments
  366. "failed": "Failed", # NOWPayments
  367. }
  368. order_status = status_index[event_type]
  369. status_res = update_order_info(
  370. client_reference_id, order_status, git_repo, local_db_filepath
  371. )
  372. if status_res:
  373. commit_msg = "Update order status"
  374. git_add_commit(git_repo, local_db_filepath, commit_msg)
  375. return status_res
  376. def read_order_info_field(
  377. field: str, order_id: str, git_repo: str, filepath: str
  378. ) -> bool:
  379. """Helper function to read the given field from the Order Info document as YAML."""
  380. p = Path(f"{git_repo}/{filepath}")
  381. try:
  382. with open(p, "r+") as f:
  383. order_content = f.read()
  384. orders = yaml.safe_load_all(order_content)
  385. selected_order = [
  386. order
  387. for order in orders
  388. if "order_id" in order and order["order_id"] == order_id
  389. ]
  390. if len(selected_order) > 0:
  391. selected_order = selected_order[0]
  392. if field in selected_order:
  393. return selected_order[field]
  394. else:
  395. None
  396. except FileNotFoundError:
  397. return None
  398. def pick_last_order_id(git_repo: str, filepath: str) -> str:
  399. """Helper function to pick the ID from the last received order from the Order Info
  400. document. Used for testing purposes.
  401. """
  402. p = Path(f"{git_repo}/{filepath}")
  403. try:
  404. with open(p, "r+") as f:
  405. order_content = f.read()
  406. orders = yaml.safe_load_all(order_content)
  407. return list(orders)[-1]["order_id"]
  408. except FileNotFoundError:
  409. raise FileNotFoundError
  410. def get_product_values_from_checkout_string(
  411. product_info: str,
  412. ) -> list[str, int, str | None]:
  413. """Helper function to parse a string part of the checkout operation, which contains:
  414. - product path (eg. product slug)
  415. - product quantity
  416. - product's size and style
  417. """
  418. cart_product_id, product_quantity = product_info.split("--")
  419. product_quantity = int(product_quantity)
  420. filename, options = cart_product_id.split("__")
  421. if options != "":
  422. size, style = options.split("-")
  423. else:
  424. size = None
  425. style = None
  426. return filename, product_quantity, size, style
  427. def fetch_order_product_list(
  428. client_reference_id: str, git_repo: str, local_db_filepath: str
  429. ) -> list[DocumentCheckoutProductInfo] | None:
  430. """Helper function to select order's status before updating it."""
  431. order_product_info = read_order_info_field(
  432. "product_list", client_reference_id, git_repo, local_db_filepath
  433. )
  434. if order_product_info is None:
  435. logger.warning("fetch_order_product_list error: no order-product-info found.")
  436. return None
  437. product_list_index = []
  438. product_list = order_product_info.split(",")
  439. for product_info in product_list:
  440. filename, quantity, size, style = get_product_values_from_checkout_string(
  441. product_info
  442. )
  443. try:
  444. product_info = DocumentCheckoutProductInfo(
  445. filename=filename, quantity=quantity, size=size, style=style
  446. )
  447. product_list_index.append(product_info)
  448. except ValidationError as e:
  449. import json
  450. logger.error("pydantic validation errors =>")
  451. for e in e.errors():
  452. logger.error(f"error => {json.dumps(e, indent=4)}")
  453. logger.error("fetch_order_product_list error")
  454. return product_list_index
  455. def export_order_to_xlsx(
  456. order: OrdersBlock,
  457. products_shipping_meta: ProductShippingMeta,
  458. order_template_filepath: str,
  459. currency: str,
  460. git_repo,
  461. email_from: str,
  462. ):
  463. """Export customer order to an .xlsx file, ready to be uploaded to
  464. the SwissPost website as part of the shipping workflow.
  465. """
  466. # read from xslx template file (settings.order_upload.filepath)
  467. # update spreadsheet in memory
  468. # export it under `orders/upload/<order-id>.xlsx`
  469. order_template_filepath = f"{git_repo}/{order_template_filepath}"
  470. workbook = load_workbook(filename=order_template_filepath)
  471. sheet = workbook.active
  472. # -- add order info
  473. order_country_iso = pycountry.countries.lookup(order.shipping_info.country).alpha_2
  474. # invoice info
  475. sheet["A2"] = order.order_id
  476. sheet["B2"] = arrow.get(order.date).format("DD/MM/YY")
  477. sheet["C2"] = currency
  478. sheet["D2"] = order.shipping_info.first_name
  479. sheet["E2"] = order.shipping_info.last_name
  480. sheet["H2"] = order.shipping_info.address
  481. sheet["I2"] = order.shipping_info.address_no
  482. sheet["J2"] = order.shipping_info.postal_code
  483. sheet["K2"] = order.shipping_info.city
  484. sheet["M2"] = order_country_iso
  485. sheet["N2"] = order.shipping_info.email
  486. sheet["O2"] = order.shipping_info.phone_number
  487. # recipient info
  488. sheet["R2"] = order.shipping_info.first_name
  489. sheet["S2"] = order.shipping_info.last_name
  490. sheet["U2"] = order.shipping_info.address
  491. sheet["V2"] = order.shipping_info.address_no
  492. sheet["W2"] = order.shipping_info.postal_code
  493. sheet["X2"] = order.shipping_info.city
  494. sheet["Z2"] = order_country_iso
  495. sheet["AA2"] = order.shipping_info.email
  496. sheet["AB2"] = order.shipping_info.phone_number
  497. # add order product items
  498. for idx, item in enumerate(order.items):
  499. # we set the baseline to row 2
  500. row_n = 2 + idx
  501. product = products_shipping_meta[idx]
  502. sheet[f"AC{row_n}"] = item.code
  503. sheet[f"AD{row_n}"] = item.title
  504. sheet[f"AE{row_n}"] = item.quantity
  505. sheet[f"AF{row_n}"] = item.price
  506. sheet[f"AG{row_n}"] = product.weight
  507. sheet[f"AH{row_n}"] = product.customs_tariff_code
  508. sheet[f"AI{row_n}"] = product.origin_country_iso
  509. # -- save file to disk
  510. timestamp = arrow.get(order.date).format("YYYY-MM-DD-HHmmss")
  511. filepath = f"{git_repo}/orders/upload/{timestamp}--{order.order_id}.xlsx"
  512. workbook.save(filename=filepath)
  513. # -- git add commit newly created file
  514. commit_msg = f"Export order {order.order_id} to .xlsx format"
  515. git_add_commit(git_repo, filepath, commit_msg)