diff --git a/designsafe/apps/workspace/cms_plugins.py b/designsafe/apps/workspace/cms_plugins.py
index d5570627e6..04dada9cb6 100644
--- a/designsafe/apps/workspace/cms_plugins.py
+++ b/designsafe/apps/workspace/cms_plugins.py
@@ -1,6 +1,7 @@
"""CMS plugins for Tools & Applications pages."""
import logging
+from typing import Optional, Union
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from designsafe.apps.workspace.models.app_entries import (
@@ -10,11 +11,30 @@
AppCategoryListingPlugin,
RelatedAppsPlugin,
AppVariantsPlugin,
+ AppUserGuideLinkPlugin,
)
+from designsafe.apps.workspace.forms import AppUserGuideLinkPluginForm
logger = logging.getLogger(__name__)
+def get_entry_instance(url):
+ """Helper function to get an AppListingEntry instance based on URL."""
+
+ app_listing_entries = AppListingEntry.objects.filter(enabled=True)
+ for entry in app_listing_entries:
+ if entry.href in url:
+ return entry
+
+ return None
+
+def is_editing_text(url):
+ """Helper function to determine if the current context is editing text."""
+
+ # e.g. /…/page/edit-plugin/123/, /…/page/plugin/text_plugin/render-plugin/
+ return 'admin/cms/page' in url
+
+
class AppCategoryListing(CMSPluginBase):
"""CMS plugin to render the list of apps for a given category."""
@@ -53,6 +73,7 @@ def render(self, context, instance, placeholder):
class RelatedApps(CMSPluginBase):
"""CMS plugin to render related apps."""
+ # IDEA: Use get_entry_instance if model field is empty (i.e. auto value)
model = RelatedAppsPlugin
name = "Related Apps"
@@ -89,6 +110,7 @@ def render(self, context, instance: AppListingEntry, placeholder):
class AppVariants(CMSPluginBase):
"""CMS plugin to render an apps versions/variants."""
+ # IDEA: Use get_entry_instance if model field is empty (i.e. auto value)
model = AppVariantsPlugin
name = "App Version Selection"
@@ -105,3 +127,37 @@ def render(self, context, instance: AppListingEntry, placeholder):
plugin_pool.register_plugin(AppVariants)
+
+
+class AppUserGuideLink(CMSPluginBase):
+ """CMS plugin to render the user guide link."""
+
+ model = AppUserGuideLinkPlugin
+ form = AppUserGuideLinkPluginForm
+ name = "App User Guide Link"
+ module = "Tools & Applications"
+ render_template = "designsafe/apps/workspace/app_user_guide_link_plugin.html"
+ text_enabled = True
+ cache = False
+
+ def render(
+ self,
+ context,
+ instance: Optional[Union[AppUserGuideLinkPlugin, AppListingEntry]] = None,
+ placeholder=None,
+ ):
+ plugin_instance = instance if isinstance(instance, AppUserGuideLinkPlugin) else None
+
+ instance_app = None
+ if isinstance(instance, AppUserGuideLinkPlugin):
+ instance_app = getattr(instance, "app", None)
+ if instance_app is None:
+ instance_app = get_entry_instance(context.get("request").path)
+
+ context = super().render(context, plugin_instance, placeholder)
+ context["user_guide_link"] = getattr(instance_app, "user_guide_link", None)
+ context["is_editing_text"] = is_editing_text(context.get("request").path)
+ return context
+
+
+plugin_pool.register_plugin(AppUserGuideLink)
diff --git a/designsafe/apps/workspace/forms.py b/designsafe/apps/workspace/forms.py
index 9e036d84ac..d496eb48ba 100644
--- a/designsafe/apps/workspace/forms.py
+++ b/designsafe/apps/workspace/forms.py
@@ -3,6 +3,7 @@
from django.forms import ModelForm
from designsafe.apps.workspace.models.app_entries import AppVariant
+from designsafe.apps.workspace.models.app_cms_plugins import AppUserGuideLinkPlugin
class AppVariantForm(ModelForm):
@@ -20,3 +21,18 @@ class Meta:
" only after save."
),
}
+
+class AppUserGuideLinkPluginForm(ModelForm):
+ """Customizes app user guide link plugin admin form"""
+
+ class Meta:
+ """To add or change help text"""
+
+ model = AppUserGuideLinkPlugin
+ fields = '__all__'
+
+ help_texts = {
+ "app": (
+ "If no app is selected, then app can be derived from URL path."
+ ),
+ }
diff --git a/designsafe/apps/workspace/migrations/0019_appuserguidelinkplugin.py b/designsafe/apps/workspace/migrations/0019_appuserguidelinkplugin.py
new file mode 100644
index 0000000000..e2c42b675b
--- /dev/null
+++ b/designsafe/apps/workspace/migrations/0019_appuserguidelinkplugin.py
@@ -0,0 +1,44 @@
+# Generated by Django 4.2.20 on 2025-09-22 21:33
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("cms", "0022_auto_20180620_1551"),
+ ("workspace", "0018_appvariant_external_href"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AppUserGuideLinkPlugin",
+ fields=[
+ (
+ "cmsplugin_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ related_name="%(app_label)s_%(class)s",
+ serialize=False,
+ to="cms.cmsplugin",
+ ),
+ ),
+ (
+ "app",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="workspace.applistingentry",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("cms.cmsplugin",),
+ ),
+ ]
diff --git a/designsafe/apps/workspace/models/app_cms_plugins.py b/designsafe/apps/workspace/models/app_cms_plugins.py
index 2ba760254a..20337f8c67 100644
--- a/designsafe/apps/workspace/models/app_cms_plugins.py
+++ b/designsafe/apps/workspace/models/app_cms_plugins.py
@@ -35,3 +35,17 @@ class AppVariantsPlugin(CMSPlugin):
def __str__(self):
return self.app.label
+
+
+class AppUserGuideLinkPlugin(CMSPlugin):
+ """Model for rendering an app user guide link."""
+
+ app = models.ForeignKey(
+ to=AppListingEntry,
+ on_delete=models.deletion.CASCADE,
+ null=True,
+ blank=True,
+ )
+
+ def __str__(self):
+ return self.app.label if self.app else "Based on URL"
diff --git a/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_user_guide_link_plugin.html b/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_user_guide_link_plugin.html
new file mode 100644
index 0000000000..44458d25b0
--- /dev/null
+++ b/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_user_guide_link_plugin.html
@@ -0,0 +1,16 @@
+{# @var user_guide_link #}
+
+{% if user_guide_link or is_editing_text %}
+ User Guide
+{% else %}
+ User guide not found.
+{% endif %}
diff --git a/designsafe/settings/common_settings.py b/designsafe/settings/common_settings.py
index 1f41db5dd8..79f411b8e5 100644
--- a/designsafe/settings/common_settings.py
+++ b/designsafe/settings/common_settings.py
@@ -327,7 +327,8 @@
'ResponsiveEmbedPlugin',
'AppCategoryListing',
'RelatedApps',
- 'AppVariants'
+ 'AppVariants',
+ 'AppUserGuideLink',
)
}
CMSPLUGIN_CASCADE_PLUGINS = [