db.py 20 KB

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