df_site 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. df_site/__init__.py +1 -0
  2. df_site/__main__.py +37 -0
  3. df_site/admin.py +130 -0
  4. df_site/apps.py +57 -0
  5. df_site/components/__init__.py +1 -0
  6. df_site/components/base.py +82 -0
  7. df_site/components/detail.py +191 -0
  8. df_site/components/list.py +446 -0
  9. df_site/components/list_filters.py +74 -0
  10. df_site/components/registry.py +55 -0
  11. df_site/constants.py +71 -0
  12. df_site/context_processors.py +61 -0
  13. df_site/defaults.py +319 -0
  14. df_site/dynamic_settings.py +37 -0
  15. df_site/form_fields.py +138 -0
  16. df_site/management/__init__.py +1 -0
  17. df_site/management/commands/__init__.py +1 -0
  18. df_site/management/commands/add_image.py +104 -0
  19. df_site/management/commands/generate_favicon.py +47 -0
  20. df_site/middleware.py +20 -0
  21. df_site/migrations/0001_initial.py +220 -0
  22. df_site/migrations/0002_alter_alertribbon_message_alter_alertribbon_summary.py +23 -0
  23. df_site/migrations/__init__.py +0 -0
  24. df_site/model_fields.py +35 -0
  25. df_site/models.py +130 -0
  26. df_site/postman/__init__.py +1 -0
  27. df_site/postman/forms.py +38 -0
  28. df_site/postman/urls.py +75 -0
  29. df_site/postman/views.py +65 -0
  30. df_site/static/css/app.css +0 -0
  31. df_site/static/css/base.css +22208 -0
  32. df_site/static/css/ckeditor5.css +422 -0
  33. df_site/static/favicon/android-chrome-192x192.png +0 -0
  34. df_site/static/favicon/android-chrome-512x512.png +0 -0
  35. df_site/static/favicon/apple-touch-icon.png +0 -0
  36. df_site/static/favicon/favicon-16x16.png +0 -0
  37. df_site/static/favicon/favicon-32x32.png +0 -0
  38. df_site/static/favicon/favicon.ico +0 -0
  39. df_site/static/favicon/mstile-150x150.png +0 -0
  40. df_site/static/favicon/safari-pinned-tab.svg +46 -0
  41. df_site/static/images/accessibility.svg +1 -0
  42. df_site/static/images/align-bottom.svg +1 -0
  43. df_site/static/images/align-center.svg +1 -0
  44. df_site/static/images/align-justify.svg +1 -0
  45. df_site/static/images/align-left.svg +1 -0
  46. df_site/static/images/align-middle.svg +1 -0
  47. df_site/static/images/align-right.svg +1 -0
  48. df_site/static/images/align-top.svg +1 -0
  49. df_site/static/images/bold.svg +1 -0
  50. df_site/static/images/browse-files.svg +1 -0
  51. df_site/static/images/bulletedlist.svg +1 -0
  52. df_site/static/images/cancel.svg +1 -0
  53. df_site/static/images/caption.svg +1 -0
  54. df_site/static/images/check.svg +1 -0
  55. df_site/static/images/code.svg +1 -0
  56. df_site/static/images/codeblock.svg +1 -0
  57. df_site/static/images/cog.svg +1 -0
  58. df_site/static/images/color-palette.svg +1 -0
  59. df_site/static/images/color-tile-check.svg +1 -0
  60. df_site/static/images/drag-handle.svg +1 -0
  61. df_site/static/images/drag-indicator.svg +1 -0
  62. df_site/static/images/dropdown-arrow.svg +1 -0
  63. df_site/static/images/eraser.svg +1 -0
  64. df_site/static/images/file-arrow-up-solid.svg +1 -0
  65. df_site/static/images/find-replace.svg +1 -0
  66. df_site/static/images/font-background.svg +1 -0
  67. df_site/static/images/font-color.svg +1 -0
  68. df_site/static/images/font-family.svg +1 -0
  69. df_site/static/images/font-size.svg +1 -0
  70. df_site/static/images/heading1.svg +1 -0
  71. df_site/static/images/heading2.svg +1 -0
  72. df_site/static/images/heading3.svg +1 -0
  73. df_site/static/images/heading4.svg +1 -0
  74. df_site/static/images/heading5.svg +1 -0
  75. df_site/static/images/heading6.svg +1 -0
  76. df_site/static/images/history.svg +1 -0
  77. df_site/static/images/horizontalline.svg +1 -0
  78. df_site/static/images/html.svg +1 -0
  79. df_site/static/images/image-asset-manager.svg +1 -0
  80. df_site/static/images/image-upload.svg +1 -0
  81. df_site/static/images/image-url.svg +1 -0
  82. df_site/static/images/image.svg +1 -0
  83. df_site/static/images/importexport.svg +1 -0
  84. df_site/static/images/indent.svg +1 -0
  85. df_site/static/images/italic.svg +1 -0
  86. df_site/static/images/link.svg +1 -0
  87. df_site/static/images/liststylecircle.svg +1 -0
  88. df_site/static/images/liststyledecimal.svg +1 -0
  89. df_site/static/images/liststyledecimalleadingzero.svg +1 -0
  90. df_site/static/images/liststyledisc.svg +1 -0
  91. df_site/static/images/liststylelowerlatin.svg +1 -0
  92. df_site/static/images/liststylelowerroman.svg +1 -0
  93. df_site/static/images/liststylesquare.svg +1 -0
  94. df_site/static/images/liststyleupperlatin.svg +1 -0
  95. df_site/static/images/liststyleupperroman.svg +1 -0
  96. df_site/static/images/loupe.svg +1 -0
  97. df_site/static/images/low-vision.svg +1 -0
  98. df_site/static/images/marker.svg +1 -0
  99. df_site/static/images/media-placeholder.svg +1 -0
  100. df_site/static/images/media.svg +1 -0
  101. df_site/static/images/next-arrow.svg +1 -0
  102. df_site/static/images/numberedlist.svg +1 -0
  103. df_site/static/images/object-center.svg +1 -0
  104. df_site/static/images/object-full-width.svg +1 -0
  105. df_site/static/images/object-inline-left.svg +1 -0
  106. df_site/static/images/object-inline-right.svg +1 -0
  107. df_site/static/images/object-inline.svg +1 -0
  108. df_site/static/images/object-left.svg +1 -0
  109. df_site/static/images/object-right.svg +1 -0
  110. df_site/static/images/object-size-custom.svg +1 -0
  111. df_site/static/images/object-size-full.svg +1 -0
  112. df_site/static/images/object-size-large.svg +1 -0
  113. df_site/static/images/object-size-medium.svg +1 -0
  114. df_site/static/images/object-size-small.svg +1 -0
  115. df_site/static/images/outdent.svg +1 -0
  116. df_site/static/images/paragraph.svg +1 -0
  117. df_site/static/images/pen.svg +1 -0
  118. df_site/static/images/pencil.svg +1 -0
  119. df_site/static/images/pilcrow.svg +1 -0
  120. df_site/static/images/plus.svg +1 -0
  121. df_site/static/images/previous-arrow.svg +1 -0
  122. df_site/static/images/project-logo.svg +1 -0
  123. df_site/static/images/quote.svg +1 -0
  124. df_site/static/images/redo.svg +1 -0
  125. df_site/static/images/remove-format.svg +1 -0
  126. df_site/static/images/return-arrow.svg +1 -0
  127. df_site/static/images/select-all.svg +1 -0
  128. df_site/static/images/show-blocks.svg +1 -0
  129. df_site/static/images/source-editing.svg +1 -0
  130. df_site/static/images/specialcharacters.svg +1 -0
  131. df_site/static/images/strikethrough.svg +1 -0
  132. df_site/static/images/subscript.svg +1 -0
  133. df_site/static/images/superscript.svg +1 -0
  134. df_site/static/images/table-cell-properties.svg +1 -0
  135. df_site/static/images/table-column.svg +1 -0
  136. df_site/static/images/table-merge-cell.svg +1 -0
  137. df_site/static/images/table-properties.svg +1 -0
  138. df_site/static/images/table-row.svg +1 -0
  139. df_site/static/images/table.svg +1 -0
  140. df_site/static/images/text-alternative.svg +1 -0
  141. df_site/static/images/text.svg +1 -0
  142. df_site/static/images/three-vertical-dots.svg +1 -0
  143. df_site/static/images/todolist.svg +1 -0
  144. df_site/static/images/underline.svg +1 -0
  145. df_site/static/images/undo.svg +1 -0
  146. df_site/static/images/unlink.svg +1 -0
  147. df_site/static/js/app.js +98 -0
  148. df_site/static/js/app.js.map +1 -0
  149. df_site/static/js/base.js +161181 -0
  150. df_site/static/js/base.js.map +1 -0
  151. df_site/static/translations/af.js +1 -0
  152. df_site/static/translations/ar.js +1 -0
  153. df_site/static/translations/ast.js +1 -0
  154. df_site/static/translations/az.js +1 -0
  155. df_site/static/translations/bg.js +1 -0
  156. df_site/static/translations/bn.js +1 -0
  157. df_site/static/translations/bs.js +1 -0
  158. df_site/static/translations/ca.js +1 -0
  159. df_site/static/translations/cs.js +1 -0
  160. df_site/static/translations/da.js +1 -0
  161. df_site/static/translations/de-ch.js +1 -0
  162. df_site/static/translations/de.js +1 -0
  163. df_site/static/translations/el.js +1 -0
  164. df_site/static/translations/en-au.js +1 -0
  165. df_site/static/translations/en-gb.js +1 -0
  166. df_site/static/translations/en.js +1 -0
  167. df_site/static/translations/eo.js +1 -0
  168. df_site/static/translations/es-co.js +1 -0
  169. df_site/static/translations/es.js +1 -0
  170. df_site/static/translations/et.js +1 -0
  171. df_site/static/translations/eu.js +1 -0
  172. df_site/static/translations/fa.js +1 -0
  173. df_site/static/translations/fi.js +1 -0
  174. df_site/static/translations/gl.js +1 -0
  175. df_site/static/translations/gu.js +1 -0
  176. df_site/static/translations/he.js +1 -0
  177. df_site/static/translations/hi.js +1 -0
  178. df_site/static/translations/hr.js +1 -0
  179. df_site/static/translations/hu.js +1 -0
  180. df_site/static/translations/hy.js +1 -0
  181. df_site/static/translations/id.js +1 -0
  182. df_site/static/translations/it.js +1 -0
  183. df_site/static/translations/ja.js +1 -0
  184. df_site/static/translations/jv.js +1 -0
  185. df_site/static/translations/kk.js +1 -0
  186. df_site/static/translations/km.js +1 -0
  187. df_site/static/translations/kn.js +1 -0
  188. df_site/static/translations/ko.js +1 -0
  189. df_site/static/translations/ku.js +1 -0
  190. df_site/static/translations/lt.js +1 -0
  191. df_site/static/translations/lv.js +1 -0
  192. df_site/static/translations/ms.js +1 -0
  193. df_site/static/translations/nb.js +1 -0
  194. df_site/static/translations/ne.js +1 -0
  195. df_site/static/translations/nl.js +1 -0
  196. df_site/static/translations/no.js +1 -0
  197. df_site/static/translations/oc.js +1 -0
  198. df_site/static/translations/pl.js +1 -0
  199. df_site/static/translations/pt-br.js +1 -0
  200. df_site/static/translations/pt.js +1 -0
  201. df_site/static/translations/ro.js +1 -0
  202. df_site/static/translations/ru.js +1 -0
  203. df_site/static/translations/si.js +1 -0
  204. df_site/static/translations/sk.js +1 -0
  205. df_site/static/translations/sl.js +1 -0
  206. df_site/static/translations/sq.js +1 -0
  207. df_site/static/translations/sr-latn.js +1 -0
  208. df_site/static/translations/sr.js +1 -0
  209. df_site/static/translations/sv.js +1 -0
  210. df_site/static/translations/th.js +1 -0
  211. df_site/static/translations/ti.js +1 -0
  212. df_site/static/translations/tk.js +1 -0
  213. df_site/static/translations/tr.js +1 -0
  214. df_site/static/translations/tt.js +1 -0
  215. df_site/static/translations/ug.js +1 -0
  216. df_site/static/translations/uk.js +1 -0
  217. df_site/static/translations/ur.js +1 -0
  218. df_site/static/translations/uz.js +1 -0
  219. df_site/static/translations/vi.js +1 -0
  220. df_site/static/translations/zh-cn.js +1 -0
  221. df_site/static/translations/zh.js +1 -0
  222. df_site/static/webfonts/fa-brands-400.ttf +0 -0
  223. df_site/static/webfonts/fa-brands-400.woff2 +0 -0
  224. df_site/static/webfonts/fa-regular-400.ttf +0 -0
  225. df_site/static/webfonts/fa-regular-400.woff2 +0 -0
  226. df_site/static/webfonts/fa-solid-900.ttf +0 -0
  227. df_site/static/webfonts/fa-solid-900.woff2 +0 -0
  228. df_site/static/webfonts/fa-v4compatibility.ttf +0 -0
  229. df_site/static/webfonts/fa-v4compatibility.woff2 +0 -0
  230. df_site/templates/account/email.html +78 -0
  231. df_site/templates/account/password_change.html +28 -0
  232. df_site/templates/account/snippets/warn_no_email.html +6 -0
  233. df_site/templates/allauth/elements/alert.html +6 -0
  234. df_site/templates/allauth/elements/badge.html +4 -0
  235. df_site/templates/allauth/elements/button.html +14 -0
  236. df_site/templates/allauth/elements/button_group.html +5 -0
  237. df_site/templates/allauth/elements/field.html +72 -0
  238. df_site/templates/allauth/elements/fields.html +3 -0
  239. df_site/templates/allauth/elements/form.html +10 -0
  240. df_site/templates/allauth/elements/h1.html +1 -0
  241. df_site/templates/allauth/elements/h2.html +1 -0
  242. df_site/templates/allauth/elements/img.html +4 -0
  243. df_site/templates/allauth/elements/p.html +1 -0
  244. df_site/templates/allauth/elements/panel.html +14 -0
  245. df_site/templates/allauth/elements/provider.html +6 -0
  246. df_site/templates/allauth/elements/provider_list.html +5 -0
  247. df_site/templates/allauth/elements/table.html +5 -0
  248. df_site/templates/allauth/layouts/base.html +14 -0
  249. df_site/templates/allauth/layouts/entrance.html +20 -0
  250. df_site/templates/allauth/layouts/manage.html +1 -0
  251. df_site/templates/cookie_consent/_cookie_group.html +64 -0
  252. df_site/templates/cookie_consent/cookiegroup_list.html +23 -0
  253. df_site/templates/df_components/base.html +0 -0
  254. df_site/templates/df_components/detail.html +12 -0
  255. df_site/templates/df_components/detail_fieldset.html +46 -0
  256. df_site/templates/df_components/list.html +42 -0
  257. df_site/templates/df_components/list_filter.html +13 -0
  258. df_site/templates/df_components/list_filters.html +36 -0
  259. df_site/templates/df_components/list_hierarchy.html +25 -0
  260. df_site/templates/df_components/list_pagination.html +39 -0
  261. df_site/templates/df_components/list_search_form.html +38 -0
  262. df_site/templates/df_components/list_table.html +35 -0
  263. df_site/templates/df_site/app.html +1 -0
  264. df_site/templates/df_site/base.html +221 -0
  265. df_site/templates/df_site/detail.html +8 -0
  266. df_site/templates/df_site/humans.txt +11 -0
  267. df_site/templates/df_site/manage_base.html +51 -0
  268. df_site/templates/df_site/popup_app.html +1 -0
  269. df_site/templates/df_site/popup_base.html +29 -0
  270. df_site/templates/df_site/security.txt +5 -0
  271. df_site/templates/django_bootstrap5/breadcrumb.html +17 -0
  272. df_site/templates/django_bootstrap5/pagination.html +40 -0
  273. df_site/templates/django_ckeditor_5/widget.html +13 -0
  274. df_site/templates/favicon/browserconfig.xml +9 -0
  275. df_site/templates/mfa/index.html +115 -0
  276. df_site/templates/mfa/recovery_codes/index.html +33 -0
  277. df_site/templates/mfa/webauthn/authenticator_list.html +74 -0
  278. df_site/templates/pipeline/css.html +1 -0
  279. df_site/templates/pipeline/js.html +1 -0
  280. df_site/templates/postman/archives.html +8 -0
  281. df_site/templates/postman/base.html +20 -0
  282. df_site/templates/postman/base_folder.html +71 -0
  283. df_site/templates/postman/base_write.html +26 -0
  284. df_site/templates/postman/inbox.html +7 -0
  285. df_site/templates/postman/inc_subject_ex.html +21 -0
  286. df_site/templates/postman/trash.html +12 -0
  287. df_site/templates/postman/view.html +64 -0
  288. df_site/templates/users/settings.html +26 -0
  289. df_site/templates/usersessions/usersession_list.html +70 -0
  290. df_site/templatetags/__init__.py +1 -0
  291. df_site/templatetags/df_site.py +241 -0
  292. df_site/templatetags/images.py +515 -0
  293. df_site/templatetags/pipeline_sri.py +97 -0
  294. df_site/testing/__init__.py +1 -0
  295. df_site/testing/multiple_views.py +369 -0
  296. df_site/testing/requests.py +299 -0
  297. df_site/urls.py +41 -0
  298. df_site/user_settings.py +69 -0
  299. df_site/users/__init__.py +1 -0
  300. df_site/users/forms.py +35 -0
  301. df_site/users/notifications.py +14 -0
  302. df_site/users/urls.py +17 -0
  303. df_site/users/views.py +75 -0
  304. df_site/views.py +122 -0
  305. df_site-0.1.0.dist-info/LICENSE +519 -0
  306. df_site-0.1.0.dist-info/METADATA +217 -0
  307. df_site-0.1.0.dist-info/RECORD +309 -0
  308. df_site-0.1.0.dist-info/WHEEL +4 -0
  309. df_site-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,515 @@
1
+ """Template tags for image generation in cache."""
2
+
3
+ import datetime
4
+ import hashlib
5
+ import logging
6
+ import math
7
+ import os
8
+ import re
9
+ import time
10
+ from enum import IntEnum
11
+ from html import escape
12
+ from typing import Callable, Dict, Iterable, List, Optional, Set, Union
13
+
14
+ from django import template
15
+ from django.conf import settings
16
+ from django.core.cache import BaseCache, caches
17
+ from django.core.files.storage import Storage, storages
18
+ from django.core.files.storage.filesystem import FileSystemStorage
19
+ from django.db.models.fields.files import FieldFile
20
+ from django.http import Http404, HttpRequest
21
+ from django.urls import reverse
22
+ from django.utils.safestring import mark_safe
23
+ from PIL import Image
24
+
25
+ register = template.Library()
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class AspectPolicy(IntEnum):
30
+ """Aspect aspect_policy of resized images."""
31
+
32
+ FORCE_HEIGHT_CROP = 0 # height will be respected, width will be cropped or too small
33
+ FORCE_WIDTH_CROP = 1 # width will be respected, height will be cropped or too small
34
+ FORCE_MIN_CROP = 2 # the smallest dimension will be respected, the other will be cropped
35
+ FORCE_MAX_FIT = 3 # the largest dimension will be respected, the other will be too small
36
+ FORCE_MAX_TRANSPARENT = 4 # the largest dimension will be respected, the other extra will be transparent
37
+
38
+ @classmethod
39
+ def get_policy(cls, int_policy: int) -> "AspectPolicy":
40
+ """Return the AspectPolicy corresponding to the given integer."""
41
+ for policy in AspectPolicy:
42
+ if policy.value == int_policy:
43
+ return policy
44
+ raise ValueError(f"Invalid aspect policy: {int_policy}")
45
+
46
+
47
+ @register.simple_tag(takes_context=True)
48
+ def media_image(
49
+ context,
50
+ path: Union[str, FieldFile, callable],
51
+ widths: Optional[Union[str, List[int]]] = None,
52
+ write: bool = True,
53
+ aspect_policy: AspectPolicy = AspectPolicy.FORCE_MAX_TRANSPARENT,
54
+ fmt: str = "webp",
55
+ height: Optional[int] = None,
56
+ width: Optional[int] = None,
57
+ alt: str = "",
58
+ crossorigin: str = "anonymous",
59
+ loading: str = "lazy",
60
+ referrerpolicy: str = "same-origin",
61
+ **kwargs,
62
+ ):
63
+ """Generate a <img> tag with associated attributes for a media file.
64
+
65
+ :param context: the template context
66
+ :param path: the path to the image
67
+ :param storage: where thumbnail images will be stored
68
+ :param widths: list of widths to create, given in the src-set attribute
69
+ :param write: whether to actually create the thumbnail images
70
+ :param aspect_policy: the policy to apply about the aspect ratio
71
+ :param fmt: the format of the thumbnail images
72
+ :param height: the targeted height of the image
73
+ :param width: the targeted width of the image
74
+ :param alt: the alt attribute, passed as-is to the <img> tag
75
+ :param crossorigin: the crossorigin attribute, passed as-is to the <img> tag
76
+ :param loading: the loading attribute, passed as-is to the <img> tag
77
+ :param referrerpolicy: the referrerpolicy attribute, passed as-is to the <img> tag
78
+ :param kwargs: additional attributes that are passed as-is to the <img> tag
79
+ """
80
+ if isinstance(widths, str):
81
+ widths = [int(x.strip()) for x in widths.split(",")]
82
+ if callable(path):
83
+ path = path()
84
+ elif isinstance(path, FieldFile):
85
+ path: str = path.name
86
+ img = CachedImage(
87
+ CachedImage.sources["media"]["src_storage"],
88
+ CachedImage.sources["media"]["dst_storage"],
89
+ CachedImage.sources["media"]["cache"],
90
+ path,
91
+ CachedImage.sources["media"]["prefix"],
92
+ height=height,
93
+ width=width,
94
+ widths=widths,
95
+ aspect_policy=aspect_policy,
96
+ fmt=fmt,
97
+ write_thumbnails=write,
98
+ )
99
+ img.process()
100
+ result = img.as_html_tag(alt=alt, crossorigin=crossorigin, loading=loading, referrerpolicy=referrerpolicy, **kwargs)
101
+ if result == "":
102
+ img.log_error(context.get("request"))
103
+ return result
104
+
105
+
106
+ @register.simple_tag(takes_context=True)
107
+ def static_image(
108
+ context,
109
+ path: Union[callable, str],
110
+ widths: Optional[Union[str, List[int]]] = None,
111
+ write: bool = True,
112
+ aspect_policy: AspectPolicy = AspectPolicy.FORCE_MAX_TRANSPARENT,
113
+ fmt: str = "webp",
114
+ height: Optional[int] = None,
115
+ width: Optional[int] = None,
116
+ alt: str = "",
117
+ crossorigin: str = "anonymous",
118
+ loading: str = "lazy",
119
+ referrerpolicy: str = "same-origin",
120
+ **kwargs,
121
+ ):
122
+ """Generate a <img> tag with associated attributes for a static file."""
123
+ if callable(path):
124
+ path = path()
125
+ if isinstance(widths, str):
126
+ widths = [int(x.strip()) for x in widths.split(",")]
127
+ img = CachedImage(
128
+ CachedImage.sources["static"]["src_storage"],
129
+ CachedImage.sources["static"]["dst_storage"],
130
+ CachedImage.sources["static"]["cache"],
131
+ path,
132
+ CachedImage.sources["static"]["prefix"],
133
+ height=height,
134
+ width=width,
135
+ widths=widths,
136
+ aspect_policy=aspect_policy,
137
+ fmt=fmt,
138
+ write_thumbnails=write,
139
+ )
140
+ img.process()
141
+ result = img.as_html_tag(alt=alt, crossorigin=crossorigin, loading=loading, referrerpolicy=referrerpolicy, **kwargs)
142
+ if result == "":
143
+ img.log_error(context.get("request"))
144
+ return result
145
+
146
+
147
+ IMAGE_THUMBNAILS = getattr(
148
+ settings,
149
+ "DF_IMAGE_THUMBNAILS",
150
+ {
151
+ "media": {
152
+ "src_storage": "default",
153
+ "dst_storage": "staticfiles",
154
+ "cache": "default",
155
+ "prefix": "T",
156
+ "reversible": True,
157
+ },
158
+ "static": {
159
+ "src_storage": "staticfiles",
160
+ "dst_storage": "staticfiles",
161
+ "cache": "default",
162
+ "prefix": "S",
163
+ "reversible": True,
164
+ },
165
+ },
166
+ )
167
+
168
+
169
+ class CachedImage:
170
+ """Handle original images to create required thumbnails."""
171
+
172
+ src_fast_check_expiration = 86400
173
+ src_slow_check_expiration = 86400 * 30
174
+ src_cache_expiration = 86400 * 365
175
+ default_cache_data = {
176
+ "width": None,
177
+ "height": None,
178
+ "filesize": None,
179
+ "sha256": None,
180
+ "next_fast_check": None,
181
+ "next_slow_check": None,
182
+ "mtime": None,
183
+ }
184
+ default_widths = [64, 160, 320, 640, 1280, 1920, 3840]
185
+ formats = ["webp", "jpg", "jpeg", "png"]
186
+ target_path_re = re.compile(
187
+ r"(?P<cache_prefix>[^/]+)/"
188
+ r"(?P<width>\d+)x(?P<height>\d+)_(?P<aspect_policy>[^/]+)/"
189
+ r"(?P<src_path>.+)\.(?P<fmt>[^.]+)$",
190
+ )
191
+ sources: Dict[str, Dict[str, str]] = IMAGE_THUMBNAILS
192
+
193
+ def __init__(
194
+ self,
195
+ src_storage: str,
196
+ dst_storage: str,
197
+ cache: str,
198
+ src_path: str,
199
+ cache_prefix: str,
200
+ url: Callable[[str], str] = lambda p: reverse("thumbnails", kwargs={"path": p}),
201
+ height: Optional[int] = None,
202
+ width: Optional[int] = None,
203
+ widths: Optional[List[int]] = None,
204
+ aspect_policy: AspectPolicy = AspectPolicy.FORCE_MAX_TRANSPARENT,
205
+ fmt: str = "webp",
206
+ write_thumbnails: bool = True,
207
+ ):
208
+ """Initialize the CachedImage."""
209
+ dst_name = hashlib.sha256(src_path.encode()).hexdigest()
210
+ cache_key = f"{cache_prefix}{dst_name}"
211
+
212
+ self.src_storage_obj: Storage = storages[src_storage]
213
+ self.dst_storage_obj: Storage = storages[dst_storage]
214
+ self.cache_obj: BaseCache = caches[cache]
215
+ self.src_path = src_path
216
+ self.cache_key = cache_key
217
+ self.cache_prefix = cache_prefix
218
+ self.url: Callable[[str], str] = url # function to generate the URL of the thumbnails from the thumbnail path
219
+ self.target_height = height # target height of the image
220
+ self.target_width = width # target width of the image
221
+ self.widths = widths # list of widths to create if possible
222
+ self.aspect_policy = aspect_policy # aspect ratio policy when target width and height are given
223
+ self.fmt = fmt # format ('webp', 'jpg', 'jpeg', 'png') of the thumbnails
224
+ self.cache_data: Dict[str, Union[None, str, int, Dict]] = self.default_cache_data.copy()
225
+ self.cache_changed: bool = False # whether the cache has been changed and must be saved
226
+ self.write_thumbnail: bool = write_thumbnails # whether the thumbnails must be actually created
227
+ self.paths_srcset: Dict[str, str] = {} # the sizes and paths for the srcset attribute
228
+ self.path_src: str = "" # the path used in the src attribute
229
+ self.created_sizes: Set[int] = set() # the list of actually created sizes
230
+
231
+ def process(self):
232
+ """Process the image, updating the cache if necessary."""
233
+ cache_data = self.cache_obj.get(self.cache_key, {})
234
+ self.cache_data.update(cache_data)
235
+ try:
236
+ self.process_thumbnails()
237
+ except FileNotFoundError:
238
+ logger.error("Image %s is missing.", self.src_path)
239
+ timestamp = int(time.time())
240
+ self.cache_data["next_slow_check"] = timestamp + self.src_slow_check_expiration
241
+ self.cache_data["next_fast_check"] = timestamp + self.src_fast_check_expiration
242
+ self.cache_changed = True
243
+ except Exception as e:
244
+ logger.error("Error while processing image %s [%s]", self.src_path, e)
245
+ timestamp = int(time.time())
246
+ self.cache_data["next_slow_check"] = timestamp + self.src_slow_check_expiration
247
+ self.cache_data["next_fast_check"] = timestamp + self.src_fast_check_expiration
248
+ self.cache_changed = True
249
+ if self.cache_changed:
250
+ self.cache_obj.set(self.cache_key, self.cache_data, self.src_cache_expiration)
251
+
252
+ def process_thumbnails(self):
253
+ """Update image metadata if required and create missing thumbnails."""
254
+ old_cache_data = self.cache_data.copy()
255
+ missing_data = any(self.cache_data[x] is None for x in self.default_cache_data)
256
+ timestamp = int(time.time())
257
+ if (
258
+ missing_data
259
+ or self.cache_data.get("next_slow_check") < timestamp
260
+ or (self.cache_data.get("next_fast_check") < timestamp and self.get_mtime() > self.cache_data["mtime"])
261
+ ):
262
+ # we must recompute the cached data since either the data is missing
263
+ # or the last slow check is too old
264
+ # or the new fast check shows that the file has been modified
265
+ self.update_image_data()
266
+ self.cache_data["next_slow_check"] = timestamp + self.src_slow_check_expiration
267
+ self.cache_data["next_fast_check"] = timestamp + self.src_fast_check_expiration
268
+ self.cache_data["mtime"] = self.get_mtime()
269
+ self.cache_changed = True
270
+ must_recreate = any(
271
+ self.cache_data[x] != old_cache_data[x] for x in ("width", "height", "filesize", "sha256", "mtime")
272
+ )
273
+ widths_to_create: Set[int] = set()
274
+ for width in self.get_required_widths():
275
+ if must_recreate or self.get_cache_key(width) not in self.cache_data:
276
+ widths_to_create.add(width)
277
+ self.cache_changed = True
278
+ base_width = self.get_base_width()
279
+ base_cache_key = self.get_cache_key(base_width)
280
+ if must_recreate or base_cache_key not in self.cache_data:
281
+ widths_to_create.add(base_width)
282
+ self.cache_changed = True
283
+ if widths_to_create:
284
+ self.create_thumbnails(widths_to_create, force=must_recreate)
285
+ for width in self.get_required_widths():
286
+ self.paths_srcset[f"{width}w"] = self.cache_data[self.get_cache_key(width)]
287
+ self.path_src = self.cache_data[base_cache_key]
288
+
289
+ def get_mtime(self) -> int:
290
+ """Return the modification time of the source image, when available."""
291
+ try:
292
+ dt: datetime.datetime = self.src_storage_obj.get_modified_time(self.src_path)
293
+ return int(dt.timestamp())
294
+ except NotImplementedError:
295
+ return 0
296
+
297
+ def get_base_width(self) -> int:
298
+ """Return the base width of the displayed image."""
299
+ if self.target_width is not None:
300
+ return self.target_width
301
+ elif self.target_height is not None:
302
+ return math.ceil(self.target_height * self.cache_data["width"] / self.cache_data["height"])
303
+ return self.cache_data["width"]
304
+
305
+ def get_required_widths(self) -> Iterable[int]:
306
+ """Return the list of required widths for the image."""
307
+ if self.widths is None:
308
+ self.widths = self.default_widths
309
+ return [x for x in self.widths if x <= self.cache_data["width"]]
310
+
311
+ def update_image_data(self):
312
+ """Update the cache data for the image, allowing to check if thumbnails must be computed."""
313
+ with self.src_storage_obj.open(self.src_path, "rb") as fd:
314
+ img = Image.open(fd)
315
+ self.cache_data["width"], self.cache_data["height"] = img.size
316
+ sha256 = hashlib.sha256()
317
+ fd.seek(0)
318
+ for data in iter(lambda: fd.read(4096), b""):
319
+ sha256.update(data)
320
+ self.cache_data["filesize"] = fd.tell()
321
+ self.cache_data["sha256"] = hashlib.sha256().hexdigest()
322
+ img.close()
323
+
324
+ def create_thumbnails(self, widths: Iterable[int], force: bool = False, write: bool = True):
325
+ """Create the thumbnails for the all given widths."""
326
+ if write:
327
+ with self.src_storage_obj.open(self.src_path, "rb") as fd:
328
+ img = Image.open(fd)
329
+ for width in widths:
330
+ self.create_thumbnail(img, width, force=force, write=write)
331
+ img.close()
332
+ else:
333
+ for width in widths:
334
+ self.create_thumbnail(None, width, force=force, write=write)
335
+
336
+ def get_target_path(self, width: int, height: int) -> str:
337
+ """Return the target path for the given width."""
338
+ return f"{self.cache_prefix}/{width}x{height}_{self.aspect_policy}/{self.src_path}.{self.fmt}"
339
+
340
+ @classmethod
341
+ def from_target_path(cls, dst_path: str):
342
+ """Build a CachedImage from the URL path, if a thumbnail is missing."""
343
+ if not (matcher := cls.target_path_re.match(dst_path)):
344
+ raise Http404("Invalid path")
345
+ cache_prefix = matcher.group("cache_prefix")
346
+ for source in cls.sources.values():
347
+ if cache_prefix == source["prefix"]:
348
+ break
349
+ else:
350
+ raise Http404("Unkown image path.")
351
+ src_storage = source["src_storage"]
352
+ dst_storage = source["dst_storage"]
353
+ cache = source["cache"]
354
+ if not source.get("reversible"):
355
+ raise Http404("Invalid image path.")
356
+ width = int(matcher.group("width"))
357
+ height = int(matcher.group("height"))
358
+ if not (0 < height < 10000 and 0 < width < 10000):
359
+ raise Http404("Invalid image size.")
360
+ try:
361
+ aspect_policy = AspectPolicy.get_policy(int(matcher.group("aspect_policy")))
362
+ except ValueError:
363
+ raise Http404("Invalid aspect policy.")
364
+ src_path = matcher.group("src_path")
365
+ src_path = os.path.normpath(src_path)
366
+ if src_path.startswith("../") or src_path in {".", ".."}:
367
+ raise Http404("Invalid source path.")
368
+ fmt = matcher.group("fmt")
369
+ if fmt not in cls.formats:
370
+ raise Http404("Invalid format.")
371
+ return cls(
372
+ src_storage,
373
+ dst_storage,
374
+ cache,
375
+ src_path,
376
+ cache_prefix,
377
+ height=height,
378
+ width=width,
379
+ widths=[],
380
+ aspect_policy=aspect_policy,
381
+ fmt=fmt,
382
+ )
383
+
384
+ @classmethod
385
+ def get_cache_key(cls, width: int) -> str:
386
+ """Return the cache key for the given width."""
387
+ return f"{width}w"
388
+
389
+ def create_thumbnail(
390
+ self,
391
+ img_src: Optional[Image],
392
+ required_width: int,
393
+ force: bool = False,
394
+ write: bool = True,
395
+ ):
396
+ """Create a thumbnail for the given width and stores the new name in the cache."""
397
+ src_width = self.cache_data["width"]
398
+ src_height = self.cache_data["height"]
399
+ width_cache_key = self.get_cache_key(required_width)
400
+ aspect_ratio_height = math.ceil(required_width * src_height / src_width)
401
+ if self.target_width and self.target_height:
402
+ required_height = math.ceil(required_width * self.target_height / self.target_width)
403
+ else:
404
+ required_height = aspect_ratio_height
405
+ dst_path = self.get_target_path(required_width, required_height)
406
+ if width_cache_key not in self.cache_data:
407
+ self.cache_data[width_cache_key] = dst_path
408
+ self.cache_changed = True
409
+ if not write:
410
+ return
411
+ exists = self.dst_storage_obj.exists(dst_path)
412
+ if not force and exists:
413
+ return
414
+ logger.info("Creating thumbnail %s", dst_path)
415
+ if aspect_ratio_height == required_height:
416
+ # perfect fit: the target aspect ratio is the same as the source aspect ratio
417
+ # always true when only one dimension is specified
418
+ img = img_src.resize((required_width, required_height))
419
+ elif aspect_ratio_height < required_height:
420
+ # the image is not high enough
421
+ if (
422
+ self.aspect_policy == AspectPolicy.FORCE_HEIGHT_CROP
423
+ or self.aspect_policy == AspectPolicy.FORCE_MIN_CROP
424
+ ):
425
+ tmp_width = math.ceil(required_height * src_width / src_height)
426
+ img = img_src.resize((tmp_width, required_height)) # larger than expected
427
+ half = (tmp_width - required_width) // 2
428
+ img = img.crop((half, 0, required_width + half, required_height)) # we crop
429
+ elif (
430
+ self.aspect_policy == AspectPolicy.FORCE_WIDTH_CROP or self.aspect_policy == AspectPolicy.FORCE_MAX_FIT
431
+ ):
432
+ img = img_src.resize((required_width, aspect_ratio_height)) # shorter than expected
433
+ else: # if self.aspect_policy == AspectPolicy.FORCE_MAX_TRANSPARENT:
434
+ r_img: Image = img_src.resize((required_width, aspect_ratio_height)) # shorter than expected
435
+ img = Image.new("RGBA", (required_width, required_height), (255, 0, 0, 0))
436
+ img.paste(r_img, (0, (required_height - aspect_ratio_height) // 2))
437
+ r_img.close()
438
+ else:
439
+ # the image is not wide enough
440
+ aspect_ratio_width = math.ceil(required_height * src_width / src_height)
441
+ if self.aspect_policy == AspectPolicy.FORCE_HEIGHT_CROP or self.aspect_policy == AspectPolicy.FORCE_MAX_FIT:
442
+ img = img_src.resize((aspect_ratio_width, required_height)) # thinner than expected
443
+ elif (
444
+ self.aspect_policy == AspectPolicy.FORCE_WIDTH_CROP or self.aspect_policy == AspectPolicy.FORCE_MIN_CROP
445
+ ):
446
+ tmp_height = math.ceil(required_width * src_height / src_width)
447
+ img = img_src.resize((required_width, tmp_height)) # taller than expected
448
+ half = (tmp_height - required_height) // 2
449
+ img = img.crop((0, half, required_width, required_height + half)) # we crop
450
+ else: # if self.aspect_policy == AspectPolicy.FORCE_MAX_TRANSPARENT:
451
+ r_img: Image = img_src.resize((aspect_ratio_width, required_height))
452
+ img = Image.new("RGBA", (required_width, required_height), (255, 0, 0, 0))
453
+ img.paste(r_img, ((required_width - aspect_ratio_width) // 2, 0))
454
+ r_img.close()
455
+ self.prepare_storage(dst_path)
456
+ if exists:
457
+ self.dst_storage_obj.delete(dst_path)
458
+ with self.dst_storage_obj.open(dst_path, "wb") as fd:
459
+ img.save(fd, format=self.fmt.upper())
460
+ self.created_sizes.add(required_width)
461
+
462
+ def prepare_storage(self, path):
463
+ """Prepare the storage for the given path, creating directories if required."""
464
+ if isinstance(self.dst_storage_obj, FileSystemStorage):
465
+ full_path = self.dst_storage_obj.path(path)
466
+ os.makedirs(os.path.dirname(full_path), exist_ok=True)
467
+
468
+ @property
469
+ def attr_src(self) -> str:
470
+ """Return the src attribute for the <img> tag."""
471
+ return self.url(self.path_src)
472
+
473
+ @property
474
+ def attr_srcset(self) -> str:
475
+ """Return the srcset attribute for the <img> tag."""
476
+ return ",".join(f"{self.url(path)} {size}" for size, path in self.paths_srcset.items())
477
+
478
+ def as_html_tag(
479
+ self,
480
+ alt: str = "",
481
+ crossorigin: str = "anonymous",
482
+ loading: str = "lazy",
483
+ referrerpolicy: str = "same-origin",
484
+ **kwargs,
485
+ ):
486
+ """Return a URL tag with all required attributes."""
487
+ if not self.path_src:
488
+ return ""
489
+ attrs = {"src": escape(self.attr_src)}
490
+ if self.paths_srcset:
491
+ attrs["srcset"] = escape(self.attr_srcset)
492
+ attrs["alt"] = escape(alt)
493
+ attrs["crossorigin"] = escape(crossorigin)
494
+ attrs["loading"] = escape(loading)
495
+ attrs["referrerpolicy"] = escape(referrerpolicy)
496
+ if self.target_width:
497
+ attrs["width"] = str(self.target_width)
498
+ if self.target_height:
499
+ attrs["height"] = str(self.target_height)
500
+ for k, v in kwargs.items():
501
+ k = k.replace("_", "-")
502
+ if k == "class-":
503
+ k = "class"
504
+ attrs[escape(k)] = escape(v)
505
+ attrs = " ".join(f'{k}="{v}"' for k, v in attrs.items())
506
+ return mark_safe(f"<img {attrs}/>") # noqa S308
507
+
508
+ def log_error(self, request: Optional[HttpRequest]):
509
+ """Log errors."""
510
+ if request is None:
511
+ logger.error("Image %s not found", self.src_path)
512
+ else:
513
+ request_path = request.path
514
+ referer = request.META.get("HTTP_REFERER", "")
515
+ logger.error("Image %s not found (referer: %s, URI: %s)", self.src_path, referer, request_path)
@@ -0,0 +1,97 @@
1
+ """Custom template tags for generating Subresource Integrity hashes for Pipeline assets."""
2
+
3
+ import base64
4
+ import hashlib
5
+ from functools import lru_cache
6
+
7
+ from django import template
8
+ from django.conf import settings
9
+ from django.contrib.staticfiles.storage import staticfiles_storage
10
+ from django.template.loader import render_to_string
11
+ from django.utils.safestring import mark_safe
12
+ from pipeline.templatetags.pipeline import JavascriptNode, StylesheetNode
13
+ from pipeline.utils import guess_type
14
+
15
+ register = template.Library()
16
+
17
+
18
+ def get_sri(path, method=None):
19
+ """Generate a Subresource Integrity hash for the given file."""
20
+ if method in {"sha256", "sha384", "sha512"} and staticfiles_storage.exists(path):
21
+ with staticfiles_storage.open(path) as fd:
22
+ h = getattr(hashlib, method)()
23
+ for data in iter(lambda: fd.read(16384), b""):
24
+ h.update(data)
25
+ hashed = base64.b64encode(h.digest()).decode()
26
+ return f"{method}-{hashed}"
27
+ return None
28
+
29
+
30
+ if not settings.DEBUG:
31
+ get_sri = lru_cache(maxsize=1024)(get_sri)
32
+
33
+
34
+ class SRIJavascriptNode(JavascriptNode):
35
+ """Render a <script> tag with a SRI hash for the given group."""
36
+
37
+ def render_js(self, package, path):
38
+ """Render the JS tag with SRI hash."""
39
+ template_name = package.template_name or "pipeline/js.html"
40
+ context = package.extra_context
41
+ url = mark_safe(staticfiles_storage.url(path)) # noqa
42
+ context.update(
43
+ {
44
+ "type": guess_type(path, "text/javascript"),
45
+ "url": url,
46
+ "crossorigin": "anonymous",
47
+ "integrity": get_sri(path, method=package.config.get("integrity")),
48
+ }
49
+ )
50
+ return render_to_string(template_name, context)
51
+
52
+
53
+ # noinspection PyUnusedLocal
54
+ @register.tag
55
+ def sri_javascript(parser, token):
56
+ """Generate a <script> tag with a SRI hash for the given group."""
57
+ try:
58
+ tag_name, name = token.split_contents()
59
+ except ValueError:
60
+ tag_name = token.split_contents()[0]
61
+ raise template.TemplateSyntaxError(
62
+ f"{tag_name!r} requires exactly one argument: the name of a group in the PIPELINE.JAVASCRIPT setting"
63
+ )
64
+ return SRIJavascriptNode(name)
65
+
66
+
67
+ class SRIStylesheetNode(StylesheetNode):
68
+ """Render a <link> tag with a SRI hash for the given group."""
69
+
70
+ def render_css(self, package, path):
71
+ """Render the CSS tag with SRI hash."""
72
+ template_name = package.template_name or "pipeline/css.html"
73
+ context = package.extra_context
74
+ url = mark_safe(staticfiles_storage.url(path)) # noqa
75
+ context.update(
76
+ {
77
+ "type": guess_type(path, "text/css"),
78
+ "url": url,
79
+ "crossorigin": "anonymous",
80
+ "integrity": get_sri(path, method=package.config.get("integrity")),
81
+ }
82
+ )
83
+ return render_to_string(template_name, context)
84
+
85
+
86
+ # noinspection PyUnusedLocal
87
+ @register.tag
88
+ def sri_stylesheet(parser, token):
89
+ """Generate a <link> tag with a SRI hash for the given group."""
90
+ try:
91
+ tag_name, name = token.split_contents()
92
+ except ValueError:
93
+ tag_name = token.split_contents()[0]
94
+ raise template.TemplateSyntaxError(
95
+ f"{tag_name!r} requires exactly one argument: the name of a group in the PIPELINE.STYLESHEET setting"
96
+ )
97
+ return SRIStylesheetNode(name)
@@ -0,0 +1 @@
1
+ """Testing utilities for the df_site package."""