{{variant.description}}
+ {% if listing.count > forloop.counter %} ++ {% endif %} +
diff --git a/designsafe/apps/workspace/admin.py b/designsafe/apps/workspace/admin.py
index 4c48a5386c..125fdbbed3 100644
--- a/designsafe/apps/workspace/admin.py
+++ b/designsafe/apps/workspace/admin.py
@@ -41,6 +41,7 @@ def get_fieldsets(self, request, obj=None):
{
"fields": (
"label",
+ "description",
"enabled",
)
},
diff --git a/designsafe/apps/workspace/cms_plugins.py b/designsafe/apps/workspace/cms_plugins.py
new file mode 100644
index 0000000000..9d344e56fd
--- /dev/null
+++ b/designsafe/apps/workspace/cms_plugins.py
@@ -0,0 +1,105 @@
+"""CMS plugins for Tools & Applications pages."""
+
+import logging
+from cms.plugin_base import CMSPluginBase
+from cms.plugin_pool import plugin_pool
+from designsafe.apps.workspace.models.app_entries import (
+ AppListingEntry,
+)
+from designsafe.apps.workspace.models.app_cms_plugins import (
+ AppCategoryListingPlugin,
+ RelatedAppsPlugin,
+ AppVariantsPlugin,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class AppCategoryListing(CMSPluginBase):
+ """CMS plugin to render the list of apps for a given category."""
+
+ model = AppCategoryListingPlugin
+ name = "App Category Listing"
+ module = "Tools & Applications"
+ render_template = "designsafe/apps/workspace/app_listing_plugin.html"
+ cache = False
+
+ def render(self, context, instance, placeholder):
+ context = super().render(context, instance, placeholder)
+ listing_entries = AppListingEntry.objects.filter(
+ category=instance.app_category, enabled=True
+ )
+ serialized_listing = [
+ {
+ "label": entry.label,
+ "icon": entry.icon,
+ "description": entry.description,
+ "tags": [tag.name for tag in entry.tags.all()],
+ "is_popular": entry.is_popular,
+ "is_simcenter": entry.is_simcenter,
+ "license_type": (
+ "Open Source" if entry.license_type == "OS" else "Licensed"
+ ),
+ "href": entry.href,
+ }
+ for entry in listing_entries
+ ]
+ context["listing"] = serialized_listing
+ return context
+
+
+plugin_pool.register_plugin(AppCategoryListing)
+
+
+class RelatedApps(CMSPluginBase):
+ """CMS plugin to render related apps."""
+
+ model = RelatedAppsPlugin
+ name = "Related Apps"
+ module = "Tools & Applications"
+ render_template = "designsafe/apps/workspace/related_apps_plugin.html"
+ cache = False
+
+ def render(self, context, instance: AppListingEntry, placeholder):
+ context = super().render(context, instance, placeholder)
+ listing_entries = instance.app.related_apps.filter(enabled=True)
+ serialized_listing = [
+ {
+ "label": entry.label,
+ "icon": entry.icon,
+ "description": entry.description,
+ "tags": [tag.name for tag in entry.tags.all()],
+ "is_popular": entry.is_popular,
+ "is_simcenter": entry.is_simcenter,
+ "license_type": (
+ "Open Source" if entry.license_type == "OS" else "Licensed"
+ ),
+ "href": entry.href,
+ }
+ for entry in listing_entries
+ ]
+ context["listing"] = serialized_listing
+ return context
+
+
+plugin_pool.register_plugin(RelatedApps)
+
+
+class AppVariants(CMSPluginBase):
+ """CMS plugin to render an apps versions/variants."""
+
+ model = AppVariantsPlugin
+ name = "App Version Selection"
+ module = "Tools & Applications"
+ render_template = "designsafe/apps/workspace/app_variant_plugin.html"
+ cache = False
+
+ def render(self, context, instance: AppListingEntry, placeholder):
+ context = super().render(context, instance, placeholder)
+ app_variants = instance.app.appvariant_set.filter(enabled=True)
+ context["listing"] = app_variants
+
+ return context
+
+
+plugin_pool.register_plugin(AppVariants)
diff --git a/designsafe/apps/workspace/migrations/0005_appcategorylistingplugin.py b/designsafe/apps/workspace/migrations/0005_appcategorylistingplugin.py
new file mode 100644
index 0000000000..26fd527f5c
--- /dev/null
+++ b/designsafe/apps/workspace/migrations/0005_appcategorylistingplugin.py
@@ -0,0 +1,42 @@
+# Generated by Django 4.2.6 on 2024-04-02 17:54
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("cms", "0022_auto_20180620_1551"),
+ ("workspace", "0004_initial_app_categories_and_tags"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AppCategoryListingPlugin",
+ 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_category",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="workspace.apptraycategory",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("cms.cmsplugin",),
+ ),
+ ]
diff --git a/designsafe/apps/workspace/migrations/0006_appvariant_description.py b/designsafe/apps/workspace/migrations/0006_appvariant_description.py
new file mode 100644
index 0000000000..65e36af7b8
--- /dev/null
+++ b/designsafe/apps/workspace/migrations/0006_appvariant_description.py
@@ -0,0 +1,21 @@
+# Generated by Django 4.2.6 on 2024-04-02 22:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("workspace", "0005_appcategorylistingplugin"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="appvariant",
+ name="description",
+ field=models.TextField(
+ blank=True,
+ help_text="App variant description text for version overview.",
+ null=True,
+ ),
+ ),
+ ]
diff --git a/designsafe/apps/workspace/migrations/0007_relatedappsplugin.py b/designsafe/apps/workspace/migrations/0007_relatedappsplugin.py
new file mode 100644
index 0000000000..97a3607c3f
--- /dev/null
+++ b/designsafe/apps/workspace/migrations/0007_relatedappsplugin.py
@@ -0,0 +1,42 @@
+# Generated by Django 4.2.6 on 2024-04-03 15:14
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("cms", "0022_auto_20180620_1551"),
+ ("workspace", "0006_appvariant_description"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="RelatedAppsPlugin",
+ 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(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="workspace.applistingentry",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("cms.cmsplugin",),
+ ),
+ ]
diff --git a/designsafe/apps/workspace/migrations/0008_appvariantsplugin.py b/designsafe/apps/workspace/migrations/0008_appvariantsplugin.py
new file mode 100644
index 0000000000..36954953ae
--- /dev/null
+++ b/designsafe/apps/workspace/migrations/0008_appvariantsplugin.py
@@ -0,0 +1,42 @@
+# Generated by Django 4.2.6 on 2024-04-03 18:02
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("cms", "0022_auto_20180620_1551"),
+ ("workspace", "0007_relatedappsplugin"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AppVariantsPlugin",
+ 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(
+ 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
new file mode 100644
index 0000000000..2ba760254a
--- /dev/null
+++ b/designsafe/apps/workspace/models/app_cms_plugins.py
@@ -0,0 +1,37 @@
+"""Models associated with CMS plugins for Tools & Applications"""
+
+from cms.models.pluginmodel import CMSPlugin
+from django.db import models
+from designsafe.apps.workspace.models.app_entries import (
+ AppTrayCategory,
+ AppListingEntry,
+)
+
+
+class AppCategoryListingPlugin(CMSPlugin):
+ """Model for listing apps by category."""
+
+ app_category = models.ForeignKey(
+ to=AppTrayCategory, on_delete=models.deletion.CASCADE
+ )
+
+ def __str__(self):
+ return self.app_category.category
+
+
+class RelatedAppsPlugin(CMSPlugin):
+ """Model for listing related apps."""
+
+ app = models.ForeignKey(to=AppListingEntry, on_delete=models.deletion.CASCADE)
+
+ def __str__(self):
+ return self.app.label
+
+
+class AppVariantsPlugin(CMSPlugin):
+ """Model for listing related apps."""
+
+ app = models.ForeignKey(to=AppListingEntry, on_delete=models.deletion.CASCADE)
+
+ def __str__(self):
+ return self.app.label
diff --git a/designsafe/apps/workspace/models/app_entries.py b/designsafe/apps/workspace/models/app_entries.py
index 2c2cd2ddf7..21f4f06ee4 100644
--- a/designsafe/apps/workspace/models/app_entries.py
+++ b/designsafe/apps/workspace/models/app_entries.py
@@ -178,6 +178,12 @@ class AppVariant(models.Model):
blank=True,
)
+ description = models.TextField(
+ help_text="App variant description text for version overview.",
+ blank=True,
+ null=True,
+ )
+
# Tapis Apps
version = models.CharField(
help_text="The version number of the app. The app id + version denotes a unique app.",
@@ -190,6 +196,14 @@ class AppVariant(models.Model):
help_text="App variant visibility in app tray.", default=True
)
+ @property
+ def href(self):
+ """Retrieve the app's URL in the Tools & Applications space"""
+ app_href = f"/rw/workspace/applications/{self.app_id}"
+ if self.version:
+ app_href += f"?version={self.version}"
+ return app_href
+
def __str__(self):
return f"{self.bundle.label} {self.app_id} {self.version} ({'ENABLED' if self.enabled else 'DISABLED'})"
diff --git a/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_card.html b/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_card.html
new file mode 100644
index 0000000000..1e1e6f3bde
--- /dev/null
+++ b/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_card.html
@@ -0,0 +1,24 @@
+
+ {{app.description}} {{app.label}}
+
+
+ {% for tag in app.tags %}
+
+
+
+ {% if app.is_popular %}
+
+
diff --git a/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_listing_plugin.html b/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_listing_plugin.html
new file mode 100644
index 0000000000..6d0a96afb7
--- /dev/null
+++ b/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_listing_plugin.html
@@ -0,0 +1,6 @@
+
{{variant.description}}
+ {% if listing.count > forloop.counter %} +