Skip to content

feat(cms): TACC Site Card plugin#1191

Merged
wesleyboar merged 14 commits into
mainfrom
feat/taccsite-card-plugin
Jul 1, 2026
Merged

feat(cms): TACC Site Card plugin#1191
wesleyboar merged 14 commits into
mainfrom
feat/taccsite-card-plugin

Conversation

@wesleyboar

@wesleyboar wesleyboar commented Jun 30, 2026

Copy link
Copy Markdown
Member

Overview

Editors deserve a simple Card plugin.

  • no migrations
  • subclasses Style
  • skin via (class_name)
  • layout via (template)
  • supports c-card's BEM
Why not use Bootstrap Card plugin?
  • Bootstrap Card plugin suite is too tedious to build a card with.
  • Bootstrap card styles are not yet skinned by Core-Styles.
  • Using our own allows us to render TACC c-card for now, and render Bootstrap when we support it.

Changes

  • added taccsite_cms.contrib.taccsite_card
  • updated INSTALLED_APPS
  • updated create_test_page_section_style

Testing

  1. make start
  2. docker exec core_cms python manage.py test taccsite_cms.contrib.taccsite_card --no-input
  3. docker exec core_cms python manage.py create_test_page_card_style --replace
  4. Open the test page URL.
  5. Confirm Card blocks show c-card plus expected skin/layout classes in the DOM.

UI

create.test.pages.mov
review.card.plugin.mov

Editors get a Style-based Card plugin that always outputs c-card with
stackable skin and image-layout modifiers, without new migrations.
@wesleyboar wesleyboar changed the title feat(cms): add TACC Site Card plugin for Core-Styles c-card feat(cms): TACC Site Card plugin Jun 30, 2026
Use c-card class names, plain default skin, and tag-type form UX; nest
test pages under /test/ with a dedicated card layout grid command.
Use CARD_SKIN_CLASS_NAME_CHOICES (Base, Plain, Standard) instead of raw class strings in the admin.
Drop legacy card/card-- mapping; default new plugins to plain when class_name is not a valid skin choice.
Keep constants.py for choices only; class and attribute helpers live in taccsite_card/utils.py.
Centralize tag choices and CARD_STYLE_INHERITED_TAG; default new plugins when tag is unknown or still Style’s inherited value.
@wesleyboar wesleyboar marked this pull request as ready for review July 1, 2026 00:53
@wesleyboar wesleyboar merged commit dcfca72 into main Jul 1, 2026
@wesleyboar wesleyboar deleted the feat/taccsite-card-plugin branch July 1, 2026 00:53
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add TACC Site Card CMS plugin (c-card) with QA test page command

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add a Style-based “Card” CMS plugin that always renders Core-Styles c-card.
• Support card skin via class_name and image layouts via template choices.
• Add automated tests and a /test/ QA page generator for visual verification.
Diagram

graph TD
  A[CMS Editor] --> B["TaccsiteCardPlugin"] --> C["taccsite_card templates"]
  B --> D["taccsite_card utils"]
  B --> E[(django CMS DB)]
  F["Card plugin form"] --> B
  G["create_test_page_card_style cmd"] --> E
  G --> B
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use djangocms-bootstrap4 Card plugin suite
  • ➕ Leverages an existing, maintained plugin set
  • ➕ Avoids custom rendering/templates for cards
  • ➖ Does not directly map to Core-Styles c-card today
  • ➖ More complex authoring UX for simple cards
  • ➖ Would still require skinning/bootstrap alignment work
2. Create a dedicated Card plugin model (not Style-based)
  • ➕ Explicit schema for card concept (skin/layout/image slots) and validations
  • ➕ Less coupling to djangocms_style defaults/semantics
  • ➖ Requires migrations and long-term model maintenance
  • ➖ Harder to iterate quickly and reuse existing Style features
3. Render-only approach (no template variants; all via classes)
  • ➕ Fewer templates and simpler plugin code path
  • ➕ Keeps layout behavior purely CSS-driven
  • ➖ Harder to guide editors toward valid combinations
  • ➖ Some layout variants may need markup constraints (e.g., image placement ordering)

Recommendation: The chosen approach (Style-based wrapper that always emits c-card, with curated choices and no migrations) is a strong fit for quick editor enablement and Core-Styles alignment. Keep this as the primary path; if/when Bootstrap card styling is fully supported, consider adding an adapter layer (same plugin UX, alternate template set) rather than switching editors to a different plugin suite.

Files changed (18) +760 / -22

Enhancement (11) +273 / -0
__init__.pyCreate taccsite_card Django app package +0/-0

Create taccsite_card Django app package

• Introduces the new app package namespace used to register the Card plugin and related code.

taccsite_card/init.py

apps.pyAdd AppConfig for TACC Site Card +6/-0

Add AppConfig for TACC Site Card

• Registers the Django app with a readable verbose name for admin/app listings.

taccsite_card/apps.py

cms_plugins.pyImplement Style-based Card CMS plugin rendering 'c-card' +83/-0

Implement Style-based Card CMS plugin rendering 'c-card'

• Adds a django CMS plugin (subclassing StylePlugin) that selects card layout templates and composes final class/attribute strings. Ensures 'c-card' is always present, with optional skin/layout modifiers and normalized additional classes.

taccsite_card/cms_plugins.py

constants.pyDefine Card skin/layout/tag choices and defaults +32/-0

Define Card skin/layout/tag choices and defaults

• Centralizes admin choice lists for card skins, layout templates, and tag types. Also introduces an inherited-tag constant to detect/override Style’s default tag behavior for new plugins.

taccsite_card/constants.py

forms.pyAdd Card plugin form with curated choices and safe defaults +93/-0

Add Card plugin form with curated choices and safe defaults

• Implements a ModelForm for Style that relabels fields into card-specific UX and constrains choices for skin/layout/tag type. Defaults new plugins to a valid card tag and skin when the underlying Style defaults are inherited or invalid.

taccsite_card/forms.py

base.htmlAdd base wrapper template emitting 'c-card' and child plugins +11/-0

Add base wrapper template emitting 'c-card' and child plugins

• Renders a wrapper element using the chosen tag type, composes class names ('c-card' + skin + layout + additional), applies inline styles, and renders all child plugins.

taccsite_card/templates/taccsite_card/base.html

image_bottom.htmlAdd image-bottom layout template variant +2/-0

Add image-bottom layout template variant

• Extends the base card template and adds the 'c-card--image-bottom' layout modifier class.

taccsite_card/templates/taccsite_card/layouts/image_bottom.html

image_left.htmlAdd image-left layout template variant +2/-0

Add image-left layout template variant

• Extends the base card template and adds the 'c-card--image-left' layout modifier class.

taccsite_card/templates/taccsite_card/layouts/image_left.html

image_right.htmlAdd image-right layout template variant +2/-0

Add image-right layout template variant

• Extends the base card template and adds the 'c-card--image-right' layout modifier class.

taccsite_card/templates/taccsite_card/layouts/image_right.html

image_top.htmlAdd image-top layout template variant +2/-0

Add image-top layout template variant

• Extends the base card template and adds the 'c-card--image-top' layout modifier class.

taccsite_card/templates/taccsite_card/layouts/image_top.html

utils.pyAdd helpers for skin mapping, class normalization, and attributes +40/-0

Add helpers for skin mapping, class normalization, and attributes

• Introduces utilities to (1) map 'class_name' to a valid 'c-card--*' modifier, (2) remove redundant 'c-card' tokens from additional classes, and (3) render attributes excluding 'class' to avoid duplication.

taccsite_card/utils.py

Refactor (2) +80 / -16
create_test_page_section_style.pyRefactor section QA page command to use shared test-page helpers +19/-16

Refactor section QA page command to use shared test-page helpers

• Replaces inline draft deletion logic with a shared helper and ensures test pages are nested under a '/test/' parent page created/published on demand.

taccsite_cms/management/commands/create_test_page_section_style.py

test_page_util.pyAdd shared helpers for '/test/' page creation and safe draft cleanup +61/-0

Add shared helpers for '/test/' page creation and safe draft cleanup

• Introduces utilities to delete draft page trees safely in django CMS 3 and to ensure a common '/test/' parent page exists (and can be published). Used by both section and card QA commands.

taccsite_cms/management/test_page_util.py

Tests (1) +76 / -0
tests.pyAdd unit tests for Card utils and plugin rendering +76/-0

Add unit tests for Card utils and plugin rendering

• Adds tests for skin modifier mapping and class normalization. Verifies template selection and that rendered HTML includes stacked 'c-card' + skin + layout modifiers.

taccsite_card/tests.py

Documentation (1) +32 / -6
README.mdDocument unified test page commands for sections and cards +32/-6

Document unified test page commands for sections and cards

• Reorganizes docs into a shared “Create Test Pages” section and adds usage/path for the new card QA page command under '/test/'.

taccsite_cms/management/commands/README.md

Other (3) +299 / -0
djangocms_plugins.pyAllow 'c-card' classes in Style plugin safe list +3/-0

Allow 'c-card' classes in Style plugin safe list

• Adds 'c-card' and its skin modifiers to the configured allowlist so they can be selected/used via CMS style-related configuration.

taccsite_cms/_settings/djangocms_plugins.py

create_test_page_card_style.pyAdd command to generate '/test/' card QA page +295/-0

Add command to generate '/test/' card QA page

• Creates a published (optional) CMS page with a responsive grid of Card plugin variants across skins and image layouts. Uses external sample images and nests the page under a shared '/test/' parent for consistent QA organization.

taccsite_cms/management/commands/create_test_page_card_style.py

settings.pyRegister 'taccsite_card' in INSTALLED_APPS +1/-0

Register 'taccsite_card' in INSTALLED_APPS

• Enables the new Card plugin app so django CMS can discover and register it at runtime.

taccsite_cms/settings/settings.py

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Attributes string autoescaped 🐞 Bug ≡ Correctness
Description
attributes_str_without_class() manually formats HTML attributes into a plain string and
base.html renders it via {{ attributes_str }} under autoescape, causing quotes to be
entity-escaped (e.g. data-x="bar" becomes data-x="bar") and changing the effective
attribute values compared to the rest of the codebase’s instance.attributes_str usage.
Code

taccsite_card/utils.py[R31-40]

+def attributes_str_without_class(instance):
+    """Render attributes except class (class is composed on the wrapper element)."""
+    attributes = dict(instance.attributes or {})
+    attributes.pop('class', None)
+    if not attributes:
+        return ''
+    return ' '.join(
+        f'{key}="{value}"'
+        for key, value in attributes.items()
+    )
Evidence
The Card plugin constructs attributes via string interpolation and renders them through a normal `{{
... }} expression, while other plugins render instance.attributes_str` directly inside tags,
indicating a safe/escaped attribute-fragment convention across the repo.

taccsite_card/utils.py[31-40]
taccsite_card/templates/taccsite_card/base.html[1-7]
taccsite_cms/contrib/taccsite_callout/templates/callout.html[3-9]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`taccsite_card.utils.attributes_str_without_class()` builds an HTML attribute fragment as a plain Python string and passes it to the template, where it is autoescaped. This causes the rendered HTML to differ from other plugins that use `instance.attributes_str` (safe, properly escaped), and can change attribute values (notably quotes).

### Issue Context
The project consistently renders attributes via `{{ instance.attributes_str }}` (from `djangocms-attributes-field`), implying the attribute fragment is already safe and correctly escaped. The Card plugin bypasses that mechanism.

### Fix Focus Areas
- taccsite_card/utils.py[31-40]
- taccsite_card/templates/taccsite_card/base.html[1-7]

### Suggested fix
Prefer one of:
1) **Reuse the existing `AttributesField` rendering path**:
  - Build the final class string (including `c-card`, skin, layout, additional classes) and assign it into `instance.attributes['class']`.
  - Then render `{{ instance.attributes_str }}` in the template (and remove `attributes_str_without_class`).

2) **If you must render a custom attribute fragment**:
  - Remove `class` from the attributes dict.
  - Use `django.forms.utils.flatatt(attributes)` (or `format_html_join` + `conditional_escape`) to produce a SafeString attribute fragment with correct escaping.
  - Ensure the result includes the needed leading space or the template adds it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Unvalidated dynamic tag_type 🐞 Bug ☼ Reliability
Description
taccsite_card/base.html interpolates instance.tag_type into the element name; if an unexpected
value reaches the DB (bypassing form choices), it will generate invalid markup and may break page
rendering.
Code

taccsite_card/templates/taccsite_card/base.html[R2-3]

+<{{ instance.tag_type }}
+  {% if instance.id_name %}id="{{ instance.id_name }}"{% endif %}
Evidence
The template uses instance.tag_type as the literal tag name, but the allowlist is only defined in
constants/form configuration, not enforced during rendering.

taccsite_card/templates/taccsite_card/base.html[1-3]
taccsite_card/constants.py[27-32]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The template uses `instance.tag_type` directly as the HTML tag name. While the form sets choices, render-time code does not defensively enforce the allowlist, so malformed DB values can produce invalid HTML.

### Issue Context
The app already defines an allowlist of tag types (article/aside/div). This is best enforced at render time as well to prevent corrupted content from taking down page rendering.

### Fix Focus Areas
- taccsite_card/templates/taccsite_card/base.html[2-3]
- taccsite_card/constants.py[27-32]
- taccsite_card/forms.py[62-93]

### Suggested fix
In `TaccsiteCardPlugin.render()` (or `get_render_template()`), normalize/validate `instance.tag_type` against `CARD_TAG_TYPE_CHOICES` (or a set derived from it). If not allowed, fall back to `CARD_TAG_TYPE_DEFAULT` before the template renders.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Layout template not guarded 🐞 Bug ☼ Reliability
Description
TaccsiteCardPlugin.get_render_template() interpolates instance.template into the template path
without validating it against the known layout choices, so unexpected DB values can raise
TemplateDoesNotExist at render time.
Code

taccsite_card/cms_plugins.py[R57-62]

+    def get_render_template(self, context, instance, placeholder):
+        layout = instance.template or 'default'
+        if layout == 'default':
+            return 'taccsite_card/base.html'
+        return f'taccsite_card/layouts/{layout}.html'
+
Evidence
The plugin concatenates instance.template into a template path even though allowed layouts are
declared separately and only applied at the form layer.

taccsite_card/cms_plugins.py[57-62]
taccsite_card/constants.py[19-25]
taccsite_card/forms.py[84-90]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`get_render_template()` constructs `taccsite_card/layouts/{layout}.html` from `instance.template` without guarding. If a record has an invalid value (older data, manual DB edits, etc.), rendering will fail.

### Issue Context
You already define valid layouts in `CARD_LAYOUT_TEMPLATES` and set them as form choices, but render-time code trusts the stored value.

### Fix Focus Areas
- taccsite_card/cms_plugins.py[57-62]
- taccsite_card/constants.py[19-25]
- taccsite_card/forms.py[84-90]

### Suggested fix
Build an allowlist from `CARD_LAYOUT_TEMPLATES` (e.g., `{key for key, _ in CARD_LAYOUT_TEMPLATES}`) and:
- if `instance.template` is falsy or not in the allowlist -> return `taccsite_card/base.html`
- else -> return the corresponding layout template path.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Empty STYLE_TAGS crash 🐞 Bug ☼ Reliability
Description
CARD_STYLE_INHERITED_TAG = STYLE_TAGS[0] will raise IndexError at import time if
DJANGOCMS_STYLE_TAGS is configured as an empty sequence, preventing Django startup.
Code

taccsite_card/constants.py[R4-11]

+STYLE_TAGS = getattr(
+    settings,
+    'DJANGOCMS_STYLE_TAGS',
+    ('div', 'article', 'section'),
+)
+# djangocms_style uses the first DJANGOCMS_STYLE_TAGS entry as Style.tag_type default.
+CARD_STYLE_INHERITED_TAG = STYLE_TAGS[0]
+
Evidence
The module defines STYLE_TAGS from settings and immediately indexes [0] without checking length,
making import-time failure possible under an empty configuration.

taccsite_card/constants.py[4-11]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`taccsite_card.constants` indexes `STYLE_TAGS[0]` at import time without ensuring the tuple/list is non-empty.

### Issue Context
If a deployment sets `DJANGOCMS_STYLE_TAGS=()` (or `[]`), importing this app will raise `IndexError`, breaking startup.

### Fix Focus Areas
- taccsite_card/constants.py[4-11]

### Suggested fix
Handle the empty-setting case safely, for example:
- `CARD_STYLE_INHERITED_TAG = STYLE_TAGS[0] if STYLE_TAGS else 'div'` (or `CARD_TAG_TYPE_DEFAULT`)
- optionally, add a system check / warning if the setting is empty.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread taccsite_card/utils.py
Comment on lines +31 to +40
def attributes_str_without_class(instance):
"""Render attributes except class (class is composed on the wrapper element)."""
attributes = dict(instance.attributes or {})
attributes.pop('class', None)
if not attributes:
return ''
return ' '.join(
f'{key}="{value}"'
for key, value in attributes.items()
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Attributes string autoescaped 🐞 Bug ≡ Correctness

attributes_str_without_class() manually formats HTML attributes into a plain string and
base.html renders it via {{ attributes_str }} under autoescape, causing quotes to be
entity-escaped (e.g. data-x="bar" becomes data-x=&quot;bar&quot;) and changing the effective
attribute values compared to the rest of the codebase’s instance.attributes_str usage.
Agent Prompt
### Issue description
`taccsite_card.utils.attributes_str_without_class()` builds an HTML attribute fragment as a plain Python string and passes it to the template, where it is autoescaped. This causes the rendered HTML to differ from other plugins that use `instance.attributes_str` (safe, properly escaped), and can change attribute values (notably quotes).

### Issue Context
The project consistently renders attributes via `{{ instance.attributes_str }}` (from `djangocms-attributes-field`), implying the attribute fragment is already safe and correctly escaped. The Card plugin bypasses that mechanism.

### Fix Focus Areas
- taccsite_card/utils.py[31-40]
- taccsite_card/templates/taccsite_card/base.html[1-7]

### Suggested fix
Prefer one of:
1) **Reuse the existing `AttributesField` rendering path**:
   - Build the final class string (including `c-card`, skin, layout, additional classes) and assign it into `instance.attributes['class']`.
   - Then render `{{ instance.attributes_str }}` in the template (and remove `attributes_str_without_class`).

2) **If you must render a custom attribute fragment**:
   - Remove `class` from the attributes dict.
   - Use `django.forms.utils.flatatt(attributes)` (or `format_html_join` + `conditional_escape`) to produce a SafeString attribute fragment with correct escaping.
   - Ensure the result includes the needed leading space or the template adds it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +2 to +3
<{{ instance.tag_type }}
{% if instance.id_name %}id="{{ instance.id_name }}"{% endif %}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Unvalidated dynamic tag_type 🐞 Bug ☼ Reliability

taccsite_card/base.html interpolates instance.tag_type into the element name; if an unexpected
value reaches the DB (bypassing form choices), it will generate invalid markup and may break page
rendering.
Agent Prompt
### Issue description
The template uses `instance.tag_type` directly as the HTML tag name. While the form sets choices, render-time code does not defensively enforce the allowlist, so malformed DB values can produce invalid HTML.

### Issue Context
The app already defines an allowlist of tag types (article/aside/div). This is best enforced at render time as well to prevent corrupted content from taking down page rendering.

### Fix Focus Areas
- taccsite_card/templates/taccsite_card/base.html[2-3]
- taccsite_card/constants.py[27-32]
- taccsite_card/forms.py[62-93]

### Suggested fix
In `TaccsiteCardPlugin.render()` (or `get_render_template()`), normalize/validate `instance.tag_type` against `CARD_TAG_TYPE_CHOICES` (or a set derived from it). If not allowed, fall back to `CARD_TAG_TYPE_DEFAULT` before the template renders.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +57 to +62
def get_render_template(self, context, instance, placeholder):
layout = instance.template or 'default'
if layout == 'default':
return 'taccsite_card/base.html'
return f'taccsite_card/layouts/{layout}.html'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Layout template not guarded 🐞 Bug ☼ Reliability

TaccsiteCardPlugin.get_render_template() interpolates instance.template into the template path
without validating it against the known layout choices, so unexpected DB values can raise
TemplateDoesNotExist at render time.
Agent Prompt
### Issue description
`get_render_template()` constructs `taccsite_card/layouts/{layout}.html` from `instance.template` without guarding. If a record has an invalid value (older data, manual DB edits, etc.), rendering will fail.

### Issue Context
You already define valid layouts in `CARD_LAYOUT_TEMPLATES` and set them as form choices, but render-time code trusts the stored value.

### Fix Focus Areas
- taccsite_card/cms_plugins.py[57-62]
- taccsite_card/constants.py[19-25]
- taccsite_card/forms.py[84-90]

### Suggested fix
Build an allowlist from `CARD_LAYOUT_TEMPLATES` (e.g., `{key for key, _ in CARD_LAYOUT_TEMPLATES}`) and:
- if `instance.template` is falsy or not in the allowlist -> return `taccsite_card/base.html`
- else -> return the corresponding layout template path.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +4 to +11
STYLE_TAGS = getattr(
settings,
'DJANGOCMS_STYLE_TAGS',
('div', 'article', 'section'),
)
# djangocms_style uses the first DJANGOCMS_STYLE_TAGS entry as Style.tag_type default.
CARD_STYLE_INHERITED_TAG = STYLE_TAGS[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Empty style_tags crash 🐞 Bug ☼ Reliability

CARD_STYLE_INHERITED_TAG = STYLE_TAGS[0] will raise IndexError at import time if
DJANGOCMS_STYLE_TAGS is configured as an empty sequence, preventing Django startup.
Agent Prompt
### Issue description
`taccsite_card.constants` indexes `STYLE_TAGS[0]` at import time without ensuring the tuple/list is non-empty.

### Issue Context
If a deployment sets `DJANGOCMS_STYLE_TAGS=()` (or `[]`), importing this app will raise `IndexError`, breaking startup.

### Fix Focus Areas
- taccsite_card/constants.py[4-11]

### Suggested fix
Handle the empty-setting case safely, for example:
- `CARD_STYLE_INHERITED_TAG = STYLE_TAGS[0] if STYLE_TAGS else 'div'` (or `CARD_TAG_TYPE_DEFAULT`)
- optionally, add a system check / warning if the setting is empty.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant