template.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. import os
  2. import random
  3. import hashlib
  4. from pathlib import Path
  5. from typing import Literal
  6. import pycountry
  7. from starlette.datastructures import URL
  8. from app.db import (
  9. check_product_availability,
  10. get_docs_by_category,
  11. get_product_by_product_id,
  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. products: list[DocumentProduct],
  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. checkout_items = []
  96. for item in cart.items.values():
  97. product = get_product_by_product_id(settings, item.product_id)
  98. checkout_item = {
  99. "path": product.meta.path,
  100. "title": product.meta.title,
  101. "image": {"url": "", "width": 0},
  102. "product_id": item.product_id,
  103. "options": item.options,
  104. "price": item.price,
  105. "weight": product.meta.weight,
  106. "quantity": item.quantity,
  107. "total": item.price * item.quantity,
  108. "availability": False,
  109. "is_selectable": False,
  110. }
  111. item_image = [block for block in product.blocks if block.type == "image"]
  112. if len(item_image) > 0:
  113. item_image = item_image[0]
  114. checkout_item["image"]["url"] = item_image.url
  115. checkout_item["image"]["width"] = item_image.info.width
  116. checkout_item["image"]["height"] = item_image.info.height
  117. triplet = check_product_availability(
  118. product,
  119. Path(checkout_item["path"]).stem,
  120. checkout_item["quantity"],
  121. checkout_item["options"].size,
  122. checkout_item["options"].style,
  123. settings,
  124. )
  125. is_product_available, is_product_selectable, inventory_amount = triplet
  126. show_checkout.append(is_product_available)
  127. checkout_item["availability"] = is_product_available
  128. checkout_item["is_selectable"] = is_product_selectable
  129. if js_update:
  130. checkout_item = CheckoutItemUpdate(**checkout_item)
  131. else:
  132. checkout_item = CheckoutItem(**checkout_item)
  133. checkout_items.append(checkout_item)
  134. return {
  135. "countries": countries,
  136. "checkout_items": checkout_items,
  137. "show_checkout": any(show_checkout),
  138. "doc": doc,
  139. }
  140. def prepare_sets_for_menu(settings: Settings) -> list[dict[str, str]]:
  141. """Get Lookbook pages and return only dict with title and path for
  142. each doc.
  143. """
  144. lookbooks = get_docs_by_category("lookbook", settings)
  145. sets = [
  146. {"title": lookbook.meta.title, "path": lookbook.meta.path}
  147. for lookbook in lookbooks
  148. ]
  149. return sets
  150. def prepare_email_order(order_id: str, settings: dict[str, list[str]]) -> dict[str]:
  151. """Prepare data for email order."""
  152. filename = "orders"
  153. orders = read_file(
  154. settings["git_repo"],
  155. filename,
  156. settings["document_match"],
  157. settings["block_types"],
  158. "",
  159. )
  160. order = [order for order in orders.blocks if order.order_id == order_id]
  161. if len(order) > 0:
  162. order = order[0]
  163. content = typeset_order_content(
  164. order.items, order.currency, order.subtotal, order.shipping, order.total
  165. )
  166. shipping_info = typeset_order_shipping_info(order.shipping_info)
  167. return {
  168. "order_id": order.order_id,
  169. "content": content,
  170. "payment_provider": order.payment_provider,
  171. "payment_reference": order.payment_reference,
  172. "shipping_info": shipping_info,
  173. "email": order.shipping_info.email,
  174. }
  175. def make_srcset_url(
  176. selected_size_width: int,
  177. img_width: int,
  178. fileparent,
  179. parent,
  180. p,
  181. BASE_URL: str,
  182. size_attr: bool,
  183. ):
  184. """Return a srcset URL in the format."""
  185. # check if img width is bigger than given srcset value
  186. # (eg. avoid to produce a srcset rule for an actual image file
  187. # that we did not produce as a thumbnail)
  188. if img_width > selected_size_width:
  189. # prepare a string for each srcset rule
  190. filename_new = f"{fileparent}/{Path(parent).stem}__{p.stem}_{selected_size_width}{p.suffix}"
  191. if size_attr:
  192. filename_new = f"{filename_new} {selected_size_width}w"
  193. new_url = f"{BASE_URL}{filename_new}"
  194. return new_url
  195. def to_srcset(
  196. url,
  197. template: Literal["main", "product-grid", "product", "page", "checkout"],
  198. img_width: int | None,
  199. parent: str,
  200. size: int | None = None,
  201. size_attr: bool = True,
  202. ) -> str:
  203. """Generate correct srcset-style URL to send to the backend
  204. and get back the desired resized image.
  205. Eg:
  206. => path/to/image.jpg
  207. path/to/image_<size>w.jpg
  208. URL can also be a str in the format => <filename>.<ext>
  209. """
  210. SIZES = {
  211. "main": [
  212. (320,),
  213. (640,),
  214. (760,),
  215. (1024,),
  216. (1366,),
  217. (1600,),
  218. (1920,),
  219. (2400,),
  220. (2880,),
  221. (3840,),
  222. ],
  223. "product-grid": [
  224. (320,),
  225. (640,),
  226. (380,),
  227. (760,),
  228. (256,),
  229. (512,),
  230. (340,),
  231. (683,),
  232. (400,),
  233. (800,),
  234. (480,),
  235. (960,),
  236. (460,),
  237. (920,),
  238. (560,),
  239. (1120,),
  240. ],
  241. "product": [
  242. (320,),
  243. (640,),
  244. (760,),
  245. (1024,),
  246. (1200,),
  247. (1366,),
  248. (1920,),
  249. (2400,),
  250. ],
  251. "page": [
  252. (320,),
  253. (640,),
  254. (760,),
  255. (1024,),
  256. (1366,),
  257. (1600,),
  258. (1920,),
  259. (2400,),
  260. ],
  261. "checkout": [(120, 120), (240, 240), (220, 220), (440, 440)],
  262. }
  263. if type(url) is URL:
  264. BASE_URL = f"{url.scheme}://{url.netloc}"
  265. # eg. /media/AJ5.jpg => /media/AJ5_<size>w.jpg
  266. tokens = url.path.split("/")
  267. fileparent = "/".join(tokens[:-1])
  268. filename = tokens[-1]
  269. p = Path(filename)
  270. template_sizes = SIZES[template]
  271. if size:
  272. selected_size_width = template_sizes[size][0]
  273. if img_width:
  274. return make_srcset_url(
  275. selected_size_width,
  276. img_width,
  277. fileparent,
  278. parent,
  279. p,
  280. BASE_URL,
  281. size_attr,
  282. )
  283. else:
  284. return ""
  285. # --
  286. srcset_list = []
  287. for selected_size in template_sizes:
  288. selected_size_width = selected_size[0]
  289. if img_width:
  290. new_url = make_srcset_url(
  291. selected_size_width,
  292. img_width,
  293. fileparent,
  294. parent,
  295. p,
  296. BASE_URL,
  297. size_attr,
  298. )
  299. srcset_list.append(new_url)
  300. # combine all srcset rules into one string
  301. srcset_list = [srcset for srcset in srcset_list if srcset]
  302. srcset_matrix = ",\n".join(srcset_list)
  303. return srcset_matrix
  304. elif type(url) is str:
  305. # sometimes if writing HTML wrongly for the jinja2 template
  306. # (?) starlette return a url string and not a url object. in
  307. # this case just return the url string as-is.
  308. return url
  309. def assets_hashing(resource: str) -> str:
  310. """
  311. Append the hash of <resource>'s modified time to the given <resource>,
  312. to make the web browser fetch the latest version of <resource>.
  313. Eg. soft cache-invalidation.
  314. => /<resource>-<file-modified-hash>.<ext>
  315. => /styles-<hash>.css
  316. """
  317. fp = Path(f"{Path(__file__).parent.parent}/{resource.path}")
  318. filepath = Path(resource.path)
  319. if fp.exists:
  320. mtime = fp.stat().st_mtime
  321. # hash mtime with sha256
  322. hash_mtime = hashlib.sha256(b"{mtime}")
  323. hex_digest = hash_mtime.hexdigest()
  324. return f"{filepath.parent}/{filepath.stem}-{hex_digest[:7]}{filepath.suffix}"
  325. else:
  326. return f"{resource}"
  327. def make_product_selected(
  328. size: str | None, style: str | None, quantity: int
  329. ) -> dict[str, str | int]:
  330. """Helper function to construct a dictionary with only not-None
  331. values. We use this as a JSON response, to let javascript update
  332. the current URL with a querystring with the selected product
  333. options. In case an option is None, we simply don't want to add it
  334. as query param.
  335. """
  336. product_selected = {"quantity": quantity}
  337. if size:
  338. product_selected["size"] = size
  339. if style:
  340. product_selected["style"] = style
  341. return product_selected
  342. def make_product_info_code(item):
  343. """
  344. Output a product info string in the format:
  345. <item.path>__<item.options.size>--<item.options.style>--<item.quantity>
  346. """
  347. p = item.path
  348. o = ""
  349. q = item.quantity
  350. if item.options.size and item.options.style:
  351. o = f"{item.options.size}-{item.options.style}"
  352. return f"{p}__{o}--{q}"
  353. def make_slugify(text: str) -> str:
  354. return slugify(text)