Skip to content

Commit 490f018

Browse files
feat: Add template components (#263)
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
1 parent 12b5e6d commit 490f018

36 files changed

+1003
-159
lines changed

.github/workflows/codecov.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ jobs:
2222
os: [
2323
ubuntu-latest,
2424
]
25+
exclude:
26+
- python-version: "3.12"
27+
requirements-file: dj42_cms311.txt
2528

2629
steps:
2730
- uses: actions/checkout@v4
@@ -32,9 +35,9 @@ jobs:
3235
uses: actions/setup-python@v5
3336
with:
3437
python-version: ${{ matrix.python-version }}
35-
- name: Intall dependencies
38+
- name: Install dependencies
3639
run: |
37-
pip install -r tests/requirements/${{ matrix.requirements-file }}
40+
pip install -U -r tests/requirements/${{ matrix.requirements-file }}
3841
- name: Run coverage
3942
run: |
4043
coverage run -m pytest

djangocms_frontend/apps.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ class DjangocmsFrontendConfig(apps.AppConfig):
88

99
def ready(self):
1010
from . import plugin_tag
11+
from . import component_pool
1112

13+
component_pool.setup()
1214
plugin_tag.setup()
1315
checks.register(check_settings)
1416
checks.register(check_installed_apps)

djangocms_frontend/cms_plugins.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
from cms.plugin_pool import plugin_pool
22

3-
from .component_pool import components
43
from .ui_plugin_base import CMSUIPluginBase
54

65

76
class CMSUIPlugin(CMSUIPluginBase):
87
pass
98

109

11-
# Loop through the values in the components' registry
12-
for _, plugin, slot_plugins in components._registry.values():
13-
# Add the plugin to the global namespace
14-
globals()[plugin.__name__] = plugin
15-
# Register the plugin with the plugin pool
16-
plugin_pool.register_plugin(plugin)
10+
def update_plugin_pool():
11+
from .component_pool import components
1712

18-
# Loop through the slot plugins associated with the current plugin
19-
for slot_plugin in slot_plugins:
20-
# Add the slot plugin to the global namespace
21-
globals()[slot_plugin.__name__] = slot_plugin
22-
# Register the slot plugin with the plugin pool
23-
plugin_pool.register_plugin(slot_plugin)
13+
# Loop through the values in the components' registry
14+
for _, plugin, slot_plugins in components._registry.values():
15+
if plugin.__name__ not in plugin_pool.plugins:
16+
# Add the plugin to the global namespace
17+
globals()[plugin.__name__] = plugin
18+
# Register the plugin with the plugin pool
19+
plugin_pool.register_plugin(plugin)
20+
21+
# Loop through the slot plugins associated with the current plugin
22+
for slot_plugin in slot_plugins:
23+
# Add the slot plugin to the global namespace
24+
globals()[slot_plugin.__name__] = slot_plugin
25+
# Register the slot plugin with the plugin pool
26+
plugin_pool.register_plugin(slot_plugin)

djangocms_frontend/component_base.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
import importlib
22
import typing
33

4-
from cms.api import add_plugin
5-
from cms.plugin_base import CMSPluginBase
64
from django import forms
75
from django.apps import apps
86
from django.utils.encoding import force_str
97
from django.utils.translation import gettext_lazy as _
108
from entangled.forms import EntangledModelForm
119

12-
from .ui_plugin_base import CMSUIComponent
13-
1410

1511
def _import_or_empty(module, name):
1612
try:
@@ -31,6 +27,14 @@ def _get_mixin_classes(mixins: list, suffix: str = "") -> list[type]:
3127
return [_import_or_empty(module, name) for module, name in mixins]
3228

3329

30+
class classproperty:
31+
def __init__(self, fget):
32+
self.fget = fget
33+
34+
def __get__(self, obj, owner):
35+
return self.fget(owner)
36+
37+
3438
class Slot:
3539
"""Slat class as syntactic surgar to more easily define slot plugins"""
3640

@@ -104,7 +108,7 @@ def plugin_model_factory(cls) -> type:
104108
from djangocms_frontend.models import FrontendUIItem
105109

106110
app_config = apps.get_containing_app_config(cls.__module__)
107-
if app_config is None:
111+
if app_config is None: # pragma: no cover
108112
raise ValueError(f"Cannot find app_config for {cls.__module__}")
109113
cls._model = type(
110114
cls.__name__,
@@ -124,13 +128,15 @@ def plugin_model_factory(cls) -> type:
124128
},
125129
),
126130
"get_short_description": cls.get_short_description,
127-
"__module__": "djangocms_frontend.contrib.component.models",
131+
"__module__": cls.__module__,
128132
},
129133
)
130134
return cls._model
131135

132136
@classmethod
133137
def plugin_factory(cls) -> type:
138+
from .ui_plugin_base import CMSUIComponent
139+
134140
if cls._plugin is None:
135141
mixins = getattr(cls._component_meta, "mixins", [])
136142
slots = cls.get_slot_plugins()
@@ -168,12 +174,15 @@ def plugin_factory(cls) -> type:
168174
if hasattr(cls, "get_render_template")
169175
else {}
170176
),
177+
"__module__": cls.__module__,
171178
},
172179
)
173180
return cls._plugin
174181

175182
@classmethod
176183
def slot_plugin_factory(cls) -> list[type]:
184+
from cms.plugin_base import CMSPluginBase
185+
177186
slots = cls.get_slot_plugins()
178187
return [
179188
type(
@@ -184,7 +193,9 @@ def slot_plugin_factory(cls) -> list[type]:
184193
"module": getattr(cls._component_meta, "module", _("Component")),
185194
"allow_children": True,
186195
"edit_disabled": True,
187-
"parent_classes": cls.__name__ + "Plugin",
196+
"is_local": False,
197+
"show_add_form": False,
198+
"parent_classes": [cls.__name__ + "Plugin"],
188199
"render_template": cls.slot_template,
189200
**slot.kwargs,
190201
},
@@ -200,8 +211,7 @@ def get_registration(cls) -> tuple[type, type, list[type]]:
200211
cls.slot_plugin_factory(),
201212
)
202213

203-
@classmethod
204-
@property
214+
@classproperty
205215
def _component_meta(cls) -> typing.Optional[type]:
206216
return getattr(cls, "Meta", None)
207217

@@ -214,6 +224,8 @@ def get_short_description(self) -> str:
214224

215225
def save_model(self, request, obj, form: forms.Form, change: bool) -> None:
216226
"""Auto-creates slot plugins upon creation of component plugin instance"""
227+
from cms.api import add_plugin
228+
from .ui_plugin_base import CMSUIComponent
217229

218230
super(CMSUIComponent, self).save_model(request, obj, form, change)
219231
if not change:

djangocms_frontend/component_pool.py

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,121 @@
1+
from collections import defaultdict
2+
import importlib
3+
import os
4+
from collections.abc import Iterator
15
import warnings
26

7+
from django import forms
8+
from django.apps import apps
9+
from django.template.loader import get_template
310
from django.utils.module_loading import autodiscover_modules
411

12+
from sekizai.context import SekizaiContext
13+
14+
from djangocms_frontend import settings
15+
from djangocms_frontend.component_base import CMSFrontendComponent
16+
17+
18+
def find_cms_component_templates() -> list[tuple[str, str]]:
19+
templates = []
20+
for app in apps.get_app_configs():
21+
app_template_dir = os.path.join(app.path, "templates", app.label, "cms_components")
22+
if os.path.exists(app_template_dir):
23+
for root, _, files in os.walk(app_template_dir):
24+
for file in files:
25+
if file.endswith(".html") or file.endswith(".htm"):
26+
relative_path = os.path.relpath(os.path.join(root, file), app_template_dir)
27+
templates.append((app.module.__name__, f"{app.label}/cms_components/{relative_path}"))
28+
return templates
29+
30+
31+
class CMSAutoComponentDiscovery:
32+
default_field_context = {
33+
"djanghocms_text": "djangocms_text.fields.TextFormField",
34+
"djanghocms_text_ckeditor": "djangocms_text_ckeditor.fields.TextFormField",
35+
"djangocms_link": "djangocms_link.fields.LinkFormField",
36+
"djangocms_frontend": [
37+
"djangocms_frontend.contrib.image.fields.ImageFormField",
38+
"djangocms_frontend.contrib.icon.fields.IconPickerField",
39+
],
40+
}
41+
42+
def __init__(self, register_to):
43+
self.default_field_context.update(settings.COMPONENT_FIELDS)
44+
templates = find_cms_component_templates()
45+
auto_components = self.scan_templates_for_component_declaration(templates)
46+
for component in auto_components:
47+
register_to.register(component)
48+
49+
def get_field_context(self) -> dict:
50+
field_context = {}
51+
for key, value in self.default_field_context.items():
52+
if apps.is_installed(key):
53+
if not isinstance(value, list):
54+
value = [value]
55+
for field in value:
56+
if "." in field:
57+
module, field_name = field.rsplit(".", 1)
58+
field_context[field_name] = importlib.import_module(module).__dict__[field_name]
59+
return field_context
60+
61+
@staticmethod
62+
def component_factory(module, component: tuple, fields: list[tuple], template: str) -> CMSFrontendComponent:
63+
args, kwargs = component
64+
(name,) = args
65+
66+
kwargs["render_template"] = template
67+
meta = type("Meta", (), kwargs)
68+
return type(
69+
name,
70+
(CMSFrontendComponent,),
71+
{
72+
"Meta": meta,
73+
"__module__": module,
74+
**{
75+
# Django template engine instantiates objects -- re-instantiate them here
76+
args[0]: args[1].__class__(**kwargs)
77+
for args, kwargs in fields
78+
if isinstance(args[1], forms.Field)
79+
},
80+
},
81+
)
82+
83+
def scan_templates_for_component_declaration(
84+
self, templates: list[tuple[str, str]]
85+
) -> Iterator[CMSFrontendComponent]:
86+
from django.forms import fields
87+
88+
field_context = self.get_field_context()
89+
for module, template_name in templates:
90+
# Create a new context for each template
91+
context = SekizaiContext(
92+
{"_cms_components": defaultdict(list), "forms": fields, "instance": {}, **field_context}
93+
)
94+
try:
95+
template = get_template(template_name)
96+
template.template.render(context)
97+
cms_component = context["_cms_components"].get("cms_component", [])
98+
discovered_fields = context["_cms_components"].get("fields", [])
99+
if len(cms_component) == 1:
100+
yield self.component_factory(module, cms_component[0], discovered_fields, template_name)
101+
elif len(cms_component) > 1: # pragma: no cover
102+
raise ValueError(f"Multiple cms_component tags found in {template_name}")
103+
except Exception: # pragma: no cover
104+
# Skip all templates that do not render
105+
import logging
106+
107+
logger = logging.getLogger(__name__)
108+
logger.error(
109+
f"Error rendering template {template_name} to scan for cms frontend components", exc_info=True
110+
)
111+
5112

6113
class Components:
7114
_registry: dict = {}
8115
_discovered: bool = False
9116

10117
def register(self, component):
11-
if component.__name__ in self._registry:
118+
if component.__name__ in self._registry: # pragma: no cover
12119
warnings.warn(f"Component {component.__name__} already registered", stacklevel=2)
13120
return component
14121
self._registry[component.__name__] = component.get_registration()
@@ -20,6 +127,26 @@ def __getitem__(self, item):
20127

21128
components = Components()
22129

23-
if not components._discovered:
24-
autodiscover_modules("cms_components", register_to=components)
25-
components._discovered = True
130+
131+
def setup():
132+
global components
133+
134+
if not components._discovered:
135+
from .cms_plugins import update_plugin_pool
136+
137+
# First discover components in cms_components module
138+
autodiscover_modules("cms_components", register_to=components)
139+
# The discover auto components by their templates
140+
CMSAutoComponentDiscovery(register_to=components)
141+
update_plugin_pool()
142+
components._discovered = True
143+
144+
if text_config := apps.get_app_config("djangocms_text"):
145+
# Hack - update inline editable fields in case djangocms_text is installed
146+
# BEFORE djangocms_frontend in INSTALLED_APPS
147+
if text_config.inline_models:
148+
# Already initialized? Need to initialize again
149+
# to reflect inline fields in of just discovered components
150+
from djangocms_text.apps import discover_inline_editable_models
151+
152+
text_config.inline_models = discover_inline_editable_models()

djangocms_frontend/contrib/link/cms_plugins.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class LinkPluginMixin:
4242
def render(self, context, instance, placeholder):
4343
if "request" in context:
4444
instance._cms_page = getattr(context["request"], "current_page", None)
45-
context["link"] = instance.get_link()
45+
context["mixin_link"] = instance.get_link()
4646
return super().render(context, instance, placeholder)
4747

4848
def get_form(self, request, obj=None, change=False, **kwargs):
@@ -97,4 +97,4 @@ def get_render_template(self, context, instance, placeholder):
9797
if "djangocms_link" in django_settings.INSTALLED_APPS:
9898
from djangocms_link.cms_plugins import LinkPlugin
9999

100-
LinkPlugin.parent_classes = [None] # Remove it from the list of valid plugins
100+
LinkPlugin.parent_classes = [""] # Remove it from the list of valid plugins
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{% load cms_tags frontend %}{% if link %}<a href="{{ link }}"{% if instance.target %} target="{{ instance.target }}"{% endif %}{{ instance.get_attributes }}>{% endif %}{% if instance.icon_left %}{% include "djangocms_frontend/bootstrap5/link/default/icon.html" with icon_class=instance.icon_left attribute_class="me-1" %}{% endif %}{% for plugin in instance.child_plugin_instances %}{% render_plugin plugin %}{% empty %}{{ instance.name }}{% endfor %}{% if instance.icon_right %}{% include "djangocms_frontend/bootstrap5/link/default/icon.html" with icon_class=instance.icon_right attribute_class="ms-1" %}{% endif %}{% if link %}</a>{% endif %}
1+
{% load cms_tags frontend %}{% if mixin_link %}<a href="{{ mixin_link }}"{% if instance.target %} target="{{ instance.target }}"{% endif %}{{ instance.get_attributes }}>{% endif %}{% if instance.icon_left %}{% include "djangocms_frontend/bootstrap5/link/default/icon.html" with icon_class=instance.icon_left attribute_class="me-1" %}{% endif %}{% for plugin in instance.child_plugin_instances %}{% render_plugin plugin %}{% empty %}{{ instance.name }}{% endfor %}{% if instance.icon_right %}{% include "djangocms_frontend/bootstrap5/link/default/icon.html" with icon_class=instance.icon_right attribute_class="ms-1" %}{% endif %}{% if link %}</a>{% endif %}

djangocms_frontend/fields.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django import forms
2+
from django.apps import apps
23
from django.core.exceptions import ValidationError
34
from django.db import models
45
from django.utils.safestring import mark_safe
@@ -9,6 +10,19 @@
910
from .helpers import first_choice
1011

1112

13+
if apps.is_installed("djangocms_text"):
14+
from djangocms_text.fields import HTMLFormField # noqa F401
15+
16+
HTMLsanitized = True
17+
elif apps.is_installed("djangocms_text_ckeditor"): # pragma: no cover
18+
from djangocms_text_ckeditor.fields import HTMLFormField # noqa F401
19+
20+
HTMLsanitized = True
21+
else: # pragma: no cover
22+
HTMLFormField = forms.CharField
23+
HTMLsanitized = False
24+
25+
1226
class TemplateChoiceMixin:
1327
"""Mixin that hides the template field if only one template is available and is selected"""
1428

@@ -168,12 +182,3 @@ class Media:
168182
def __init__(self, *args, **kwargs):
169183
kwargs.setdefault("attrs", {"class": "auto-field"})
170184
super().__init__(*args, **kwargs)
171-
172-
173-
try:
174-
from djangocms_text_ckeditor.fields import HTMLFormField # noqa
175-
176-
HTMLsanitized = True
177-
except ModuleNotFoundError:
178-
HTMLFormField = forms.CharField
179-
HTMLsanitized = False

djangocms_frontend/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676

7777
FORM_OPTIONS = getattr(django_settings, "DJANGOCMS_FRONTEND_FORM_OPTIONS", {})
7878

79+
COMPONENT_FIELDS = getattr(django_settings, "DJANGOCMS_FRONTEND_COMPONENT_FIELDS", {})
7980

8081
framework = getattr(django_settings, "DJANGOCMS_FRONTEND_FRAMEWORK", "bootstrap5")
8182
theme = getattr(django_settings, "DJANGOCMS_FRONTEND_THEME", "djangocms_frontend")

0 commit comments

Comments
 (0)