diff --git a/admin/templates/admin/group_list.html b/admin/templates/admin/group_list.html
index 3b9c95c9..44120272 100644
--- a/admin/templates/admin/group_list.html
+++ b/admin/templates/admin/group_list.html
@@ -17,7 +17,7 @@
{% if not groups %}
- {% bs_icon 'exclamation-triangle '%} {% trans "Warning" %}: {% trans "You don't have any groups" %}
+ {% bs_icon 'exclamation-triangle' %} {% trans "Warning" %}: {% trans "You don't have any groups" %}
{% else %}
diff --git a/admin/templates/admin/user_list.html b/admin/templates/admin/user_list.html
index ef5cbc8d..206b5479 100644
--- a/admin/templates/admin/user_list.html
+++ b/admin/templates/admin/user_list.html
@@ -20,7 +20,7 @@
{% if not users %}
- {% bs_icon 'exclamation-triangle '%} {% trans "Warning" %}: {% trans "You don't have any user" %}
+ {% bs_icon 'exclamation-triangle' %} {% trans "Warning" %}: {% trans "You don't have any user" %}
{% else %}
diff --git a/static/icon_cache/copy_None_None_None.svg b/static/icon_cache/copy_None_None_None.svg
new file mode 100644
index 00000000..7935cfee
--- /dev/null
+++ b/static/icon_cache/copy_None_None_None.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/static/icon_cache/envelope-open_None_None_None.svg b/static/icon_cache/envelope-open_None_None_None.svg
new file mode 100644
index 00000000..fb80c283
--- /dev/null
+++ b/static/icon_cache/envelope-open_None_None_None.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/static/icon_cache/save_None_None_None.svg b/static/icon_cache/save_None_None_None.svg
new file mode 100644
index 00000000..e05d1980
--- /dev/null
+++ b/static/icon_cache/save_None_None_None.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/webvirtcloud/test_icon_cache.py b/webvirtcloud/test_icon_cache.py
new file mode 100644
index 00000000..5acbfbd1
--- /dev/null
+++ b/webvirtcloud/test_icon_cache.py
@@ -0,0 +1,32 @@
+"""Every {% bs_icon %} of the templates is in the committed icon cache
+(BS_ICONS_CACHE). A missing one is fetched from the CDN when a page is
+rendered, or shown as an error where the server has no internet access."""
+
+import re
+import unittest
+from pathlib import Path
+
+from django.conf import settings
+
+BASE = Path(settings.BASE_DIR)
+CALL = re.compile(r"\{%\s*bs_icon\s+(.*?)\s*%\}")
+NAME = re.compile(r"""^['"]([^'"]+)['"]""")
+KWARG = re.compile(r"""(\w+)=['"]([^'"]*)['"]""")
+
+
+class IconCacheTestCase(unittest.TestCase):
+ def test_every_template_icon_is_cached(self):
+ cache = Path(settings.BS_ICONS_CACHE)
+ missing = []
+ templates = [*BASE.glob("templates/**/*.html"), *BASE.glob("*/templates/**/*.html")]
+ self.assertGreater(len(templates), 50) # the globs still find the templates
+ for template in templates:
+ for args in CALL.findall(template.read_text(encoding="utf-8")):
+ name = NAME.match(args)
+ self.assertIsNotNone(name, f"{template}: icon name is not a literal: {args}")
+ kw = dict(KWARG.findall(args))
+ # the file name django-bootstrap-icons uses for its cache
+ cached = f"{name.group(1)}_{kw.get('size')}_{kw.get('color')}_{kw.get('extra_classes')}.svg".replace(" ", "_")
+ if not (cache / cached).exists():
+ missing.append(f"{template.relative_to(BASE)}: {cached}")
+ self.assertEqual(missing, [])