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.
- df_site/__init__.py +1 -0
- df_site/__main__.py +37 -0
- df_site/admin.py +130 -0
- df_site/apps.py +57 -0
- df_site/components/__init__.py +1 -0
- df_site/components/base.py +82 -0
- df_site/components/detail.py +191 -0
- df_site/components/list.py +446 -0
- df_site/components/list_filters.py +74 -0
- df_site/components/registry.py +55 -0
- df_site/constants.py +71 -0
- df_site/context_processors.py +61 -0
- df_site/defaults.py +319 -0
- df_site/dynamic_settings.py +37 -0
- df_site/form_fields.py +138 -0
- df_site/management/__init__.py +1 -0
- df_site/management/commands/__init__.py +1 -0
- df_site/management/commands/add_image.py +104 -0
- df_site/management/commands/generate_favicon.py +47 -0
- df_site/middleware.py +20 -0
- df_site/migrations/0001_initial.py +220 -0
- df_site/migrations/0002_alter_alertribbon_message_alter_alertribbon_summary.py +23 -0
- df_site/migrations/__init__.py +0 -0
- df_site/model_fields.py +35 -0
- df_site/models.py +130 -0
- df_site/postman/__init__.py +1 -0
- df_site/postman/forms.py +38 -0
- df_site/postman/urls.py +75 -0
- df_site/postman/views.py +65 -0
- df_site/static/css/app.css +0 -0
- df_site/static/css/base.css +22208 -0
- df_site/static/css/ckeditor5.css +422 -0
- df_site/static/favicon/android-chrome-192x192.png +0 -0
- df_site/static/favicon/android-chrome-512x512.png +0 -0
- df_site/static/favicon/apple-touch-icon.png +0 -0
- df_site/static/favicon/favicon-16x16.png +0 -0
- df_site/static/favicon/favicon-32x32.png +0 -0
- df_site/static/favicon/favicon.ico +0 -0
- df_site/static/favicon/mstile-150x150.png +0 -0
- df_site/static/favicon/safari-pinned-tab.svg +46 -0
- df_site/static/images/accessibility.svg +1 -0
- df_site/static/images/align-bottom.svg +1 -0
- df_site/static/images/align-center.svg +1 -0
- df_site/static/images/align-justify.svg +1 -0
- df_site/static/images/align-left.svg +1 -0
- df_site/static/images/align-middle.svg +1 -0
- df_site/static/images/align-right.svg +1 -0
- df_site/static/images/align-top.svg +1 -0
- df_site/static/images/bold.svg +1 -0
- df_site/static/images/browse-files.svg +1 -0
- df_site/static/images/bulletedlist.svg +1 -0
- df_site/static/images/cancel.svg +1 -0
- df_site/static/images/caption.svg +1 -0
- df_site/static/images/check.svg +1 -0
- df_site/static/images/code.svg +1 -0
- df_site/static/images/codeblock.svg +1 -0
- df_site/static/images/cog.svg +1 -0
- df_site/static/images/color-palette.svg +1 -0
- df_site/static/images/color-tile-check.svg +1 -0
- df_site/static/images/drag-handle.svg +1 -0
- df_site/static/images/drag-indicator.svg +1 -0
- df_site/static/images/dropdown-arrow.svg +1 -0
- df_site/static/images/eraser.svg +1 -0
- df_site/static/images/file-arrow-up-solid.svg +1 -0
- df_site/static/images/find-replace.svg +1 -0
- df_site/static/images/font-background.svg +1 -0
- df_site/static/images/font-color.svg +1 -0
- df_site/static/images/font-family.svg +1 -0
- df_site/static/images/font-size.svg +1 -0
- df_site/static/images/heading1.svg +1 -0
- df_site/static/images/heading2.svg +1 -0
- df_site/static/images/heading3.svg +1 -0
- df_site/static/images/heading4.svg +1 -0
- df_site/static/images/heading5.svg +1 -0
- df_site/static/images/heading6.svg +1 -0
- df_site/static/images/history.svg +1 -0
- df_site/static/images/horizontalline.svg +1 -0
- df_site/static/images/html.svg +1 -0
- df_site/static/images/image-asset-manager.svg +1 -0
- df_site/static/images/image-upload.svg +1 -0
- df_site/static/images/image-url.svg +1 -0
- df_site/static/images/image.svg +1 -0
- df_site/static/images/importexport.svg +1 -0
- df_site/static/images/indent.svg +1 -0
- df_site/static/images/italic.svg +1 -0
- df_site/static/images/link.svg +1 -0
- df_site/static/images/liststylecircle.svg +1 -0
- df_site/static/images/liststyledecimal.svg +1 -0
- df_site/static/images/liststyledecimalleadingzero.svg +1 -0
- df_site/static/images/liststyledisc.svg +1 -0
- df_site/static/images/liststylelowerlatin.svg +1 -0
- df_site/static/images/liststylelowerroman.svg +1 -0
- df_site/static/images/liststylesquare.svg +1 -0
- df_site/static/images/liststyleupperlatin.svg +1 -0
- df_site/static/images/liststyleupperroman.svg +1 -0
- df_site/static/images/loupe.svg +1 -0
- df_site/static/images/low-vision.svg +1 -0
- df_site/static/images/marker.svg +1 -0
- df_site/static/images/media-placeholder.svg +1 -0
- df_site/static/images/media.svg +1 -0
- df_site/static/images/next-arrow.svg +1 -0
- df_site/static/images/numberedlist.svg +1 -0
- df_site/static/images/object-center.svg +1 -0
- df_site/static/images/object-full-width.svg +1 -0
- df_site/static/images/object-inline-left.svg +1 -0
- df_site/static/images/object-inline-right.svg +1 -0
- df_site/static/images/object-inline.svg +1 -0
- df_site/static/images/object-left.svg +1 -0
- df_site/static/images/object-right.svg +1 -0
- df_site/static/images/object-size-custom.svg +1 -0
- df_site/static/images/object-size-full.svg +1 -0
- df_site/static/images/object-size-large.svg +1 -0
- df_site/static/images/object-size-medium.svg +1 -0
- df_site/static/images/object-size-small.svg +1 -0
- df_site/static/images/outdent.svg +1 -0
- df_site/static/images/paragraph.svg +1 -0
- df_site/static/images/pen.svg +1 -0
- df_site/static/images/pencil.svg +1 -0
- df_site/static/images/pilcrow.svg +1 -0
- df_site/static/images/plus.svg +1 -0
- df_site/static/images/previous-arrow.svg +1 -0
- df_site/static/images/project-logo.svg +1 -0
- df_site/static/images/quote.svg +1 -0
- df_site/static/images/redo.svg +1 -0
- df_site/static/images/remove-format.svg +1 -0
- df_site/static/images/return-arrow.svg +1 -0
- df_site/static/images/select-all.svg +1 -0
- df_site/static/images/show-blocks.svg +1 -0
- df_site/static/images/source-editing.svg +1 -0
- df_site/static/images/specialcharacters.svg +1 -0
- df_site/static/images/strikethrough.svg +1 -0
- df_site/static/images/subscript.svg +1 -0
- df_site/static/images/superscript.svg +1 -0
- df_site/static/images/table-cell-properties.svg +1 -0
- df_site/static/images/table-column.svg +1 -0
- df_site/static/images/table-merge-cell.svg +1 -0
- df_site/static/images/table-properties.svg +1 -0
- df_site/static/images/table-row.svg +1 -0
- df_site/static/images/table.svg +1 -0
- df_site/static/images/text-alternative.svg +1 -0
- df_site/static/images/text.svg +1 -0
- df_site/static/images/three-vertical-dots.svg +1 -0
- df_site/static/images/todolist.svg +1 -0
- df_site/static/images/underline.svg +1 -0
- df_site/static/images/undo.svg +1 -0
- df_site/static/images/unlink.svg +1 -0
- df_site/static/js/app.js +98 -0
- df_site/static/js/app.js.map +1 -0
- df_site/static/js/base.js +161181 -0
- df_site/static/js/base.js.map +1 -0
- df_site/static/translations/af.js +1 -0
- df_site/static/translations/ar.js +1 -0
- df_site/static/translations/ast.js +1 -0
- df_site/static/translations/az.js +1 -0
- df_site/static/translations/bg.js +1 -0
- df_site/static/translations/bn.js +1 -0
- df_site/static/translations/bs.js +1 -0
- df_site/static/translations/ca.js +1 -0
- df_site/static/translations/cs.js +1 -0
- df_site/static/translations/da.js +1 -0
- df_site/static/translations/de-ch.js +1 -0
- df_site/static/translations/de.js +1 -0
- df_site/static/translations/el.js +1 -0
- df_site/static/translations/en-au.js +1 -0
- df_site/static/translations/en-gb.js +1 -0
- df_site/static/translations/en.js +1 -0
- df_site/static/translations/eo.js +1 -0
- df_site/static/translations/es-co.js +1 -0
- df_site/static/translations/es.js +1 -0
- df_site/static/translations/et.js +1 -0
- df_site/static/translations/eu.js +1 -0
- df_site/static/translations/fa.js +1 -0
- df_site/static/translations/fi.js +1 -0
- df_site/static/translations/gl.js +1 -0
- df_site/static/translations/gu.js +1 -0
- df_site/static/translations/he.js +1 -0
- df_site/static/translations/hi.js +1 -0
- df_site/static/translations/hr.js +1 -0
- df_site/static/translations/hu.js +1 -0
- df_site/static/translations/hy.js +1 -0
- df_site/static/translations/id.js +1 -0
- df_site/static/translations/it.js +1 -0
- df_site/static/translations/ja.js +1 -0
- df_site/static/translations/jv.js +1 -0
- df_site/static/translations/kk.js +1 -0
- df_site/static/translations/km.js +1 -0
- df_site/static/translations/kn.js +1 -0
- df_site/static/translations/ko.js +1 -0
- df_site/static/translations/ku.js +1 -0
- df_site/static/translations/lt.js +1 -0
- df_site/static/translations/lv.js +1 -0
- df_site/static/translations/ms.js +1 -0
- df_site/static/translations/nb.js +1 -0
- df_site/static/translations/ne.js +1 -0
- df_site/static/translations/nl.js +1 -0
- df_site/static/translations/no.js +1 -0
- df_site/static/translations/oc.js +1 -0
- df_site/static/translations/pl.js +1 -0
- df_site/static/translations/pt-br.js +1 -0
- df_site/static/translations/pt.js +1 -0
- df_site/static/translations/ro.js +1 -0
- df_site/static/translations/ru.js +1 -0
- df_site/static/translations/si.js +1 -0
- df_site/static/translations/sk.js +1 -0
- df_site/static/translations/sl.js +1 -0
- df_site/static/translations/sq.js +1 -0
- df_site/static/translations/sr-latn.js +1 -0
- df_site/static/translations/sr.js +1 -0
- df_site/static/translations/sv.js +1 -0
- df_site/static/translations/th.js +1 -0
- df_site/static/translations/ti.js +1 -0
- df_site/static/translations/tk.js +1 -0
- df_site/static/translations/tr.js +1 -0
- df_site/static/translations/tt.js +1 -0
- df_site/static/translations/ug.js +1 -0
- df_site/static/translations/uk.js +1 -0
- df_site/static/translations/ur.js +1 -0
- df_site/static/translations/uz.js +1 -0
- df_site/static/translations/vi.js +1 -0
- df_site/static/translations/zh-cn.js +1 -0
- df_site/static/translations/zh.js +1 -0
- df_site/static/webfonts/fa-brands-400.ttf +0 -0
- df_site/static/webfonts/fa-brands-400.woff2 +0 -0
- df_site/static/webfonts/fa-regular-400.ttf +0 -0
- df_site/static/webfonts/fa-regular-400.woff2 +0 -0
- df_site/static/webfonts/fa-solid-900.ttf +0 -0
- df_site/static/webfonts/fa-solid-900.woff2 +0 -0
- df_site/static/webfonts/fa-v4compatibility.ttf +0 -0
- df_site/static/webfonts/fa-v4compatibility.woff2 +0 -0
- df_site/templates/account/email.html +78 -0
- df_site/templates/account/password_change.html +28 -0
- df_site/templates/account/snippets/warn_no_email.html +6 -0
- df_site/templates/allauth/elements/alert.html +6 -0
- df_site/templates/allauth/elements/badge.html +4 -0
- df_site/templates/allauth/elements/button.html +14 -0
- df_site/templates/allauth/elements/button_group.html +5 -0
- df_site/templates/allauth/elements/field.html +72 -0
- df_site/templates/allauth/elements/fields.html +3 -0
- df_site/templates/allauth/elements/form.html +10 -0
- df_site/templates/allauth/elements/h1.html +1 -0
- df_site/templates/allauth/elements/h2.html +1 -0
- df_site/templates/allauth/elements/img.html +4 -0
- df_site/templates/allauth/elements/p.html +1 -0
- df_site/templates/allauth/elements/panel.html +14 -0
- df_site/templates/allauth/elements/provider.html +6 -0
- df_site/templates/allauth/elements/provider_list.html +5 -0
- df_site/templates/allauth/elements/table.html +5 -0
- df_site/templates/allauth/layouts/base.html +14 -0
- df_site/templates/allauth/layouts/entrance.html +20 -0
- df_site/templates/allauth/layouts/manage.html +1 -0
- df_site/templates/cookie_consent/_cookie_group.html +64 -0
- df_site/templates/cookie_consent/cookiegroup_list.html +23 -0
- df_site/templates/df_components/base.html +0 -0
- df_site/templates/df_components/detail.html +12 -0
- df_site/templates/df_components/detail_fieldset.html +46 -0
- df_site/templates/df_components/list.html +42 -0
- df_site/templates/df_components/list_filter.html +13 -0
- df_site/templates/df_components/list_filters.html +36 -0
- df_site/templates/df_components/list_hierarchy.html +25 -0
- df_site/templates/df_components/list_pagination.html +39 -0
- df_site/templates/df_components/list_search_form.html +38 -0
- df_site/templates/df_components/list_table.html +35 -0
- df_site/templates/df_site/app.html +1 -0
- df_site/templates/df_site/base.html +221 -0
- df_site/templates/df_site/detail.html +8 -0
- df_site/templates/df_site/humans.txt +11 -0
- df_site/templates/df_site/manage_base.html +51 -0
- df_site/templates/df_site/popup_app.html +1 -0
- df_site/templates/df_site/popup_base.html +29 -0
- df_site/templates/df_site/security.txt +5 -0
- df_site/templates/django_bootstrap5/breadcrumb.html +17 -0
- df_site/templates/django_bootstrap5/pagination.html +40 -0
- df_site/templates/django_ckeditor_5/widget.html +13 -0
- df_site/templates/favicon/browserconfig.xml +9 -0
- df_site/templates/mfa/index.html +115 -0
- df_site/templates/mfa/recovery_codes/index.html +33 -0
- df_site/templates/mfa/webauthn/authenticator_list.html +74 -0
- df_site/templates/pipeline/css.html +1 -0
- df_site/templates/pipeline/js.html +1 -0
- df_site/templates/postman/archives.html +8 -0
- df_site/templates/postman/base.html +20 -0
- df_site/templates/postman/base_folder.html +71 -0
- df_site/templates/postman/base_write.html +26 -0
- df_site/templates/postman/inbox.html +7 -0
- df_site/templates/postman/inc_subject_ex.html +21 -0
- df_site/templates/postman/trash.html +12 -0
- df_site/templates/postman/view.html +64 -0
- df_site/templates/users/settings.html +26 -0
- df_site/templates/usersessions/usersession_list.html +70 -0
- df_site/templatetags/__init__.py +1 -0
- df_site/templatetags/df_site.py +241 -0
- df_site/templatetags/images.py +515 -0
- df_site/templatetags/pipeline_sri.py +97 -0
- df_site/testing/__init__.py +1 -0
- df_site/testing/multiple_views.py +369 -0
- df_site/testing/requests.py +299 -0
- df_site/urls.py +41 -0
- df_site/user_settings.py +69 -0
- df_site/users/__init__.py +1 -0
- df_site/users/forms.py +35 -0
- df_site/users/notifications.py +14 -0
- df_site/users/urls.py +17 -0
- df_site/users/views.py +75 -0
- df_site/views.py +122 -0
- df_site-0.1.0.dist-info/LICENSE +519 -0
- df_site-0.1.0.dist-info/METADATA +217 -0
- df_site-0.1.0.dist-info/RECORD +309 -0
- df_site-0.1.0.dist-info/WHEEL +4 -0
- 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."""
|