template.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. import random
  2. import hashlib
  3. from pathlib import Path
  4. from typing import Literal
  5. import pycountry
  6. from starlette.datastructures import URL
  7. from app.db import (
  8. check_product_availability,
  9. get_docs_by_category,
  10. get_product_by_product_id,
  11. get_products_id_db,
  12. )
  13. from app.parser import read_file
  14. from app.schema import (
  15. CartSession,
  16. CheckoutItem,
  17. CheckoutItemUpdate,
  18. DocumentBlock,
  19. DocumentCheckout,
  20. DocumentProduct,
  21. Settings,
  22. )
  23. from app.serializer import typeset_order_content, typeset_order_shipping_info
  24. from slugify import slugify
  25. def prepare_product(doc: DocumentProduct) -> dict[str, list[DocumentBlock]]:
  26. """Re-shape DocumentProductBlocks to the layout needs of
  27. ./templates/product.html
  28. """
  29. blocks = {
  30. "texts": [],
  31. "images": [],
  32. }
  33. for block in doc.blocks:
  34. if block.type == "text":
  35. blocks["texts"].append(block)
  36. elif block.type == "image":
  37. blocks["images"].append(block)
  38. return blocks
  39. def prepare_related_products(
  40. docs: list[DocumentProduct], product_id: str
  41. ) -> list[DocumentProduct]:
  42. """Given a list of DocumentProduct coming from ./<products>/, remove
  43. the product index page and pick a random selection of 5 products
  44. to display in the Related Products section.
  45. """
  46. random.seed()
  47. def generate_random_index(length):
  48. for i in range(0, length):
  49. N = 1 + random.randrange(3)
  50. for j in range(0, N):
  51. random_idx = random.randrange(length - j)
  52. return random_idx
  53. products = []
  54. for doc in docs:
  55. if doc.meta.path not in products:
  56. if (
  57. Path(doc.meta.path).stem != product_id
  58. and doc.meta.template == "product"
  59. ):
  60. doc.blocks = prepare_product(doc)
  61. # check if there's at least 1 image before adding this
  62. # item to the list of related products
  63. if len(doc.blocks["images"]) > 0:
  64. products.append(doc)
  65. random_selection = []
  66. products_length = len(products)
  67. for i in range(0, products_length):
  68. if len(random_selection) == 5:
  69. break
  70. idx = generate_random_index(products_length)
  71. if products[idx] not in random_selection:
  72. random_selection.append(products[idx])
  73. return random_selection
  74. def prepare_checkout(
  75. cart: CartSession,
  76. settings: Settings,
  77. doc: DocumentCheckout,
  78. product_id: str = None,
  79. js_update: bool = False,
  80. ) -> dict[str, list[str] | dict[str, str | int] | DocumentCheckout]:
  81. """Return dictionary with necessary data to display the Checkout view:
  82. - list of countries for final checkout form
  83. - list of checkout items, including whether they're still
  84. available (eg. meanwhile somebody else might have bought any of
  85. them); we update the cart itself while doing this to minimize
  86. the number of duplicate operations that otherwise would happen
  87. in a different yet similar function
  88. - checkout document
  89. """
  90. # -- prepare list of countries
  91. countries = [country.name for country in pycountry.countries]
  92. # -- prepare checkout items and update cart
  93. show_checkout = []
  94. # map over each item in the cart and create a new item object
  95. products = get_products_id_db(settings)
  96. checkout_items = []
  97. for item in cart.items.values():
  98. product = get_product_by_product_id(settings, item.product_id, products)
  99. checkout_item = {
  100. "path": product.meta.path,
  101. "title": product.meta.title,
  102. "image": {"url": "", "width": 0},
  103. "product_id": item.product_id,
  104. "options": item.options,
  105. "price": item.price,
  106. "weight": product.meta.weight,
  107. "quantity": item.quantity,
  108. "total": item.price * item.quantity,
  109. "availability": False,
  110. "is_selectable": False,
  111. }
  112. item_image = [block for block in product.blocks if block.type == "image"]
  113. if len(item_image) > 0:
  114. item_image = item_image[0]
  115. checkout_item["image"]["url"] = item_image.url
  116. checkout_item["image"]["width"] = item_image.info.width
  117. checkout_item["image"]["height"] = item_image.info.height
  118. triplet = check_product_availability(
  119. product,
  120. Path(checkout_item["path"]).stem,
  121. checkout_item["quantity"],
  122. checkout_item["options"].size,
  123. checkout_item["options"].style,
  124. settings,
  125. )
  126. is_product_available, is_product_selectable, inventory_amount = triplet
  127. show_checkout.append(is_product_available)
  128. checkout_item["availability"] = is_product_available
  129. checkout_item["is_selectable"] = is_product_selectable
  130. if js_update:
  131. checkout_item = CheckoutItemUpdate(**checkout_item)
  132. else:
  133. checkout_item = CheckoutItem(**checkout_item)
  134. checkout_items.append(checkout_item)
  135. return {
  136. "countries": countries,
  137. "checkout_items": checkout_items,
  138. "show_checkout": any(show_checkout),
  139. "doc": doc,
  140. }
  141. def prepare_sets_for_menu(settings: Settings) -> list[dict[str, str]]:
  142. """Get Lookbook pages and return only dict with title and path for
  143. each doc.
  144. """
  145. lookbooks = get_docs_by_category("lookbook", settings)
  146. sets = [
  147. {"title": lookbook.meta.title, "path": lookbook.meta.path}
  148. for lookbook in lookbooks
  149. ]
  150. return sets
  151. def prepare_email_order(order_id: str, settings: dict[str, list[str]]) -> dict[str]:
  152. """Prepare data for email order."""
  153. filename = "orders"
  154. orders = read_file(
  155. settings["git_repo"],
  156. filename,
  157. settings["document_match"],
  158. settings["block_types"],
  159. "",
  160. )
  161. order = [order for order in orders.blocks if order.order_id == order_id]
  162. if len(order) > 0:
  163. order = order[0]
  164. content = typeset_order_content(
  165. order.items, order.currency, order.subtotal, order.shipping, order.total
  166. )
  167. shipping_info = typeset_order_shipping_info(order.shipping_info)
  168. return {
  169. "order_id": order.order_id,
  170. "content": content,
  171. "payment_provider": order.payment_provider,
  172. "payment_reference": order.payment_reference,
  173. "shipping_info": shipping_info,
  174. "email": order.shipping_info.email,
  175. }
  176. def make_srcset_url(
  177. selected_size_width: int,
  178. img_width: int,
  179. fileparent,
  180. parent,
  181. p,
  182. BASE_URL: str,
  183. size_attr: bool,
  184. ):
  185. """Return a srcset URL in the format."""
  186. # check if img width is bigger than given srcset value
  187. # (eg. avoid to produce a srcset rule for an actual image file
  188. # that we did not produce as a thumbnail)
  189. if img_width > selected_size_width:
  190. # prepare a string for each srcset rule
  191. filename_new = f"{fileparent}/{Path(parent).stem}__{p.stem}_{selected_size_width}{p.suffix}"
  192. if size_attr:
  193. filename_new = f"{filename_new} {selected_size_width}w"
  194. new_url = f"{BASE_URL}{filename_new}"
  195. return new_url
  196. def to_srcset(
  197. url,
  198. template: Literal["main", "product-grid", "product", "page", "checkout"],
  199. img_width: int | None,
  200. parent: str,
  201. size: int | None = None,
  202. size_attr: bool = True,
  203. ) -> str:
  204. """Generate correct srcset-style URL to send to the backend
  205. and get back the desired resized image.
  206. Eg:
  207. => path/to/image.jpg
  208. path/to/image_<size>w.jpg
  209. URL can also be a str in the format => <filename>.<ext>
  210. """
  211. SIZES = {
  212. "main": [
  213. (320,),
  214. (640,),
  215. (760,),
  216. (1024,),
  217. (1366,),
  218. (1600,),
  219. (1920,),
  220. (2400,),
  221. (2880,),
  222. (3840,),
  223. ],
  224. "product-grid": [
  225. (320,),
  226. (640,),
  227. (380,),
  228. (760,),
  229. (256,),
  230. (512,),
  231. (340,),
  232. (683,),
  233. (400,),
  234. (800,),
  235. (480,),
  236. (960,),
  237. (460,),
  238. (920,),
  239. (560,),
  240. (1120,),
  241. ],
  242. "product": [
  243. (320,),
  244. (640,),
  245. (760,),
  246. (1024,),
  247. (1200,),
  248. (1366,),
  249. (1920,),
  250. (2400,),
  251. ],
  252. "page": [
  253. (320,),
  254. (640,),
  255. (760,),
  256. (1024,),
  257. (1366,),
  258. (1600,),
  259. (1920,),
  260. (2400,),
  261. ],
  262. "checkout": [(120, 120), (240, 240), (220, 220), (440, 440)],
  263. }
  264. if type(url) is URL:
  265. BASE_URL = f"{url.scheme}://{url.netloc}"
  266. # eg. /media/AJ5.jpg => /media/AJ5_<size>w.jpg
  267. tokens = url.path.split("/")
  268. fileparent = "/".join(tokens[:-1])
  269. filename = tokens[-1]
  270. p = Path(filename)
  271. template_sizes = SIZES[template]
  272. if size:
  273. selected_size_width = template_sizes[size][0]
  274. if img_width:
  275. return make_srcset_url(
  276. selected_size_width,
  277. img_width,
  278. fileparent,
  279. parent,
  280. p,
  281. BASE_URL,
  282. size_attr,
  283. )
  284. else:
  285. return ""
  286. # --
  287. srcset_list = []
  288. for selected_size in template_sizes:
  289. selected_size_width = selected_size[0]
  290. if img_width:
  291. new_url = make_srcset_url(
  292. selected_size_width,
  293. img_width,
  294. fileparent,
  295. parent,
  296. p,
  297. BASE_URL,
  298. size_attr,
  299. )
  300. srcset_list.append(new_url)
  301. # combine all srcset rules into one string
  302. srcset_list = [srcset for srcset in srcset_list if srcset]
  303. srcset_matrix = ",\n".join(srcset_list)
  304. return srcset_matrix
  305. elif type(url) is str:
  306. # sometimes if writing HTML wrongly for the jinja2 template
  307. # (?) starlette return a url string and not a url object. in
  308. # this case just return the url string as-is.
  309. return url
  310. def assets_hashing(resource: str) -> str:
  311. """
  312. Append the hash of <resource>'s modified time to the given <resource>,
  313. to make the web browser fetch the latest version of <resource>.
  314. Eg. soft cache-invalidation.
  315. => /<resource>-<file-modified-hash>.<ext>
  316. => /styles-<hash>.css
  317. """
  318. fp = Path(f"{Path(__file__).parent.parent}/{resource.path}")
  319. filepath = Path(resource.path)
  320. if fp.exists:
  321. mtime = fp.stat().st_mtime
  322. # hash mtime with sha256
  323. hash_mtime = hashlib.sha256(b"{mtime}")
  324. hex_digest = hash_mtime.hexdigest()
  325. return f"{filepath.parent}/{filepath.stem}-{hex_digest[:7]}{filepath.suffix}"
  326. else:
  327. return f"{resource}"
  328. def make_product_selected(
  329. size: str | None, style: str | None, quantity: int
  330. ) -> dict[str, str | int]:
  331. """Helper function to construct a dictionary with only not-None
  332. values. We use this as a JSON response, to let javascript update
  333. the current URL with a querystring with the selected product
  334. options. In case an option is None, we simply don't want to add it
  335. as query param.
  336. """
  337. product_selected = {"quantity": quantity}
  338. if size:
  339. product_selected["size"] = size
  340. if style:
  341. product_selected["style"] = style
  342. return product_selected
  343. def make_product_info_code(item):
  344. """
  345. Output a product info string in the format:
  346. <item.path>__<item.options.size>--<item.options.style>--<item.quantity>
  347. """
  348. p = item.path
  349. o = ""
  350. q = item.quantity
  351. if item.options.size and item.options.style:
  352. o = f"{item.options.size}-{item.options.style}"
  353. return f"{p}__{o}--{q}"
  354. def make_slugify(text: str) -> str:
  355. return slugify(text)