db.py 20 KB

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