diff --git a/.flake8 b/.flake8 index c3e40888df..3c81c28f5d 100644 --- a/.flake8 +++ b/.flake8 @@ -1,7 +1,8 @@ [flake8] # E501: line is too long. # H101: Use TODO(NAME) -ignore = E501, H101 +# W503: line break before binary operator. Ingore as black will break this rule. +ignore = E501, H101, W503 exclude = __pycache__, tests.py, migrations diff --git a/.pylintrc b/.pylintrc index 492dab158b..5a4da2b364 100644 --- a/.pylintrc +++ b/.pylintrc @@ -430,9 +430,11 @@ disable=raw-checker-failed, useless-suppression, deprecated-pragma, use-symbolic-message-instead, + line-too-long, duplicate-code, logging-fstring-interpolation + # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where diff --git a/designsafe/apps/workspace/admin.py b/designsafe/apps/workspace/admin.py index f7721229ca..b95c773de0 100644 --- a/designsafe/apps/workspace/admin.py +++ b/designsafe/apps/workspace/admin.py @@ -1,4 +1,99 @@ +"""Admin layout for Tools & Applications workspace models. +""" + from django.contrib import admin +from django.db import models +from django.forms import CheckboxSelectMultiple from designsafe.apps.workspace.models.app_descriptions import AppDescription +from designsafe.apps.workspace.models.app_entries import ( + AppListingEntry, + AppVariant, + AppTrayCategory, +) admin.site.register(AppDescription) +admin.site.register(AppTrayCategory) + + +class AppVariantInline(admin.StackedInline): + """Admin layout for app variants.""" + + extra = 0 + model = AppVariant + fk_name = "bundle" + + def get_fieldsets(self, request, obj=None): + return [ + ( + "Tapis App information", + { + "fields": ( + "app_type", + "app_id", + "version", + "license_type", + ) + }, + ), + ( + "Display information", + { + "fields": ( + "label", + "enabled", + ) + }, + ), + ( + "HTML App Body for app_type: HTML", + { + "classes": ["collapse"], + "fields": ["html"], + }, + ), + ] + + +@admin.register(AppListingEntry) +class AppTrayEntryAdmin(admin.ModelAdmin): + """Admin layout for AppTrayEntry items.""" + + class Media: + css = {"all": ("styles/cms-form-styles.css",)} + + inlines = [AppVariantInline] + + def get_fieldsets(self, request, obj=None): + default_fieldset = [ + ( + "Portal Display Options", + { + "fields": [ + "category", + "label", + "icon", + "enabled", + ], + }, + ), + ] + + cms_fieldset = [ + ( + "CMS Display Options", + { + "fields": [ + "href", + "popular", + "not_bundled", + "related_apps", + ], + }, + ), + ] + + return default_fieldset + cms_fieldset + + formfield_overrides = { + models.ManyToManyField: {"widget": CheckboxSelectMultiple}, + } diff --git a/designsafe/apps/workspace/migrations/0001_initial.py b/designsafe/apps/workspace/migrations/0001_initial.py index ce5d324624..7f3fc4f043 100644 --- a/designsafe/apps/workspace/migrations/0001_initial.py +++ b/designsafe/apps/workspace/migrations/0001_initial.py @@ -16,8 +16,8 @@ class Migration(migrations.Migration): migrations.CreateModel( name='AppDescription', fields=[ - ('appId', models.CharField(max_length=120, primary_key=True, serialize=False, unique=True)), - ('appDescription', models.TextField(help_text=b'App dropdown description text for apps that have a dropdown.')), + ('appid', models.CharField(max_length=120, primary_key=True, serialize=False, unique=True)), + ('appdescription', models.TextField(help_text=b'App dropdown description text for apps that have a dropdown.')), ], ), ] diff --git a/designsafe/apps/workspace/migrations/0002_auto_20200423_1940.py b/designsafe/apps/workspace/migrations/0002_auto_20200423_1940.py index a717cf3763..4e23933c39 100644 --- a/designsafe/apps/workspace/migrations/0002_auto_20200423_1940.py +++ b/designsafe/apps/workspace/migrations/0002_auto_20200423_1940.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): operations = [ migrations.AlterField( model_name='appdescription', - name='appDescription', + name='appdescription', field=models.TextField(help_text='App dropdown description text for apps that have a dropdown.'), ), ] diff --git a/designsafe/apps/workspace/migrations/0003_applistingentry_apptraycategory_appvariant_and_more.py b/designsafe/apps/workspace/migrations/0003_applistingentry_apptraycategory_appvariant_and_more.py new file mode 100644 index 0000000000..6994cca463 --- /dev/null +++ b/designsafe/apps/workspace/migrations/0003_applistingentry_apptraycategory_appvariant_and_more.py @@ -0,0 +1,228 @@ +# Generated by Django 4.2.6 on 2024-03-01 20:10 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("workspace", "0002_auto_20200423_1940"), + ] + + operations = [ + migrations.CreateModel( + name="AppListingEntry", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "label", + models.CharField( + help_text="The display name of this bundle in the Apps Tray. Not used if this entry is a single app ID.", + max_length=64, + ), + ), + ( + "icon", + models.CharField( + blank=True, + choices=[ + ("adcirc", "ADCIRC"), + ("ansys", "Ansys"), + ("blender", "Blender"), + ("clawpack", "Clawpack"), + ("compress", "Compress"), + ("dakota", "Dakota"), + ("extract", "Extract"), + ("hazmapper", "Hazmapper"), + ("jupyter", "Jupyter"), + ("ls-dyna", "LS-DYNA"), + ("matlab", "MATLAB"), + ("ngl", "NGL"), + ("openfoam", "OpenFOAM"), + ("opensees", "OpenSees"), + ("paraview", "Paraview"), + ("qgis", "QGIS"), + ("rwhale", "rWHALE"), + ("stko", "STKO"), + ("swbatch", "swbatch"), + ("visit", "VisIt"), + ], + help_text="The icon associated with this app.", + max_length=64, + ), + ), + ( + "enabled", + models.BooleanField( + default=True, help_text="App bundle visibility in app tray." + ), + ), + ( + "popular", + models.BooleanField( + default=False, + help_text="Mark as popular on tools & apps overview.", + ), + ), + ( + "not_bundled", + models.BooleanField( + default=False, + help_text="Select if this entry represents a single app ID and not a bundle.", + ), + ), + ( + "href", + models.CharField( + blank=True, + help_text="Link to overview page for this app.", + max_length=128, + ), + ), + ], + options={ + "verbose_name_plural": "App Listing Entries", + }, + ), + migrations.CreateModel( + name="AppTrayCategory", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "category", + models.CharField( + help_text="A category for the app tray.", + max_length=64, + unique=True, + ), + ), + ( + "priority", + models.IntegerField( + default=0, + help_text="Category priority, where higher number tabs appear before lower ones.", + ), + ), + ], + options={ + "verbose_name_plural": "App Categories", + }, + ), + migrations.CreateModel( + name="AppVariant", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "app_id", + models.CharField( + help_text="The id of this app or app bundle. The id appears in the unique url path to the app.", + max_length=64, + ), + ), + ( + "app_type", + models.CharField( + choices=[ + ("tapis", "Tapis App"), + ("html", "HTML or External app"), + ], + default="tapis", + help_text="Application type.", + max_length=10, + ), + ), + ( + "label", + models.CharField( + blank=True, + help_text="The display name of this app in the Apps Tray. If not defined, uses notes.label from app definition.", + max_length=64, + ), + ), + ( + "license_type", + models.CharField( + choices=[("OS", "Open Source"), ("LS", "Licensed")], + default="OS", + help_text="License Type.", + max_length=2, + ), + ), + ( + "html", + models.TextField( + blank=True, + default="", + help_text="HTML definition to display when app is loaded.", + ), + ), + ( + "version", + models.CharField( + blank=True, + default="", + help_text="The version number of the app. The app id + version denotes a unique app.", + max_length=64, + ), + ), + ( + "enabled", + models.BooleanField( + default=True, help_text="App variant visibility in app tray." + ), + ), + ( + "bundle", + models.ForeignKey( + blank=True, + help_text="Bundle that the app belongs to.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="workspace.applistingentry", + ), + ), + ], + ), + migrations.AddField( + model_name="applistingentry", + name="category", + field=models.ForeignKey( + help_text="The category for this app entry.", + on_delete=django.db.models.deletion.CASCADE, + to="workspace.apptraycategory", + ), + ), + migrations.AddField( + model_name="applistingentry", + name="related_apps", + field=models.ManyToManyField( + blank=True, + help_text="Related apps that will display on app overview page.", + to="workspace.applistingentry", + ), + ), + ] diff --git a/designsafe/apps/workspace/migrations/0004_initial_app_categories.py b/designsafe/apps/workspace/migrations/0004_initial_app_categories.py new file mode 100644 index 0000000000..72667dbcb7 --- /dev/null +++ b/designsafe/apps/workspace/migrations/0004_initial_app_categories.py @@ -0,0 +1,35 @@ +from django.db import migrations + + +APP_CATEGORIES = [ + ("5", "Simulation"), + ("4", "Sim Center Tools"), + ("3", "Visualization"), + ("2", "Analysis"), + ("1", "Hazard Apps"), + ("0", "Utilities"), +] + + +def populate_categories(apps, schema_editor): + AppTrayCategory = apps.get_model("workspace", "AppTrayCategory") + for priority, category in APP_CATEGORIES: + AppTrayCategory.objects.create( + priority=priority, + category=category, + ) + + +def reverse_func(apps, schema_editor): + AppTrayCategory = apps.get_model("workspace", "AppTrayCategory") + AppTrayCategory.objects.all().delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("workspace", "0003_applistingentry_apptraycategory_appvariant_and_more"), + ] + + operations = [ + migrations.RunPython(populate_categories, reverse_func), + ] diff --git a/designsafe/apps/workspace/models/__init__.py b/designsafe/apps/workspace/models/__init__.py index bed7aa34d0..9402842dc2 100644 --- a/designsafe/apps/workspace/models/__init__.py +++ b/designsafe/apps/workspace/models/__init__.py @@ -1,4 +1,4 @@ -from django.db import models + from django.dispatch import receiver from designsafe.apps.signals.signals import generic_event from designsafe.apps.notifications.models import Notification diff --git a/designsafe/apps/workspace/models/app_entries.py b/designsafe/apps/workspace/models/app_entries.py new file mode 100644 index 0000000000..ec3d559593 --- /dev/null +++ b/designsafe/apps/workspace/models/app_entries.py @@ -0,0 +1,173 @@ +"""Models for the Tools & Applications workspace. +""" + +from django.db import models + +APP_ICONS = [ + ("adcirc", "ADCIRC"), + ("ansys", "Ansys"), + ("blender", "Blender"), + ("clawpack", "Clawpack"), + ("compress", "Compress"), + ("dakota", "Dakota"), + ("extract", "Extract"), + ("hazmapper", "Hazmapper"), + ("jupyter", "Jupyter"), + ("ls-dyna", "LS-DYNA"), + ("matlab", "MATLAB"), + ("ngl", "NGL"), + ("openfoam", "OpenFOAM"), + ("opensees", "OpenSees"), + ("paraview", "Paraview"), + ("qgis", "QGIS"), + ("rwhale", "rWHALE"), + ("stko", "STKO"), + ("swbatch", "swbatch"), + ("visit", "VisIt"), +] + +LICENSE_TYPES = [("OS", "Open Source"), ("LS", "Licensed")] + + +class AppTrayCategory(models.Model): + """Categories in which AppTrayEntry items are organized.""" + + category = models.CharField( + help_text="A category for the app tray.", max_length=64, unique=True + ) + priority = models.IntegerField( + help_text="Category priority, where higher number tabs appear before lower ones.", + default=0, + ) + + def __str__(self): + return f"{self.category}" + + class Meta: + verbose_name_plural = "App Categories" + + +class AppListingEntry(models.Model): + """Entries for the Tools & Applications workspace, including Tapis, HTML, and Bundled apps. + + ENTRY_TYPES: + A Tapis App is corresponds to a valid app id registered in the Tapis tenant. + + An HTML or External app is typically an HTML body with a link to an external resource. + + An App Listing Entry (bundle) is both: + 1) a card on the apps CMS layout page that links to an overview page, and + 2) a binned app in the apps workspace, where each app variant is a dropdown item. + + Note: If an App Listing Entry has only one variant, it will be treated as a single app, and not a bundle. + """ + + # Basic display options + category = models.ForeignKey( + AppTrayCategory, + help_text="The category for this app entry.", + on_delete=models.CASCADE, + ) + label = models.CharField( + help_text="The display name of this bundle in the Apps Tray. Not used if this entry is a single app ID.", + max_length=64, + ) + icon = models.CharField( + help_text="The icon associated with this app.", + max_length=64, + choices=APP_ICONS, + blank=True, + ) + enabled = models.BooleanField( + help_text="App bundle visibility in app tray.", default=True + ) + + # CMS Display Options + related_apps = models.ManyToManyField( + "self", + help_text="Related apps that will display on app overview page.", + blank=True, + ) + popular = models.BooleanField( + help_text="Mark as popular on tools & apps overview.", default=False + ) + + not_bundled = models.BooleanField( + help_text="Select if this entry represents a single app ID and not a bundle.", + default=False, + ) + + href = models.CharField( + help_text="Link to overview page for this app.", max_length=128, blank=True + ) + + def __str__(self): + return ( + f"{self.category} " + f"{self.label + ': ' if self.label else ''}" + f" ({'ENABLED' if self.enabled else 'DISABLED'})" + ) + + class Meta: + verbose_name_plural = "App Listing Entries" + + +class AppVariant(models.Model): + """Model to represent a variant of an app, e.g. a software version or execution environment""" + + APP_TYPES = [ + ("tapis", "Tapis App"), + ("html", "HTML or External app"), + ] + + app_id = models.CharField( + help_text="The id of this app or app bundle. The id appears in the unique url path to the app.", + max_length=64, + ) + + app_type = models.CharField( + help_text="Application type.", + max_length=10, + choices=APP_TYPES, + default="tapis", + ) + + label = models.CharField( + help_text="The display name of this app in the Apps Tray. If not defined, uses notes.label from app definition.", + max_length=64, + blank=True, + ) + + license_type = models.CharField( + max_length=2, choices=LICENSE_TYPES, help_text="License Type.", default="OS" + ) + + bundle = models.ForeignKey( + AppListingEntry, + help_text="Bundle that the app belongs to.", + on_delete=models.CASCADE, + blank=True, + null=True, + ) + + # HTML Apps + html = models.TextField( + help_text="HTML definition to display when app is loaded.", + default="", + blank=True, + ) + + # Tapis Apps + version = models.CharField( + help_text="The version number of the app. The app id + version denotes a unique app.", + default="", + max_length=64, + blank=True, + ) + + enabled = models.BooleanField( + help_text="App variant visibility in app tray.", default=True + ) + + def __str__(self): + return f"{self.bundle.label} {self.app_id} {self.version} ({'ENABLED' if self.enabled else 'DISABLED'})" diff --git a/designsafe/static/styles/cms-form-styles.css b/designsafe/static/styles/cms-form-styles.css new file mode 100644 index 0000000000..f8800a15f6 --- /dev/null +++ b/designsafe/static/styles/cms-form-styles.css @@ -0,0 +1,4 @@ +.djangocms-admin-style .inline-deletelink { + text-indent: 0px !important; + width: fit-content !important; + }