db.py 20 KB

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