db.py 20 KB

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