db.py 19 KB

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