diff --git a/api/openapi/public_apps_api.py b/api/openapi/public_apps_api.py index 7e198ffb2..7a5177cda 100644 --- a/api/openapi/public_apps_api.py +++ b/api/openapi/public_apps_api.py @@ -17,26 +17,29 @@ class PublicAppsAPI(viewsets.ReadOnlyModelViewSet): The Public Apps API with read-only methods to get public apps information. """ - # TODO: refactor. Rename list to list_apps, because it is a reserved word in python. - def list(self, request): + def list_apps(self, request): """ This endpoint gets a list of public apps. - :returns list: A list of app information. + :returns list: A list of app information, ordered by date created from the most recent to the oldest. """ logger.info("PublicAppsAPI. Entered list method.") logger.info("Requested API version %s", request.version) list_apps = [] + list_apps_dict = {} # TODO: MAKE SURE THAT THIS IS FILTERED BASED ON ACCESS for model_class in APP_REGISTRY.iter_orm_models(): # Loop over all models, and check if they have the access and description field if hasattr(model_class, "description") and hasattr(model_class, "access"): - queryset = ( - model_class.objects.filter(~Q(app_status__status="Deleted"), access="public") - .order_by("-updated_on")[:8] - .values("id", "name", "app_id", "url", "description", "updated_on", "app_status") + queryset = model_class.objects.filter(~Q(app_status__status="Deleted"), access="public").values( + "id", "name", "app_id", "url", "description", "created_on", "updated_on", "app_status" ) - list_apps.extend(list(queryset)) + # using a dictionary to avoid duplicates for shiny apps + for item in queryset: + list_apps_dict[item["id"]] = item + + # Order the combined list by "created_on" + list_apps = sorted(list_apps_dict.values(), key=lambda x: x["created_on"], reverse=True) for app in list_apps: app["app_type"] = Apps.objects.get(id=app["app_id"]).name @@ -45,6 +48,9 @@ def list(self, request): # Add the previous url key located at app.table_field.url to support clients using the previous schema app["table_field"] = {"url": app["url"]} + # Remove misleading app_id from the final output because it only refers to the app type + del app["app_id"] + data = {"data": list_apps} logger.info("LIST: %s", data) return JsonResponse(data) @@ -65,7 +71,7 @@ def retrieve(self, request, app_slug=None, pk=None): try: queryset = model_class.objects.all().values( - "id", "name", "app_id", "url", "description", "updated_on", "access", "app_status" + "id", "name", "app_id", "url", "description", "created_on", "updated_on", "access", "app_status" ) logger.info("Queryset: %s", queryset) except FieldError as e: diff --git a/api/openapi/urls.py b/api/openapi/urls.py index 18cd55ca3..8213427aa 100644 --- a/api/openapi/urls.py +++ b/api/openapi/urls.py @@ -20,7 +20,7 @@ path("system-version", get_system_version), path("api-info", APIInfo.as_view({"get": "get_api_info"})), # The Apps API - path("public-apps", PublicAppsAPI.as_view({"get": "list"})), + path("public-apps", PublicAppsAPI.as_view({"get": "list_apps"})), path("public-apps//", PublicAppsAPI.as_view({"get": "retrieve"})), # Supplementary lookups API path( diff --git a/api/tests/test_openapi_public_apps.py b/api/tests/test_openapi_public_apps.py index 11cdcef11..dbde53fdb 100644 --- a/api/tests/test_openapi_public_apps.py +++ b/api/tests/test_openapi_public_apps.py @@ -59,7 +59,6 @@ def test_public_apps_list(self): self.assertEqual(app["id"], self.app_instance.id) self.assertIsNotNone(app["name"]) self.assertEqual(app["name"], self.app_instance.name) - self.assertTrue(app["app_id"] > 0) self.assertEqual(app["description"], self.app_instance.description) updated_on = datetime.fromisoformat(app["updated_on"][:-1]) self.assertEqual(datetime.date(updated_on), datetime.date(self.app_instance.updated_on)) diff --git a/apps/admin.py b/apps/admin.py index 48b06aa4e..7a8416a65 100644 --- a/apps/admin.py +++ b/apps/admin.py @@ -14,10 +14,12 @@ CustomAppInstance, DashInstance, FilemanagerInstance, + GradioInstance, JupyterInstance, NetpolicyInstance, RStudioInstance, ShinyInstance, + StreamlitInstance, Subdomain, TissuumapsInstance, VolumeInstance, @@ -52,7 +54,7 @@ class AppsAdmin(admin.ModelAdmin): class BaseAppAdmin(admin.ModelAdmin): list_display = ("name", "display_owner", "display_project", "display_status", "display_subdomain", "chart") - readonly_fields = ("id",) + readonly_fields = ("id", "created_on") list_filter = ["owner", "project", "app_status__status", "chart"] actions = ["redeploy_apps", "deploy_resources", "delete_resources"] @@ -230,6 +232,26 @@ class FilemanagerInstanceAdmin(BaseAppAdmin): ) +@admin.register(GradioInstance) +class GradioInstanceAdmin(BaseAppAdmin): + list_display = BaseAppAdmin.list_display + ( + "display_volumes", + "image", + "port", + "user_id", + ) + + +@admin.register(StreamlitInstance) +class StreamlitInstanceAdmin(BaseAppAdmin): + list_display = BaseAppAdmin.list_display + ( + "display_volumes", + "image", + "port", + "user_id", + ) + + admin.site.register(Subdomain) admin.site.register(AppCategories) admin.site.register(AppStatus, AppStatusAdmin) diff --git a/apps/app_registry.py b/apps/app_registry.py index 4721fdfd4..cc6ef903e 100644 --- a/apps/app_registry.py +++ b/apps/app_registry.py @@ -2,10 +2,12 @@ CustomAppForm, DashForm, FilemanagerForm, + GradioForm, JupyterForm, NetpolicyForm, RStudioForm, ShinyForm, + StreamlitForm, TissuumapsForm, VolumeForm, VSCodeForm, @@ -14,10 +16,12 @@ CustomAppInstance, DashInstance, FilemanagerInstance, + GradioInstance, JupyterInstance, NetpolicyInstance, RStudioInstance, ShinyInstance, + StreamlitInstance, TissuumapsInstance, VolumeInstance, VSCodeInstance, @@ -37,3 +41,5 @@ APP_REGISTRY.register("shinyproxyapp", ModelFormTuple(ShinyInstance, ShinyForm)) APP_REGISTRY.register("tissuumaps", ModelFormTuple(TissuumapsInstance, TissuumapsForm)) APP_REGISTRY.register("filemanager", ModelFormTuple(FilemanagerInstance, FilemanagerForm)) +APP_REGISTRY.register("gradio", ModelFormTuple(GradioInstance, GradioForm)) +APP_REGISTRY.register("streamlit", ModelFormTuple(StreamlitInstance, StreamlitForm)) diff --git a/apps/constants.py b/apps/constants.py index 149b2f67a..07b5edd2e 100644 --- a/apps/constants.py +++ b/apps/constants.py @@ -2,6 +2,8 @@ "name": "Display name for the application. This is the name visible on the app catalogue if the app is public", "description": "Provide a detailed description of your app. " "This will be the description visible in the app catalogue if the app is public.", + "tags": "Keywords relevant to your app. These will be displayed along with the description " + "in the app catalogue if the app is public.", "subdomain": "Valid subdomain names have minimum length of 3 characters and may contain lower case letters a-z " "and numbers 0-9 and a hyphen '-'. The hyphen should not be at the start or end of the subdomain.", "access": "Public apps will be displayed on the app catalogue and can be accessed by anyone that has the link to " diff --git a/apps/forms/__init__.py b/apps/forms/__init__.py index 8b1ace01e..3bb0106d1 100644 --- a/apps/forms/__init__.py +++ b/apps/forms/__init__.py @@ -3,10 +3,12 @@ from .custom import CustomAppForm from .dash import DashForm from .filemanager import FilemanagerForm +from .gradio import GradioForm from .jupyter import JupyterForm from .netpolicy import NetpolicyForm from .rstudio import RStudioForm from .shiny import ShinyForm +from .streamlit import StreamlitForm from .tissuumaps import TissuumapsForm from .volumes import VolumeForm from .vscode import VSCodeForm diff --git a/apps/forms/custom.py b/apps/forms/custom.py index f51e0a6fb..748a2a9b0 100644 --- a/apps/forms/custom.py +++ b/apps/forms/custom.py @@ -26,7 +26,7 @@ def _setup_form_helper(self): body = Div( SRVCommonDivField("name", placeholder="Name your app"), SRVCommonDivField("description", rows=3, placeholder="Provide a detailed description of your app"), - Field("tags"), + SRVCommonDivField("tags"), SRVCommonDivField("subdomain", placeholder="Enter a subdomain or leave blank for a random one."), Field("volume"), SRVCommonDivField("path", placeholder="/home/..."), diff --git a/apps/forms/dash.py b/apps/forms/dash.py index 03802fdb4..e552e158c 100644 --- a/apps/forms/dash.py +++ b/apps/forms/dash.py @@ -23,7 +23,7 @@ def _setup_form_helper(self): body = Div( SRVCommonDivField("name", placeholder="Name your app"), SRVCommonDivField("description", rows="3", placeholder="Provide a detailed description of your app"), - Field("tags"), + SRVCommonDivField("tags"), SRVCommonDivField( "subdomain", placeholder="Enter a subdomain or leave blank for a random one", spinner=True ), @@ -35,22 +35,12 @@ def _setup_form_helper(self): ), SRVCommonDivField("source_code_url", placeholder="Provide a link to the public source code"), SRVCommonDivField("port", placeholder="8000"), - SRVCommonDivField("image", placeholder="registry/repository/image:tag"), + SRVCommonDivField("image", placeholder="e.g. docker.io/username/image-name:image-tag"), css_class="card-body", ) self.helper.layout = Layout(body, self.footer) - def clean(self): - cleaned_data = super().clean() - access = cleaned_data.get("access") - source_code_url = cleaned_data.get("source_code_url") - - if access == "public" and not source_code_url: - self.add_error("source_code_url", "Source is required when access is public.") - - return cleaned_data - class Meta: model = DashInstance fields = [ diff --git a/apps/forms/gradio.py b/apps/forms/gradio.py new file mode 100644 index 000000000..d88a83eef --- /dev/null +++ b/apps/forms/gradio.py @@ -0,0 +1,88 @@ +from crispy_forms.layout import Div, Field, Layout +from django import forms + +from apps.forms.base import AppBaseForm +from apps.forms.field.common import SRVCommonDivField +from apps.models import GradioInstance +from projects.models import Flavor + +__all__ = ["GradioForm"] + + +class GradioForm(AppBaseForm): + flavor = forms.ModelChoiceField(queryset=Flavor.objects.none(), required=False, empty_label=None) + port = forms.IntegerField(min_value=3000, max_value=9999, required=True) + image = forms.CharField(max_length=255, required=True) + path = forms.CharField(max_length=255, required=False) + + def _setup_form_fields(self): + # Handle Volume field + super()._setup_form_fields() + self.fields["volume"].initial = None + + def _setup_form_helper(self): + super()._setup_form_helper() + + body = Div( + SRVCommonDivField("name", placeholder="Name your app"), + SRVCommonDivField("description", rows=3, placeholder="Provide a detailed description of your app"), + SRVCommonDivField("tags"), + SRVCommonDivField("subdomain", placeholder="Enter a subdomain or leave blank for a random one."), + Field("volume"), + SRVCommonDivField("path", placeholder="/home/..."), + SRVCommonDivField("flavor"), + SRVCommonDivField("access"), + SRVCommonDivField("source_code_url", placeholder="Provide a link to the public source code"), + SRVCommonDivField( + "note_on_linkonly_privacy", + rows=1, + placeholder="Describe why you want to make the app accessible only via a link", + ), + SRVCommonDivField("port", placeholder="7860"), + SRVCommonDivField("image", placeholder="e.g. docker.io/username/image-name:image-tag"), + css_class="card-body", + ) + self.helper.layout = Layout(body, self.footer) + + def clean_path(self): + cleaned_data = super().clean() + + path = cleaned_data.get("path", None) + volume = cleaned_data.get("volume", None) + + if volume and not path: + self.add_error("path", "Path is required when volume is selected.") + + if path and not volume: + self.add_error("path", "Warning, you have provided a path, but not selected a volume.") + + if path: + # If new path matches current path, it is valid. + if self.instance and getattr(self.instance, "path", None) == path: + return path + # Verify that path starts with "/home" + path = path.strip().rstrip("/").lower().replace(" ", "") + if not path.startswith("/home"): + self.add_error("path", 'Path must start with "/home"') + + return path + + class Meta: + model = GradioInstance + fields = [ + "name", + "description", + "volume", + "path", + "flavor", + "access", + "note_on_linkonly_privacy", + "source_code_url", + "port", + "image", + "tags", + ] + labels = { + "note_on_linkonly_privacy": "Reason for choosing the link only option", + "tags": "Keywords", + } diff --git a/apps/forms/shiny.py b/apps/forms/shiny.py index 215d745c3..b855711be 100644 --- a/apps/forms/shiny.py +++ b/apps/forms/shiny.py @@ -53,7 +53,7 @@ def _setup_form_helper(self): ), SRVCommonDivField("source_code_url", placeholder="Provide a link to the public source code"), SRVCommonDivField("port", placeholder="3838"), - SRVCommonDivField("image", placeholder="registry/repository/image:tag"), + SRVCommonDivField("image", placeholder="e.g. docker.io/username/image-name:image-tag"), Accordion( AccordionGroup( "Advanced settings", @@ -81,16 +81,6 @@ def clean_shiny_site_dir(self): return shiny_site_dir - def clean(self): - cleaned_data = super().clean() - access = cleaned_data.get("access", None) - source_code_url = cleaned_data.get("source_code_url", None) - - if access == "public" and not source_code_url: - self.add_error("source_code_url", "Source is required when access is public.") - - return cleaned_data - class Meta: model = ShinyInstance fields = [ diff --git a/apps/forms/streamlit.py b/apps/forms/streamlit.py new file mode 100644 index 000000000..2ef1c38a6 --- /dev/null +++ b/apps/forms/streamlit.py @@ -0,0 +1,88 @@ +from crispy_forms.layout import Div, Field, Layout +from django import forms + +from apps.forms.base import AppBaseForm +from apps.forms.field.common import SRVCommonDivField +from apps.models import GradioInstance, StreamlitInstance +from projects.models import Flavor + +__all__ = ["StreamlitForm"] + + +class StreamlitForm(AppBaseForm): + flavor = forms.ModelChoiceField(queryset=Flavor.objects.none(), required=False, empty_label=None) + port = forms.IntegerField(min_value=3000, max_value=9999, required=True) + image = forms.CharField(max_length=255, required=True) + path = forms.CharField(max_length=255, required=False) + + def _setup_form_fields(self): + # Handle Volume field + super()._setup_form_fields() + self.fields["volume"].initial = None + + def _setup_form_helper(self): + super()._setup_form_helper() + + body = Div( + SRVCommonDivField("name", placeholder="Name your app"), + SRVCommonDivField("description", rows=3, placeholder="Provide a detailed description of your app"), + SRVCommonDivField("tags"), + SRVCommonDivField("subdomain", placeholder="Enter a subdomain or leave blank for a random one."), + Field("volume"), + SRVCommonDivField("path", placeholder="/home/..."), + SRVCommonDivField("flavor"), + SRVCommonDivField("access"), + SRVCommonDivField("source_code_url", placeholder="Provide a link to the public source code"), + SRVCommonDivField( + "note_on_linkonly_privacy", + rows=1, + placeholder="Describe why you want to make the app accessible only via a link", + ), + SRVCommonDivField("port", placeholder="8501"), + SRVCommonDivField("image", placeholder="e.g. docker.io/username/image-name:image-tag"), + css_class="card-body", + ) + self.helper.layout = Layout(body, self.footer) + + def clean_path(self): + cleaned_data = super().clean() + + path = cleaned_data.get("path", None) + volume = cleaned_data.get("volume", None) + + if volume and not path: + self.add_error("path", "Path is required when volume is selected.") + + if path and not volume: + self.add_error("path", "Warning, you have provided a path, but not selected a volume.") + + if path: + # If new path matches current path, it is valid. + if self.instance and getattr(self.instance, "path", None) == path: + return path + # Verify that path starts with "/home" + path = path.strip().rstrip("/").lower().replace(" ", "") + if not path.startswith("/home"): + self.add_error("path", 'Path must start with "/home"') + + return path + + class Meta: + model = StreamlitInstance + fields = [ + "name", + "description", + "volume", + "path", + "flavor", + "access", + "note_on_linkonly_privacy", + "source_code_url", + "port", + "image", + "tags", + ] + labels = { + "note_on_linkonly_privacy": "Reason for choosing the link only option", + "tags": "Keywords", + } diff --git a/apps/forms/tissuumaps.py b/apps/forms/tissuumaps.py index 3dd1ebaaa..ea262b449 100644 --- a/apps/forms/tissuumaps.py +++ b/apps/forms/tissuumaps.py @@ -20,7 +20,7 @@ def _setup_form_helper(self): body = Div( SRVCommonDivField("name", placeholder="Name your app"), SRVCommonDivField("description", rows="3", placeholder="Provide a detailed description of your app"), - Field("tags"), + SRVCommonDivField("tags"), SRVCommonDivField( "subdomain", placeholder="Enter a subdomain or leave blank for a random one", spinner=True ), diff --git a/apps/helpers.py b/apps/helpers.py index 775d54541..f9b7b3630 100644 --- a/apps/helpers.py +++ b/apps/helpers.py @@ -261,7 +261,7 @@ def create_instance_from_form(form, project, app_slug, app_id=None): do_deploy = True else: # Only re-deploy existing apps if one of the following fields was changed: - redeployment_fields = ["subdomain", "volume", "path", "flavor", "port", "image", "access"] + redeployment_fields = ["subdomain", "volume", "path", "flavor", "port", "image", "access", "shiny_site_dir"] logger.debug(f"An existing app has changed. The changed form fields: {form.changed_data}") # Because not all forms contain all fields, we check if the supposedly changed field diff --git a/apps/migrations/0013_alter_customappinstance_tags_alter_dashinstance_tags_and_more.py b/apps/migrations/0013_alter_customappinstance_tags_alter_dashinstance_tags_and_more.py new file mode 100644 index 000000000..104e6aeda --- /dev/null +++ b/apps/migrations/0013_alter_customappinstance_tags_alter_dashinstance_tags_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 5.1.1 on 2024-09-27 12:11 + +import tagulous.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("apps", "0012_rstudioinstance_environment"), + ] + + operations = [ + migrations.AlterField( + model_name="customappinstance", + name="tags", + field=tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + force_lowercase=True, + help_text="Add keywords to help categorize your app", + to="apps.tagulous_customappinstance_tags", + ), + ), + migrations.AlterField( + model_name="dashinstance", + name="tags", + field=tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + force_lowercase=True, + help_text="Add keywords to help categorize your app", + to="apps.tagulous_dashinstance_tags", + ), + ), + migrations.AlterField( + model_name="shinyinstance", + name="shiny_site_dir", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AlterField( + model_name="shinyinstance", + name="tags", + field=tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + force_lowercase=True, + help_text="Add keywords to help categorize your app", + to="apps.tagulous_shinyinstance_tags", + ), + ), + migrations.AlterField( + model_name="tissuumapsinstance", + name="tags", + field=tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + force_lowercase=True, + help_text="Add keywords to help categorize your app", + to="apps.tagulous_tissuumapsinstance_tags", + ), + ), + ] diff --git a/apps/migrations/0014_alter_customappinstance_path.py b/apps/migrations/0014_alter_customappinstance_path.py new file mode 100644 index 000000000..9656559c4 --- /dev/null +++ b/apps/migrations/0014_alter_customappinstance_path.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.1 on 2024-09-27 12:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("apps", "0013_alter_customappinstance_tags_alter_dashinstance_tags_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="customappinstance", + name="path", + field=models.CharField(blank=True, default="/", max_length=255), + ), + ] diff --git a/apps/migrations/0015_tagulous_abstractcustomappinstance_tags_and_more.py b/apps/migrations/0015_tagulous_abstractcustomappinstance_tags_and_more.py new file mode 100644 index 000000000..1894ab6e1 --- /dev/null +++ b/apps/migrations/0015_tagulous_abstractcustomappinstance_tags_and_more.py @@ -0,0 +1,129 @@ +# Generated by Django 5.1.1 on 2024-10-17 16:20 + +import django.db.models.deletion +import tagulous.models.fields +import tagulous.models.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("apps", "0014_alter_customappinstance_path"), + ("portal", "0002_eventsobject"), + ] + + operations = [ + migrations.CreateModel( + name="Tagulous_AbstractCustomAppInstance_tags", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255, unique=True)), + ("slug", models.SlugField()), + ( + "count", + models.IntegerField(default=0, help_text="Internal counter of how many times this tag is in use"), + ), + ( + "protected", + models.BooleanField(default=False, help_text="Will not be deleted when the count reaches 0"), + ), + ], + options={ + "ordering": ("name",), + "abstract": False, + "unique_together": {("slug",)}, + }, + bases=(tagulous.models.models.BaseTagModel, models.Model), + ), + migrations.CreateModel( + name="Tagulous_GradioInstance_tags", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255, unique=True)), + ("slug", models.SlugField()), + ( + "count", + models.IntegerField(default=0, help_text="Internal counter of how many times this tag is in use"), + ), + ( + "protected", + models.BooleanField(default=False, help_text="Will not be deleted when the count reaches 0"), + ), + ], + options={ + "ordering": ("name",), + "abstract": False, + "unique_together": {("slug",)}, + }, + bases=(tagulous.models.models.BaseTagModel, models.Model), + ), + migrations.CreateModel( + name="GradioInstance", + fields=[ + ( + "baseappinstance_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="apps.baseappinstance", + ), + ), + ( + "logs_enabled", + models.BooleanField( + default=True, help_text="Indicates whether logs are activated and visible to the user" + ), + ), + ("note_on_linkonly_privacy", models.TextField(blank=True, default="", null=True)), + ("source_code_url", models.URLField(blank=True, null=True)), + ("description", models.TextField(blank=True, default="", null=True)), + ( + "access", + models.CharField( + choices=[ + ("project", "Project"), + ("private", "Private"), + ("public", "Public"), + ("link", "Link"), + ], + default="project", + max_length=20, + ), + ), + ("image", models.CharField(max_length=255)), + ("path", models.CharField(blank=True, default="/", max_length=255)), + ("user_id", models.IntegerField(default=1000)), + ("port", models.IntegerField(default=7860)), + ("collections", models.ManyToManyField(blank=True, related_name="%(class)s", to="portal.collection")), + ( + "volume", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s", + to="apps.volumeinstance", + ), + ), + ( + "tags", + tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + force_lowercase=True, + help_text="Add keywords to help categorize your app", + to="apps.tagulous_gradioinstance_tags", + ), + ), + ], + options={ + "verbose_name": "Gradio App Instance", + "verbose_name_plural": "Gradio App Instances", + "permissions": [("can_access_app", "Can access app service")], + }, + bases=("apps.baseappinstance", models.Model), + ), + ] diff --git a/apps/migrations/0016_tagulous_streamlitinstance_tags_streamlitinstance.py b/apps/migrations/0016_tagulous_streamlitinstance_tags_streamlitinstance.py new file mode 100644 index 000000000..6aaa0fb37 --- /dev/null +++ b/apps/migrations/0016_tagulous_streamlitinstance_tags_streamlitinstance.py @@ -0,0 +1,107 @@ +# Generated by Django 5.1.1 on 2024-10-15 10:18 + +import django.db.models.deletion +import tagulous.models.fields +import tagulous.models.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("apps", "0015_tagulous_abstractcustomappinstance_tags_and_more"), + ("portal", "0002_eventsobject"), + ] + + operations = [ + migrations.CreateModel( + name="Tagulous_StreamlitInstance_tags", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255, unique=True)), + ("slug", models.SlugField()), + ( + "count", + models.IntegerField(default=0, help_text="Internal counter of how many times this tag is in use"), + ), + ( + "protected", + models.BooleanField(default=False, help_text="Will not be deleted when the count reaches 0"), + ), + ], + options={ + "ordering": ("name",), + "abstract": False, + "unique_together": {("slug",)}, + }, + bases=(tagulous.models.models.BaseTagModel, models.Model), + ), + migrations.CreateModel( + name="StreamlitInstance", + fields=[ + ( + "baseappinstance_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="apps.baseappinstance", + ), + ), + ( + "logs_enabled", + models.BooleanField( + default=True, help_text="Indicates whether logs are activated and visible to the user" + ), + ), + ("note_on_linkonly_privacy", models.TextField(blank=True, default="", null=True)), + ("source_code_url", models.URLField(blank=True, null=True)), + ("description", models.TextField(blank=True, default="", null=True)), + ( + "access", + models.CharField( + choices=[ + ("project", "Project"), + ("private", "Private"), + ("public", "Public"), + ("link", "Link"), + ], + default="project", + max_length=20, + ), + ), + ("port", models.IntegerField(default=8501)), + ("image", models.CharField(max_length=255)), + ("path", models.CharField(blank=True, default="/", max_length=255)), + ("user_id", models.IntegerField(default=1000)), + ("collections", models.ManyToManyField(blank=True, related_name="%(class)s", to="portal.collection")), + ( + "volume", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s", + to="apps.volumeinstance", + ), + ), + ( + "tags", + tagulous.models.fields.TagField( + _set_tag_meta=True, + blank=True, + force_lowercase=True, + help_text="Add keywords to help categorize your app", + to="apps.tagulous_streamlitinstance_tags", + ), + ), + ], + options={ + "verbose_name": "Streamlit App Instance", + "verbose_name_plural": "Streamlit App Instances", + "permissions": [("can_access_app", "Can access app service")], + }, + bases=("apps.baseappinstance", models.Model), + ), + ] diff --git a/apps/migrations/0017_alter_streamlitinstance_port.py b/apps/migrations/0017_alter_streamlitinstance_port.py new file mode 100644 index 000000000..c26d01200 --- /dev/null +++ b/apps/migrations/0017_alter_streamlitinstance_port.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.1 on 2024-10-21 11:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("apps", "0016_tagulous_streamlitinstance_tags_streamlitinstance"), + ] + + operations = [ + migrations.AlterField( + model_name="streamlitinstance", + name="port", + field=models.IntegerField(default=8000), + ), + ] diff --git a/apps/models/app_types/__init__.py b/apps/models/app_types/__init__.py index 7fd820431..58b74c929 100644 --- a/apps/models/app_types/__init__.py +++ b/apps/models/app_types/__init__.py @@ -1,4 +1,6 @@ from .custom import CustomAppInstance, CustomAppInstanceManager +from .custom.gradio import GradioAppInstanceManager, GradioInstance +from .custom.streamlit import StreamlitAppInstanceManager, StreamlitInstance from .dash import DashInstance, DashInstanceManager from .filemanager import FilemanagerInstance, FilemanagerInstanceManager from .jupyter import JupyterInstance, JupyterInstanceManager diff --git a/apps/models/app_types/custom/__init__.py b/apps/models/app_types/custom/__init__.py new file mode 100644 index 000000000..de857363a --- /dev/null +++ b/apps/models/app_types/custom/__init__.py @@ -0,0 +1 @@ +from .custom import CustomAppInstance, CustomAppInstanceManager diff --git a/apps/models/app_types/custom.py b/apps/models/app_types/custom/base.py similarity index 63% rename from apps/models/app_types/custom.py rename to apps/models/app_types/custom/base.py index c09417974..218320bd8 100644 --- a/apps/models/app_types/custom.py +++ b/apps/models/app_types/custom/base.py @@ -1,19 +1,23 @@ from django.db import models -from apps.models import ( - AppInstanceManager, - BaseAppInstance, - LogsEnabledMixin, - SocialMixin, -) +from apps.models import LogsEnabledMixin, SocialMixin -class CustomAppInstanceManager(AppInstanceManager): - model_type = "customappinstance" +class AbstractCustomAppInstance(SocialMixin, LogsEnabledMixin): + """ + This class is intended to be used with ``BaseAppInstance`` the following way: + ```python + class CustomAppInstance(AbstractCustomAppInstance, BaseAppInstance): + pass + ``` + + This is because of how ``get_k8s_values`` method works. It depends on in this case + the ``CustomAppInstance`` to be already a child class of a ``BaseAppInstance``. That way, + when this classes ``super().get_k8s_values()`` is called, it will call ``get_k8s_values()`` + of ``BaseAppInstance``. + """ -class CustomAppInstance(BaseAppInstance, SocialMixin, LogsEnabledMixin): - objects = CustomAppInstanceManager() ACCESS_TYPES = ( ("project", "Project"), ( @@ -30,7 +34,7 @@ class CustomAppInstance(BaseAppInstance, SocialMixin, LogsEnabledMixin): access = models.CharField(max_length=20, default="project", choices=ACCESS_TYPES) port = models.IntegerField(default=8000) image = models.CharField(max_length=255) - path = models.CharField(max_length=255, default="/") + path = models.CharField(max_length=255, default="/", blank=True) user_id = models.IntegerField(default=1000) def get_k8s_values(self): @@ -48,3 +52,4 @@ class Meta: verbose_name = "Custom App Instance" verbose_name_plural = "Custom App Instances" permissions = [("can_access_app", "Can access app service")] + abstract = True diff --git a/apps/models/app_types/custom/custom.py b/apps/models/app_types/custom/custom.py new file mode 100644 index 000000000..ba4ae6fb0 --- /dev/null +++ b/apps/models/app_types/custom/custom.py @@ -0,0 +1,11 @@ +from apps.models import AppInstanceManager, BaseAppInstance + +from .base import AbstractCustomAppInstance + + +class CustomAppInstanceManager(AppInstanceManager): + model_type = "customappinstance" + + +class CustomAppInstance(AbstractCustomAppInstance, BaseAppInstance): + objects = CustomAppInstanceManager() diff --git a/apps/models/app_types/custom/gradio.py b/apps/models/app_types/custom/gradio.py new file mode 100644 index 000000000..5eb5deb34 --- /dev/null +++ b/apps/models/app_types/custom/gradio.py @@ -0,0 +1,23 @@ +from django.db import models + +from ... import AppInstanceManager, BaseAppInstance +from .base import AbstractCustomAppInstance + + +class GradioAppInstanceManager(AppInstanceManager): + model_type = "gradioappinstance" + + +class GradioInstance(AbstractCustomAppInstance, BaseAppInstance): + objects = GradioAppInstanceManager() + port = models.IntegerField(default=7860) + + def get_k8s_values(self): + k8s_values = super().get_k8s_values() + k8s_values["appconfig"]["startupCommand"] = "python main.py" + return k8s_values + + class Meta: + verbose_name = "Gradio App Instance" + verbose_name_plural = "Gradio App Instances" + permissions = [("can_access_app", "Can access app service")] diff --git a/apps/models/app_types/custom/streamlit.py b/apps/models/app_types/custom/streamlit.py new file mode 100644 index 000000000..760a5cffe --- /dev/null +++ b/apps/models/app_types/custom/streamlit.py @@ -0,0 +1,23 @@ +from ... import AppInstanceManager, BaseAppInstance +from .base import AbstractCustomAppInstance + + +class StreamlitAppInstanceManager(AppInstanceManager): + model_type = "streamlitappinstance" + + +class StreamlitInstance(AbstractCustomAppInstance, BaseAppInstance): + objects = StreamlitAppInstanceManager() + + def get_k8s_values(self): + k8s_values = super().get_k8s_values() + # TODO Change this to actual command to run gradio app + k8s_values["appconfig"][ + "startupCommand" + ] = f"streamlit run app.py --server.address=0.0.0.0 --server.port={self.port}" + return k8s_values + + class Meta: + verbose_name = "Streamlit App Instance" + verbose_name_plural = "Streamlit App Instances" + permissions = [("can_access_app", "Can access app service")] diff --git a/apps/models/app_types/shiny.py b/apps/models/app_types/shiny.py index 290f955a4..f17a80805 100644 --- a/apps/models/app_types/shiny.py +++ b/apps/models/app_types/shiny.py @@ -35,7 +35,7 @@ class ShinyInstance(BaseAppInstance, SocialMixin, LogsEnabledMixin): container_waittime = models.IntegerField(default=20000) heartbeat_timeout = models.IntegerField(default=60000) heartbeat_rate = models.IntegerField(default=10000) - shiny_site_dir = models.CharField(max_length=255, default="") + shiny_site_dir = models.CharField(max_length=255, default="", blank=True) # The following three settings control the pre-init and seats behaviour (see documentation) # These settings override the Helm chart default values diff --git a/apps/models/base/social_mixin.py b/apps/models/base/social_mixin.py index 36f78bddb..32e21d53b 100644 --- a/apps/models/base/social_mixin.py +++ b/apps/models/base/social_mixin.py @@ -3,7 +3,7 @@ class SocialMixin(models.Model): - tags = TagField(blank=True, help_text="Add keywords to help categorize your app") + tags = TagField(blank=True, help_text="Add keywords to help categorize your app", force_lowercase=True) note_on_linkonly_privacy = models.TextField(blank=True, null=True, default="") collections = models.ManyToManyField("portal.Collection", blank=True, related_name="%(class)s") source_code_url = models.URLField(blank=True, null=True) diff --git a/apps/signals.py b/apps/signals.py index d1c018496..14438d74f 100644 --- a/apps/signals.py +++ b/apps/signals.py @@ -42,8 +42,10 @@ def update_permission(sender, instance, created, **kwargs): access = getattr(instance, "access", None) - if access is None: + if access is None and instance.name not in ["project-vol", "project-netpolicy"]: logger.error(f"Access not found in {instance}") + # We do not expect there to be access in case of project-vol and project-netpolicy + # so we do not show a warning when these are deployed. return if access == "private": diff --git a/apps/tasks.py b/apps/tasks.py index a9b4fa5c6..12756368a 100644 --- a/apps/tasks.py +++ b/apps/tasks.py @@ -24,7 +24,7 @@ def delete_old_objects(): This function retrieves the old apps based on the given threshold, category, and model class. It then iterates through the subclasses of BaseAppInstance and deletes the old apps for both the "Develop" and "Manage Files" categories. - + TODO: Make app categories and their corresponding thresholds variables in settings.py. """ def get_threshold(threshold): @@ -32,17 +32,48 @@ def get_threshold(threshold): # Handle deletion of apps in the "Develop" category for orm_model in APP_REGISTRY.iter_orm_models(): - old_develop_apps = orm_model.objects.filter(created_on__lt=get_threshold(7), app__category__name="Develop") + old_develop_apps = orm_model.objects.filter( + created_on__lt=get_threshold(7), app__category__name="Develop" + ).exclude(app_status__status="Deleted") for app_ in old_develop_apps: - delete_resource.delay(app_.pk) + delete_resource.delay(app_.serialize()) # Handle deletion of non persistent file managers old_file_managers = FilemanagerInstance.objects.filter( created_on__lt=timezone.now() - timezone.timedelta(days=1), persistent=False - ) + ).exclude(app_status__status="Deleted") for app_ in old_file_managers: - delete_resource.delay(app_.pk) + delete_resource.delay(app_.serialize()) + + +@app.task +def clean_up_apps_in_database(): + """ + This task retrieves apps that have been deleted (i.e. got status 'deleted') over a \ + specified amount of days ago and removes them from the database. + TODO: Make apps_clean_up_threshold_days a variable in settings.py. + + """ + + apps_clean_up_threshold_days = 425 + logger.info( + f"Running task clean_up_apps_in_database to remove all apps that have been deleted more than \ + {apps_clean_up_threshold_days} days ago." + ) + + for orm_model in APP_REGISTRY.iter_orm_models(): + apps_to_be_cleaned_up = orm_model.objects.filter( + deleted_on__lt=timezone.now() - timezone.timedelta(days=apps_clean_up_threshold_days), + app_status__status="Deleted", + ) + + if apps_to_be_cleaned_up: + logger.info( + f"Removing {len(apps_to_be_cleaned_up)} {apps_to_be_cleaned_up[0].app.name} app(s) from the database." + ) + for app_ in apps_to_be_cleaned_up: + app_.delete() def helm_install(release_name, chart, namespace="default", values_file=None, version=None): @@ -98,6 +129,9 @@ def deploy_resource(serialized_instance): if "ghcr" in instance.chart: version = instance.chart.split(":")[-1] chart = "oci://" + instance.chart.split(":")[0] + else: + version = None + chart = instance.chart # Save helm values file for internal reference values_file = f"charts/values/{str(uuid.uuid4())}.yaml" with open(values_file, "w") as f: diff --git a/apps/views.py b/apps/views.py index e3c500bee..e241ce0cb 100644 --- a/apps/views.py +++ b/apps/views.py @@ -11,6 +11,7 @@ from django.views import View from guardian.decorators import permission_required_or_403 +from apps.types_.subdomain import SubdomainCandidateName from projects.models import Project from studio.utils import get_logger @@ -72,8 +73,13 @@ def post(self, request, project, app_slug, app_id): project = self.get_project(project, post=True) instance = self.get_instance(app_slug, app_id, post=True) - # container name is often same as subdomain name - container = instance.subdomain.subdomain + # get container name from UI (subdomain or copy-to-pvc) if none exists then use subdomain name + container = request.POST.get("container", "") or instance.subdomain.subdomain + + # Perform data validation + if not SubdomainCandidateName(container, project.id).is_valid() and container != "": + # Handle the validation error + return JsonResponse({"error": "Invalid container value. It must be alphanumeric or empty."}, status=403) if not getattr(instance, "logs_enabled", False): return JsonResponse({"error": "Logs not enabled for this instance"}, status=403) @@ -241,7 +247,12 @@ def post(self, request, project, app_slug, app_id=None): raise PermissionDenied() if not form.is_valid(): - return render(request, self.template_name, {"form": form}) + form_header = "Update" if app_id else "Create" + return render( + request, + self.template_name, + {"form": form, "project": project, "app_id": app_id, "app_slug": app_slug, "form_header": form_header}, + ) # Otherwise we can create the instance create_instance_from_form(form, project, app_slug, app_id) diff --git a/common/forms.py b/common/forms.py index 1d660e2ad..5b31f3d2a 100644 --- a/common/forms.py +++ b/common/forms.py @@ -321,3 +321,65 @@ class Meta: fields = [ "token", ] + + +# SS-643 We've created a new form because UserForm above +# is a UserCreationForm, +# which means 'exclude' in Meta or change in +# initialization won't work +class UserEditForm(BootstrapErrorFormMixin, forms.ModelForm): + first_name = forms.CharField( + min_length=1, + max_length=30, + label="First name", + widget=forms.TextInput(attrs={"class": "form-control"}), + ) + last_name = forms.CharField( + min_length=1, + max_length=30, + label="Last name", + widget=forms.TextInput(attrs={"class": "form-control"}), + ) + email = forms.EmailField( + max_length=254, + label="Email address", + widget=forms.TextInput(attrs={"class": "form-control"}), + help_text=mark_safe("Email address can not be changed. Please email serve@scilifelab.se with any questions."), + disabled=True, + ) + + required_css_class = "required" + + class Meta: + model = User + fields = [ + "username", + "first_name", + "last_name", + "email", + "password1", + "password2", + ] + exclude = [ + "username", + "password1", + "password2", + ] + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.data})" + + +class ProfileEditForm(ProfileForm): + class Meta(ProfileForm.Meta): + exclude = [ + "note", + "why_account_needed", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["affiliation"].disabled = True + self.fields[ + "affiliation" + ].help_text = "Affiliation can not be changed. Please email serve@scilifelab.se with any questions." diff --git a/common/tests.py b/common/tests.py index 3a4133e17..219c11a00 100644 --- a/common/tests.py +++ b/common/tests.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import User from django.core import mail from django.core.exceptions import ValidationError +from django.http import HttpRequest from django.test import override_settings from hypothesis import Verbosity, assume, given, settings from hypothesis import strategies as st @@ -16,8 +17,10 @@ DEPARTMENTS, EMAIL_ALLOW_REGEX, UNIVERSITIES, + ProfileEditForm, ProfileForm, SignUpForm, + UserEditForm, UserForm, ) from common.models import EmailVerificationTable, UserProfile @@ -235,3 +238,66 @@ def test_fail_validation_other_email_affiliation_selected(form): "Please select 'Other' in affiliation or use your Swedish university researcher email." ] } == form.user.errors + + +@pytest.mark.parametrize( + "first_name, last_name", + [ + ("abc", "xyz"), + ("122124", "57457458"), + ("/&)(&(/))", "@#€%€%&"), + ], +) +def test_pass_validation_user_edit_form(first_name, last_name): + request = HttpRequest() + request.POST = { + "first_name": first_name, + "last_name": last_name, + } + form = UserEditForm(request.POST, instance=UserProfile(), initial={"email": "a@uu.se"}) + assert form.is_valid() + + +@pytest.mark.parametrize( + "first_name, last_name", + [ + ("", "xyz"), + ("abc", ""), + ("", ""), + (" ", " "), + (None, ""), + ("", None), + (None, None), + ], +) +def test_fail_validation_user_edit_form(first_name, last_name): + request = HttpRequest() + request.POST = {"first_name": first_name, "last_name": last_name} + form = UserEditForm(request.POST, instance=UserProfile(), initial={"email": "a@uu.se"}) + assert not form.is_valid() + + +@pytest.mark.parametrize( + "department", + [ + ("abc"), + ("122445"), + ("@#%&&"), + (""), + (" "), + (None), + ], +) +def test_pass_validation_profile_edit_form(department): + request = HttpRequest() + request.POST = { + "department": department, + } + form = ProfileEditForm( + request.POST, + instance=UserProfile(), + initial={ + "affiliation": "uu", + }, + ) + assert form.is_valid() diff --git a/common/urls.py b/common/urls.py index 6a0d9e4d9..aef751211 100644 --- a/common/urls.py +++ b/common/urls.py @@ -9,4 +9,6 @@ path("success/", views.RegistrationCompleteView.as_view(), name="success"), path("signup/", views.SignUpView.as_view(), name="signup"), path("verify/", views.VerifyView.as_view(), name="verify"), + path("edit-profile/", views.EditProfileView.as_view(), name="edit-profile"), + path("admin_profile_edit_disabled/", views.EditProfileView.as_view(), name="admin_profile_edit_disabled"), ] diff --git a/common/views.py b/common/views.py index cc78da74f..8056d3ca0 100644 --- a/common/views.py +++ b/common/views.py @@ -1,15 +1,29 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ObjectDoesNotExist from django.core.mail import send_mail from django.db import transaction from django.http.response import HttpResponseRedirect -from django.shortcuts import redirect, render +from django.shortcuts import HttpResponse, redirect, render from django.urls import reverse_lazy +from django.utils.decorators import method_decorator from django.views.generic import CreateView, TemplateView -from .forms import ProfileForm, SignUpForm, TokenVerificationForm, UserForm -from .models import EmailVerificationTable +from studio.utils import get_logger + +from .forms import ( + ProfileEditForm, + ProfileForm, + SignUpForm, + TokenVerificationForm, + UserEditForm, + UserForm, +) +from .models import EmailVerificationTable, UserProfile + +logger = get_logger(__name__) # Create your views here. @@ -64,8 +78,9 @@ def form_valid(self, form): self.request, ( "Please check your email for a verification link." - " If you don’t see it, please contact us at " - "serve@scilifelab.se" + " If you don’t see it, perhaps you already have an account on our service, " + "try the forgot password route linked below." + " Otherwise contact us at serve@scilifelab.se." ), ) else: @@ -129,3 +144,97 @@ def post(self, request, *args, **kwargs): messages.error(request, "Invalid token!") return redirect("portal:home") return render(request, self.template_name, {"form": form}) + + +@method_decorator(login_required, name="dispatch") +class EditProfileView(TemplateView): + template_name = "user/profile_edit_form.html" + + profile_edit_form_class = ProfileEditForm + user_edit_form_class = UserEditForm + + def get_user_profile_info(self, request): + # Get the user profile from database + try: + # Note that not all users have a user profile object + # such as the admin superuser + user_profile = UserProfile.objects.get(user_id=request.user.id) + except ObjectDoesNotExist as e: + logger.error(str(e), exc_info=True) + user_profile = UserProfile() + except Exception as e: + logger.error(str(e), exc_info=True) + user_profile = UserProfile() + + return user_profile + + def get(self, request, *args, **kwargs): + # admin user + if request.user.email in ["admin@serve.scilifelab.se", "event_user@serve.scilifelab.se"]: + return render(request, "user/admin_profile_edit_disabled.html") + + # common user with or without Staff/Superuser status + else: + user_profile_data = self.get_user_profile_info(request) + + profile_edit_form = self.profile_edit_form_class( + initial={ + "affiliation": user_profile_data.affiliation, + "department": user_profile_data.department, + } + ) + + user_edit_form = self.user_edit_form_class( + initial={ + "email": user_profile_data.user.email, + "first_name": user_profile_data.user.first_name, + "last_name": user_profile_data.user.last_name, + } + ) + + return render(request, self.template_name, {"form": user_edit_form, "profile_form": profile_edit_form}) + + def post(self, request, *args, **kwargs): + user_profile_data = self.get_user_profile_info(request) + + user_form_details = self.user_edit_form_class( + request.POST, + instance=request.user, + initial={ + "email": user_profile_data.user.email, + }, + ) + + profile_form_details = self.profile_edit_form_class( + request.POST, + instance=user_profile_data, + initial={ + "affiliation": user_profile_data.affiliation, + }, + ) + + if user_form_details.is_valid() and profile_form_details.is_valid(): + try: + with transaction.atomic(): + user_form_details.save() + profile_form_details.save() + + logger.info("Updated First Name: " + str(self.get_user_profile_info(request).user.first_name)) + logger.info("Updated Last Name: " + str(self.get_user_profile_info(request).user.last_name)) + logger.info("Updated Department: " + str(self.get_user_profile_info(request).department)) + + except Exception as e: + return HttpResponse("Error updating records: " + str(e)) + + return render(request, "user/profile.html", {"user_profile": self.get_user_profile_info(request)}) + + else: + if not user_form_details.is_valid(): + logger.error("Edit user error: " + str(user_form_details.errors)) + + if not profile_form_details.is_valid(): + logger.error("Edit profile error: " + str(profile_form_details.errors)) + + return render( + request, self.template_name, {"form": user_form_details, "profile_form": profile_form_details} + ) diff --git a/cypress/e2e/ui-tests/test-brute-force-login-attempts.cy.js b/cypress/e2e/ui-tests/test-brute-force-login-attempts.cy.js index e296b7edf..57fd7b95c 100644 --- a/cypress/e2e/ui-tests/test-brute-force-login-attempts.cy.js +++ b/cypress/e2e/ui-tests/test-brute-force-login-attempts.cy.js @@ -45,7 +45,7 @@ describe("Test brute force login attempts are blocked", () => { // Sign out before logging in again cy.logf("Sign out before logging in again", Cypress.currentTest) - cy.get('button.btn-profile').click() + cy.get('button.btn-profile').contains("Profile").click() cy.get('li.btn-group').find('button').contains("Sign out").click() cy.get("title").should("have.text", "Logout | SciLifeLab Serve (beta)") diff --git a/cypress/e2e/ui-tests/test-deploy-app.cy.js b/cypress/e2e/ui-tests/test-deploy-app.cy.js index 2521160b4..e8ed49682 100644 --- a/cypress/e2e/ui-tests/test-deploy-app.cy.js +++ b/cypress/e2e/ui-tests/test-deploy-app.cy.js @@ -102,7 +102,7 @@ describe("Test deploying app", () => { verifyAppStatus(app_name_project, "Running", "project") // check that the app is not visible under public apps cy.visit('/apps/') - cy.get('h3').should('contain', 'Public apps') + cy.get('h3').should('contain', 'Public applications and models') cy.contains('h5.card-title', app_name_project).should('not.exist') // make this app public as an update and check that it works @@ -233,8 +233,8 @@ describe("Test deploying app", () => { // check that the app is not visible under public apps cy.visit("/apps") - cy.get("title").should("have.text", "Apps | SciLifeLab Serve (beta)") - cy.get('h3').should('contain', 'Public apps') + cy.get("title").should("have.text", "Apps and models | SciLifeLab Serve (beta)") + cy.get('h3').should('contain', 'Public applications and models') cy.contains('h5.card-title', app_name_public_2).should('not.exist') } else { @@ -305,8 +305,8 @@ describe("Test deploying app", () => { cy.get('tr:contains("' + app_name + '")').find('span').should('contain', 'Deleted') // check that the app is not visible under public apps cy.visit("/apps") - cy.get("title").should("have.text", "Apps | SciLifeLab Serve (beta)") - cy.get('h3').should('contain', 'Public apps') + cy.get("title").should("have.text", "Apps and models | SciLifeLab Serve (beta)") + cy.get('h3').should('contain', 'Public applications and models') cy.contains('h5.card-title', app_name).should('not.exist') } else { @@ -368,8 +368,8 @@ describe("Test deploying app", () => { // check that the app is not visible under public apps cy.visit('/apps/') - cy.get("title").should("have.text", "Apps | SciLifeLab Serve (beta)") - cy.get('h3').should('contain', 'Public apps') + cy.get("title").should("have.text", "Apps and models | SciLifeLab Serve (beta)") + cy.get('h3').should('contain', 'Public applications and models') cy.contains('h5.card-title', app_name).should('not.exist') } else { @@ -426,8 +426,8 @@ describe("Test deploying app", () => { // check that the app is not visible under public apps cy.visit('/apps/') - cy.get("title").should("have.text", "Apps | SciLifeLab Serve (beta)") - cy.get('h3').should('contain', 'Public apps') + cy.get("title").should("have.text", "Apps and models | SciLifeLab Serve (beta)") + cy.get('h3').should('contain', 'Public applications and models') cy.contains('h5.card-title', app_name).should('not.exist') } else { @@ -512,8 +512,8 @@ describe("Test deploying app", () => { // check that the app is not visible under public apps cy.visit('/apps/') - cy.get("title").should("have.text", "Apps | SciLifeLab Serve (beta)") - cy.get('h3').should('contain', 'Public apps') + cy.get("title").should("have.text", "Apps and models | SciLifeLab Serve (beta)") + cy.get('h3').should('contain', 'Public applications and models') cy.contains('h5.card-title', app_name_edited).should('not.exist') } else { @@ -609,8 +609,8 @@ describe("Test deploying app", () => { // check that the app is not visible under public apps cy.visit('/apps/') - cy.get("title").should("have.text", "Apps | SciLifeLab Serve (beta)") - cy.get('h3').should('contain', 'Public apps') + cy.get("title").should("have.text", "Apps and models | SciLifeLab Serve (beta)") + cy.get('h3').should('contain', 'Public applications and models') cy.contains('h5.card-title', app_name).should('not.exist') } else { @@ -691,8 +691,8 @@ describe("Test deploying app", () => { // check that the app is not visible under public apps cy.visit('/apps/') - cy.get("title").should("have.text", "Apps | SciLifeLab Serve (beta)") - cy.get('h3').should('contain', 'Public apps') + cy.get("title").should("have.text", "Apps and models | SciLifeLab Serve (beta)") + cy.get('h3').should('contain', 'Public applications and models') cy.contains('h5.card-title', app_name).should('not.exist') } else { diff --git a/cypress/e2e/ui-tests/test-public-webpages.cy.js b/cypress/e2e/ui-tests/test-public-webpages.cy.js index 574547b99..4181a0be6 100644 --- a/cypress/e2e/ui-tests/test-public-webpages.cy.js +++ b/cypress/e2e/ui-tests/test-public-webpages.cy.js @@ -8,14 +8,14 @@ describe("Tests of the public pages of the website", () => { it("should open the home page on link click", () => { cy.get("li.nav-item a").contains("Home").click() - cy.url().should("include", "/home") + cy.contains("SciLifeLab Serve").should("exist") }) - it("should open the Apps page on link click", () => { - cy.get("li.nav-item a").contains("Apps").click() + it("should open the Apps and models page on link click", () => { + cy.get("li.nav-item a").contains("Apps & Models").click() cy.url().should("include", "/apps") - cy.get('h3').should('contain', 'Public apps') - cy.get("title").should("have.text", "Apps | SciLifeLab Serve (beta)") + cy.get('h3').should('contain', 'Public applications and models') + cy.get("title").should("have.text", "Apps and models | SciLifeLab Serve (beta)") if (Cypress.env('do_reset_db') === true) { // This test was flaky before as other test failures could make this test fail as well @@ -34,14 +34,6 @@ describe("Tests of the public pages of the website", () => { } }) - it("should open the Models page on link click", () => { - cy.get("li.nav-item a").contains("Models").click() - cy.url().should("include", "/models/") - cy.get('h3').should('contain', 'Model cards') - cy.get("title").should("have.text", "Models | SciLifeLab Serve (beta)") - cy.get('p').should('contain', 'No public model cards available.') - }) - it("should open the User guide page on link click", () => { cy.get("li.nav-item a").contains("User guide").click() cy.url().should("include", "/docs/") diff --git a/cypress/e2e/ui-tests/test-signup.cy.js b/cypress/e2e/ui-tests/test-signup.cy.js index d0bb31bcc..bd42b9387 100644 --- a/cypress/e2e/ui-tests/test-signup.cy.js +++ b/cypress/e2e/ui-tests/test-signup.cy.js @@ -47,8 +47,7 @@ describe("Test sign up", () => { cy.url().should("include", "accounts/login"); cy.get('.alert-success').should( 'contain', - 'Please check your email for a verification link.' + - ' If you don’t see it, please contact us at serve@scilifelab.se' + 'Please check your email for a verification link.' ); // TO-DO: add steps to check that email was sent, get token from email, go to email verification page, submit token there, then log in with new account diff --git a/cypress/e2e/ui-tests/test-superuser-functionality.cy.js b/cypress/e2e/ui-tests/test-superuser-functionality.cy.js index 3e333a4fa..a30a61705 100644 --- a/cypress/e2e/ui-tests/test-superuser-functionality.cy.js +++ b/cypress/e2e/ui-tests/test-superuser-functionality.cy.js @@ -237,7 +237,7 @@ describe("Test superuser access", () => { cy.get('#id_image').clear().type(image_name) cy.get('#id_port').clear().type(image_port) cy.get('#submit-id-submit').contains('Submit').click() - cy.get('tr:contains("' + app_name_flavor + '")').find('span').should('contain', 'Running') + cy.get('tr:contains("' + app_name_flavor + '")', {timeout: longCmdTimeoutMs}).find('span', {timeout: longCmdTimeoutMs}).should('contain', 'Running') cy.logf("Changing the flavor setting", Cypress.currentTest) cy.visit("/projects/") @@ -247,7 +247,7 @@ describe("Test superuser access", () => { cy.get('#id_flavor').find(':selected').should('contain', '2 vCPU, 4 GB RAM') cy.get('#id_flavor').select(new_flavor_name) cy.get('#submit-id-submit').contains('Submit').click() - cy.get('tr:contains("' + app_name_flavor + '")').find('span').should('contain', 'Running') + cy.get('tr:contains("' + app_name_flavor + '")', {timeout: longCmdTimeoutMs}).find('span', {timeout: longCmdTimeoutMs}).should('contain', 'Running') cy.logf("Checking that the new flavor setting was saved in the database", Cypress.currentTest) cy.visit("/projects/") @@ -267,7 +267,7 @@ describe("Test superuser access", () => { cy.get('#id_name').type(app_name_env) cy.get('#id_environment').select('Default Jupyter Lab') cy.get('#submit-id-submit').contains('Submit').click() - cy.get('tr:contains("' + app_name_env + '")').find('span').should('contain', 'Running') + cy.get('tr:contains("' + app_name_env + '")', {timeout: longCmdTimeoutMs}).find('span', {timeout: longCmdTimeoutMs}).should('contain', 'Running') cy.logf("Changing the environment setting", Cypress.currentTest) cy.visit("/projects/") @@ -277,7 +277,7 @@ describe("Test superuser access", () => { cy.get('#id_environment').find(':selected').should('contain', 'Default Jupyter Lab') cy.get('#id_environment').select(new_environment_name) cy.get('#submit-id-submit').contains('Submit').click() - cy.get('tr:contains("' + app_name_env + '")').find('span').should('contain', 'Running') + cy.get('tr:contains("' + app_name_env + '")', {timeout: longCmdTimeoutMs}).find('span', {timeout: longCmdTimeoutMs}).should('contain', 'Running') cy.logf("Checking that the new environment setting was saved in the database", Cypress.currentTest) cy.visit("/projects/") diff --git a/cypress/e2e/ui-tests/test-views-as-reader.cy.js b/cypress/e2e/ui-tests/test-views-as-reader.cy.js index a96e3c8ea..4d82932b7 100644 --- a/cypress/e2e/ui-tests/test-views-as-reader.cy.js +++ b/cypress/e2e/ui-tests/test-views-as-reader.cy.js @@ -32,18 +32,11 @@ describe("Test views as authenticated user", () => { }) - it("can view the Apps view", () => { + it("can view the Apps and models view", () => { cy.visit("/apps") - cy.get('h3').should('contain', 'apps') - }) - - it("can view the Models view", () => { - - cy.visit("/models/") - - cy.get('h3').should('contain', 'Model cards') + cy.get('h3').should('contain', 'applications') }) it("can view the Projects view", () => { diff --git a/fixtures/apps_fixtures.json b/fixtures/apps_fixtures.json index 9c528833d..d9ffa3763 100644 --- a/fixtures/apps_fixtures.json +++ b/fixtures/apps_fixtures.json @@ -311,7 +311,7 @@ { "fields": { "category": "serve", - "chart": "ghcr.io/scilifelabdatacentre/serve-charts/custom-app:1.0.2", + "chart": "ghcr.io/scilifelabdatacentre/serve-charts/custom-app:1.1.0", "created_on": "2023-08-25T21:34:37.815Z", "description": "Apps built with Gradio, Streamlit, Flask, etc.", "logo": "default-logo.svg", @@ -383,7 +383,7 @@ { "fields": { "category": "serve", - "chart": "ghcr.io/scilifelabdatacentre/serve-charts/shinyproxy:1.4.1", + "chart": "ghcr.io/scilifelabdatacentre/serve-charts/shinyproxy:1.4.2", "created_on": "2023-08-25T21:34:37.815Z", "description": "", "logo": "shinyapp-logo.svg", @@ -484,5 +484,41 @@ }, "model": "apps.apps", "pk": 28 + }, + { + "fields": { + "category": "serve", + "chart": "ghcr.io/scilifelabdatacentre/serve-charts/custom-app:1.1.0", + "created_on": "2023-08-25T21:34:37.815Z", + "description": "", + "logo": "gradio-logo.svg", + "name": "Gradio App", + "priority": "400", + "slug": "gradio", + "table_field": { + "url": "https://{{ release }}.{{ global.domain }}" + }, + "updated_on": "2023-08-25T19:45:03.927Z" + }, + "model": "apps.apps", + "pk": 30 + }, + { + "fields": { + "category": "serve", + "chart": "ghcr.io/scilifelabdatacentre/serve-charts/custom-app:1.1.0", + "created_on": "2023-08-25T21:34:37.815Z", + "description": "", + "logo": "streamlit-logo.svg", + "name": "Streamlit App", + "priority": "400", + "slug": "streamlit", + "table_field": { + "url": "https://{{ release }}.{{ global.domain }}" + }, + "updated_on": "2023-08-25T19:45:03.927Z" + }, + "model": "apps.apps", + "pk": 35 } ] diff --git a/fixtures/intervals_fixtures.json b/fixtures/intervals_fixtures.json index 4fd3229ab..515dd3d08 100644 --- a/fixtures/intervals_fixtures.json +++ b/fixtures/intervals_fixtures.json @@ -66,5 +66,27 @@ }, "model": "django_celery_beat.crontabschedule", "pk": 4 + }, + { + "fields": { + "day_of_month": "*", + "day_of_week": "1", + "hour": "6", + "minute": "0", + "month_of_year": "*" + }, + "model": "django_celery_beat.crontabschedule", + "pk": 5 + }, + { + "fields": { + "day_of_month": "*", + "day_of_week": "1", + "hour": "6", + "minute": "30", + "month_of_year": "*" + }, + "model": "django_celery_beat.crontabschedule", + "pk": 6 } ] diff --git a/fixtures/periodic_tasks_fixtures.json b/fixtures/periodic_tasks_fixtures.json index 939409ab9..cda16d305 100644 --- a/fixtures/periodic_tasks_fixtures.json +++ b/fixtures/periodic_tasks_fixtures.json @@ -27,14 +27,13 @@ "model": "django_celery_beat.periodictask", "pk": 1 }, - { "fields": { "args": "[]", "clocked": null, "crontab": 3, "date_changed": "2021-02-26T14:03:40.168Z", - "description": "Deletes apps under develop after specified amount of time", + "description": "Deletes apps of specified categories after specified amount of time", "enabled": true, "exchange": null, "expire_seconds": null, @@ -111,5 +110,61 @@ }, "model": "django_celery_beat.periodictask", "pk": 8 + }, + { + "fields": { + "args": "[]", + "clocked": null, + "crontab": 5, + "date_changed": "2021-02-26T14:03:40.168Z", + "description": "Removes apps that have been deleted over a specified number of days ago from the database.", + "enabled": true, + "exchange": null, + "expire_seconds": null, + "expires": null, + "headers": "{}", + "interval": null, + "kwargs": "{}", + "last_run_at": "2021-02-26T14:03:37.169Z", + "name": "clean_up_apps_in_database", + "one_off": false, + "priority": null, + "queue": null, + "routing_key": null, + "solar": null, + "start_time": null, + "task": "apps.tasks.clean_up_apps_in_database", + "total_run_count": 174 + }, + "model": "django_celery_beat.periodictask", + "pk": 9 + }, + { + "fields": { + "args": "[]", + "clocked": null, + "crontab": 6, + "date_changed": "2021-02-26T14:03:40.168Z", + "description": "Removes projects that have been deleted over a specified number of days ago from the database.", + "enabled": true, + "exchange": null, + "expire_seconds": null, + "expires": null, + "headers": "{}", + "interval": null, + "kwargs": "{}", + "last_run_at": "2021-02-26T14:03:37.169Z", + "name": "clean_up_projects_in_database", + "one_off": false, + "priority": null, + "queue": null, + "routing_key": null, + "solar": null, + "start_time": null, + "task": "projects.tasks.clean_up_projects_in_database", + "total_run_count": 174 + }, + "model": "django_celery_beat.periodictask", + "pk": 10 } ] diff --git a/fixtures/projects_templates.json b/fixtures/projects_templates.json index 15145f809..dc0badb47 100644 --- a/fixtures/projects_templates.json +++ b/fixtures/projects_templates.json @@ -1,10 +1,21 @@ [ { "fields": { - "description": "Use this template if you intend to deploy apps or ML models packaged into containers, or if you intend to use web-based notebooks.", + "description": "The default project type includes all standard SciLifeLab Serve functionality - model serving, app hosting, web-based notebooks, and file management.", "name": "Default project", "slug": "blank", - "available_apps": [8, 9, 19, 21, 22, 23, 24, 28], + "available_apps": [ + 8, + 9, + 19, + 21, + 22, + 23, + 24, + 28, + 30, + 35 + ], "template": { "apps": { @@ -22,16 +33,6 @@ "app": "rstudio", "image": "serve-rstudio:231030-1146", "repository": "ghcr.io/scilifelabdatacentre" - }, - "MLflow Serving": { - "app": "mlflow-serve", - "image": "serve-mlflow:231030-1149", - "repository": "ghcr.io/scilifelabdatacentre" - }, - "Python Serving": { - "app": "python-serve", - "image": "serve-python:latest", - "repository": "ghcr.io/scilifelabdatacentre" } }, "volumes": { @@ -63,77 +64,5 @@ }, "model": "projects.projecttemplate", "pk": 1 - }, - { - "fields": { - "description": "This project type allows to deploy machine learning models using the specialized model serving frameworks.", - "name": "Project with specialized ML serving", - "slug": "default", - "template": { - "apps": { - "netpolicy": { - "name": "project-netpolicy" - }, - "jupyter-lab": { - "name": "My Jupyter Lab", - "description": "Deployed via project template", - "volume": "project-vol", - "access": "private", - "flavor": "2 vCPU, 4 GB RAM" - }, - "filemanager": { - "name": "project-filemanager", - "volume": "project-vol", - "persistent": "True", - "flavor": "2 vCPU, 4 GB RAM", - "access": "private" - } - }, - "environments": { - "Jupyter Lab": { - "app": "jupyter-lab", - "image": "serve-jupyterlab:231030-1145", - "repository": "ghcr.io/scilifelabdatacentre" - }, - "MLflow Serving": { - "app": "mlflow-serve", - "image": "serve-mlflow:231030-1149", - "repository": "ghcr.io/scilifelabdatacentre" - }, - "Python Serving": { - "app": "python-serve", - "image": "serve-python:latest", - "repository": "ghcr.io/scilifelabdatacentre" - } - }, - "volumes": { - "project-vol": { - "size": "1" - } - }, - "flavors": { - "2 vCPU, 4 GB RAM": { - "cpu": { - "limit": "2000m", - "requirement": "500m" - }, - "ephmem": { - "limit": "5000Mi", - "requirement": "100Mi" - }, - "gpu": { - "limit": "0", - "requirement": "0" - }, - "mem": { - "limit": "4Gi", - "requirement": "1Gi" - } - } - } - } - }, - "model": "projects.projecttemplate", - "pk": 2 } ] diff --git a/portal/tests.py b/portal/tests.py index 0df530e3c..8fe18977e 100644 --- a/portal/tests.py +++ b/portal/tests.py @@ -22,7 +22,7 @@ def test_index(): # Check if it returns the correct status code assert response.status_code == 200 - assert "Apps | SciLifeLab Serve (beta)" in response.content.decode() + assert "Apps and models | SciLifeLab Serve (beta)" in response.content.decode() @pytest.mark.django_db diff --git a/portal/urls.py b/portal/urls.py index 776f691b2..6cf2e3b1d 100644 --- a/portal/urls.py +++ b/portal/urls.py @@ -7,7 +7,7 @@ app_name = "portal" urlpatterns = [ - path("home/", views.HomeView.as_view(), name="home"), + path("home/", views.HomeView.as_view(), name="home-explicit"), path("about/", views.about, name="about"), path("teaching/", views.teaching, name="teaching"), path("privacy/", views.privacy, name="privacy"), @@ -16,5 +16,5 @@ path("events/", views.get_events, name="events"), path("collections/", views.get_collections_index, name="collections_index"), path("collections//", views.get_collection, name="collection"), - path("", views.HomeViewDynamic.as_view(), name="home-dynamic"), + path("", views.HomeView.as_view(), name="home"), ] diff --git a/portal/views.py b/portal/views.py index eef646ace..ff72234a1 100644 --- a/portal/views.py +++ b/portal/views.py @@ -1,3 +1,5 @@ +from datetime import timedelta + import markdown from django.apps import apps from django.conf import settings @@ -6,6 +8,7 @@ from django.utils import timezone from django.views.generic import View +from apps.app_registry import APP_REGISTRY from apps.models import BaseAppInstance, SocialMixin from studio.utils import get_logger @@ -20,9 +23,8 @@ # TODO minor refactor -# 1. Change id to app_id as it's anti-pattern to override language reserved function names # 2. add type annotations -def get_public_apps(request, app_id=0, get_all=True, collection=None): +def get_public_apps(request, app_id=0, collection=None, order_by="updated_on", order_reverse=False): try: projects = Project.objects.filter( Q(owner=request.user) | Q(authorized=request.user), status="active" @@ -59,36 +61,42 @@ def get_public_apps(request, app_id=0, get_all=True, collection=None): if "tf_add" not in request.GET and "tf_remove" not in request.GET: request.session["app_tags"] = {} + # Select published apps published_apps = [] - - if collection: - # TODO: TIDY THIS UP! - - for subclass in SocialMixin.__subclasses__(): - print(subclass, flush=True) - published_apps_qs = subclass.objects.filter( - ~Q(app_status__status="Deleted"), access="public", collections__slug=collection - ) - print(published_apps_qs, flush=True) - published_apps.extend([app for app in published_apps_qs]) - - else: - for subclass in SocialMixin.__subclasses__(): - published_apps_qs = subclass.objects.filter(~Q(app_status__status="Deleted"), access="public") - published_apps.extend([app for app in published_apps_qs]) - - # sorting the apps by date updated - published_apps.sort( - key=lambda app: (app.updated_on is None, app.updated_on if app.updated_on is not None else ""), - reverse=True, # Sort in descending order - ) - - if len(published_apps) >= 3 and not get_all: - published_apps = published_apps[:3] + # because shiny appears twice we have to ensure uniqueness + seen_app_ids = set() + + def get_unique_apps(queryset, app_ids_to_exclude): + """Get from queryset app orm models, that are not present in ``seen_app_ids``""" + unique_app_ids_ = set() + unique_apps_ = [] + for app in queryset: + if app.id not in app_ids_to_exclude and app_id not in unique_app_ids_: + unique_app_ids_.add(app.id) + unique_apps_.append(app) + return unique_apps_, unique_app_ids_ + + app_orms = (app_model for app_model in APP_REGISTRY.iter_orm_models() if issubclass(app_model, SocialMixin)) + + for app_orm in app_orms: + logger.info("Processing: %s", app_orm) + filters = ~Q(app_status__status="Deleted") & Q(access="public") + if collection: + filters &= Q(collections__slug=collection) + published_apps_qs = app_orm.objects.filter(filters) + + unique_apps, unique_app_ids = get_unique_apps(published_apps_qs, seen_app_ids) + published_apps += unique_apps + seen_app_ids.update(unique_app_ids) + + # Sort by the values specified in 'order_by' and 'reverse' + if all(hasattr(app, order_by) for app in published_apps): + published_apps.sort( + key=lambda app: (getattr(app, order_by) is None, getattr(app, order_by, "")), reverse=order_reverse + ) else: - published_apps = published_apps - # Get the app instance latest status (not state) - # Similar to GetStatusView() in apps.views + logger.error("Error: Invalid order_by field", exc_info=True) + for app in published_apps: try: app.status_group = "success" if app.app_status.status in settings.APPS_STATUS_SUCCESS else "warning" @@ -134,7 +142,7 @@ def get_public_apps(request, app_id=0, get_all=True, collection=None): def public_apps(request, app_id=0): - published_apps, request = get_public_apps(request, app_id=app_id) + published_apps, request = get_public_apps(request, app_id=app_id, order_by="updated_on", order_reverse=True) template = "portal/apps.html" return render(request, template, locals()) @@ -143,12 +151,24 @@ class HomeView(View): template = "portal/home.html" def get(self, request, app_id=0): - published_apps, request = get_public_apps(request, app_id=app_id, get_all=False) - published_models = PublishedModel.objects.all() - if published_models.count() >= 3: - published_models = published_models[:3] - else: - published_models = published_models + all_published_apps_created_on, request = get_public_apps( + request, app_id=app_id, order_by="created_on", order_reverse=True + ) + all_published_apps_updated_on, request = get_public_apps( + request, app_id=app_id, order_by="updated_on", order_reverse=True + ) + + # Make sure we don't have the same apps displayed in both Recently updated and Recently added fields + published_apps_created_on = [ + app for app in all_published_apps_created_on if app.updated_on <= (app.created_on + timedelta(minutes=60)) + ][ + :3 + ] # we display only 3 apps + published_apps_updated_on = [ + app for app in all_published_apps_updated_on if app.updated_on > (app.created_on + timedelta(minutes=60)) + ][ + :3 + ] # we display only 3 apps news_objects = NewsObject.objects.all().order_by("-created_on") link_all_news = False @@ -180,8 +200,8 @@ def get(self, request, app_id=0): event.past = True if event.start_time.date() < timezone.now().date() else False context = { - "published_apps": published_apps, - "published_models": published_models, + "published_apps_updated_on": published_apps_updated_on, + "published_apps_created_on": published_apps_created_on, "news_objects": news_objects, "link_all_news": link_all_news, "collection_objects": collection_objects, @@ -193,16 +213,6 @@ def get(self, request, app_id=0): return render(request, self.template, context=context) -class HomeViewDynamic(View): - template = "portal/home.html" - - def get(self, request): - if request.user.is_authenticated: - return redirect("projects/") - else: - return HomeView.as_view()(request, app_id=0) - - def about(request): template = "portal/about.html" return render(request, template, locals()) @@ -252,7 +262,7 @@ def get_collection(request, slug, app_id=0): def get_events(request): - future_events = EventsObject.objects.filter(start_time__date__gte=timezone.now().date()).order_by("-start_time") + future_events = EventsObject.objects.filter(start_time__date__gte=timezone.now().date()).order_by("start_time") for event in future_events: event.description_html = markdown.markdown(event.description) past_events = EventsObject.objects.filter(start_time__date__lt=timezone.now().date()).order_by("-start_time") diff --git a/projects/admin.py b/projects/admin.py index db5a65d69..31cf24a79 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -13,6 +13,7 @@ class ProjectAdmin(admin.ModelAdmin): list_display = ("name", "owner", "status", "updated_at", "project_template") list_filter = ["owner", "status", "project_template"] actions = ["update_app_limits"] + readonly_fields = ["created_at"] @admin.action(description="Reset app limits") def update_app_limits(self, request, queryset): diff --git a/projects/forms.py b/projects/forms.py index cc9247c09..53d9d3b81 100644 --- a/projects/forms.py +++ b/projects/forms.py @@ -29,13 +29,15 @@ def __init__(self, *args, **kwargs): selected_users = forms.MultipleChoiceField(widget=forms.SelectMultiple, choices=OPTIONS) +# Note: 2024-09-30: I don't think we're currently using this one but will keep it up to date for future. class FlavorForm(forms.Form): cpu_req = forms.CharField(label="CPU request", max_length=10, initial="200m") - mem_req = forms.CharField(label="Memory request", max_length=15) - gpu_req = forms.CharField(label="GPU request", max_length=10) + mem_req = forms.CharField(label="Memory request", max_length=15, initial="0.5Gi") + ephmem_req = forms.CharField(label="Ephemeral storage request", max_length=15, initial="200Mi") - cpu_lim = forms.CharField(label="CPU limit", max_length=10) - mem_lim = forms.CharField(label="Memory limit", max_length=15) + cpu_lim = forms.CharField(label="CPU limit", max_length=10, initial="2000m") + mem_lim = forms.CharField(label="Memory limit", max_length=15, initial="4Gi") + ephmem_req = forms.CharField(label="Ephemeral storage limit", max_length=15, initial="5000Mi") class ImageUpdateForm(forms.Form): diff --git a/projects/migrations/0005_alter_flavor_cpu_lim_alter_flavor_ephmem_lim_and_more.py b/projects/migrations/0005_alter_flavor_cpu_lim_alter_flavor_ephmem_lim_and_more.py new file mode 100644 index 000000000..373627e78 --- /dev/null +++ b/projects/migrations/0005_alter_flavor_cpu_lim_alter_flavor_ephmem_lim_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.1 on 2024-09-30 15:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("projects", "0004_add_rstudio_environment_to_old_projects"), + ] + + operations = [ + migrations.AlterField( + model_name="flavor", + name="cpu_lim", + field=models.TextField(blank=True, default="2000m", null=True), + ), + migrations.AlterField( + model_name="flavor", + name="ephmem_lim", + field=models.TextField(blank=True, default="1000Mi", null=True), + ), + migrations.AlterField( + model_name="flavor", + name="mem_lim", + field=models.TextField(blank=True, default="4Gi", null=True), + ), + ] diff --git a/projects/migrations/0006_alter_flavor_cpu_lim_alter_flavor_cpu_req_and_more.py b/projects/migrations/0006_alter_flavor_cpu_lim_alter_flavor_cpu_req_and_more.py new file mode 100644 index 000000000..aebe35391 --- /dev/null +++ b/projects/migrations/0006_alter_flavor_cpu_lim_alter_flavor_cpu_req_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 5.1.1 on 2024-10-04 08:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("projects", "0005_alter_flavor_cpu_lim_alter_flavor_ephmem_lim_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="flavor", + name="cpu_lim", + field=models.TextField(blank=True, default="2000m", null=True, verbose_name="CPU limit"), + ), + migrations.AlterField( + model_name="flavor", + name="cpu_req", + field=models.TextField(blank=True, default="200m", null=True, verbose_name="CPU request"), + ), + migrations.AlterField( + model_name="flavor", + name="ephmem_lim", + field=models.TextField(blank=True, default="1000Mi", null=True, verbose_name="Ephemeral storage limit"), + ), + migrations.AlterField( + model_name="flavor", + name="ephmem_req", + field=models.TextField(blank=True, default="200Mi", null=True, verbose_name="Ephemeral storage request"), + ), + migrations.AlterField( + model_name="flavor", + name="mem_lim", + field=models.TextField(blank=True, default="4Gi", null=True, verbose_name="Memory limit"), + ), + migrations.AlterField( + model_name="flavor", + name="mem_req", + field=models.TextField(blank=True, default="0.5Gi", null=True, verbose_name="Memory request"), + ), + migrations.AlterField( + model_name="flavor", + name="name", + field=models.CharField(max_length=512, verbose_name="Flavor name (N vCPU, N GB RAM)"), + ), + ] diff --git a/projects/migrations/0007_alter_flavor_ephmem_lim.py b/projects/migrations/0007_alter_flavor_ephmem_lim.py new file mode 100644 index 000000000..7d3ea637a --- /dev/null +++ b/projects/migrations/0007_alter_flavor_ephmem_lim.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.1 on 2024-10-10 15:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("projects", "0006_alter_flavor_cpu_lim_alter_flavor_cpu_req_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="flavor", + name="ephmem_lim", + field=models.TextField(blank=True, default="5000Mi", null=True, verbose_name="Ephemeral storage limit"), + ), + ] diff --git a/projects/migrations/0008_project_deleted_on.py b/projects/migrations/0008_project_deleted_on.py new file mode 100644 index 000000000..a3a8e2622 --- /dev/null +++ b/projects/migrations/0008_project_deleted_on.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.1 on 2024-10-17 07:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("projects", "0007_alter_flavor_ephmem_lim"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="deleted_on", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/projects/models.py b/projects/models.py index 5e9868b10..511fa5970 100644 --- a/projects/models.py +++ b/projects/models.py @@ -71,15 +71,15 @@ def get_full_image_reference(self): class Flavor(models.Model): created_at = models.DateTimeField(auto_now_add=True) - cpu_lim = models.TextField(blank=True, null=True, default="1000m") + cpu_lim = models.TextField("CPU limit", blank=True, null=True, default="2000m") gpu_lim = models.TextField(blank=True, null=True, default="0") - ephmem_lim = models.TextField(blank=True, null=True, default="200Mi") - mem_lim = models.TextField(blank=True, null=True, default="3Gi") - cpu_req = models.TextField(blank=True, null=True, default="200m") + ephmem_lim = models.TextField("Ephemeral storage limit", blank=True, null=True, default="5000Mi") + mem_lim = models.TextField("Memory limit", blank=True, null=True, default="4Gi") + cpu_req = models.TextField("CPU request", blank=True, null=True, default="200m") gpu_req = models.TextField(blank=True, null=True, default="0") - ephmem_req = models.TextField(blank=True, null=True, default="200Mi") - mem_req = models.TextField(blank=True, null=True, default="0.5Gi") - name = models.CharField(max_length=512) + ephmem_req = models.TextField("Ephemeral storage request", blank=True, null=True, default="200Mi") + mem_req = models.TextField("Memory request", blank=True, null=True, default="0.5Gi") + name = models.CharField("Flavor name (N vCPU, N GB RAM)", max_length=512) project = models.ForeignKey(settings.PROJECTS_MODEL, on_delete=models.CASCADE, null=True) updated_at = models.DateTimeField(auto_now=True) @@ -216,6 +216,7 @@ def __str__(self): class Project(models.Model): authorized = models.ManyToManyField(get_user_model(), blank=True) created_at = models.DateTimeField(auto_now_add=True) + deleted_on = models.DateTimeField(null=True, blank=True) clone_url = models.CharField(max_length=512, null=True, blank=True) description = models.TextField(null=True, blank=True) diff --git a/projects/tasks.py b/projects/tasks.py index 35ec099b4..bbb69afee 100644 --- a/projects/tasks.py +++ b/projects/tasks.py @@ -1,10 +1,12 @@ import collections import json +from datetime import datetime from celery import shared_task from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model +from django.utils import timezone from apps.app_registry import APP_REGISTRY from apps.helpers import create_instance_from_form @@ -168,7 +170,9 @@ def delete_project(project_pk): project = Project.objects.get(pk=project_pk) delete_project_apps(project) - project.delete() + project.status = "deleted" + project.deleted_on = datetime.now() + project.save() @shared_task @@ -178,3 +182,29 @@ def delete_project_apps(project): for instance in queryset: serialized_instance = instance.serialize() delete_resource(serialized_instance) + + +@shared_task +def clean_up_projects_in_database(): + """ + This task retrieves projects that have been deleted (i.e. got status 'deleted') over a specified + amount of days ago and removes them from the database. + TODO: Make projects_clean_up_threshold_days a variable in settings.py. + """ + + projects_clean_up_threshold_days = 425 + logger.info( + f"Running task clean_up_projects_in_database to remove all projects that have been \ + deleted more than {projects_clean_up_threshold_days} days ago." + ) + + projects_to_be_cleaned_up = Project.objects.filter( + deleted_on__lt=timezone.now() - timezone.timedelta(days=projects_clean_up_threshold_days), status="deleted" + ) + + if projects_to_be_cleaned_up: + logger.info(f"Removing {len(projects_to_be_cleaned_up)} project(s) from the database.") + for project in projects_to_be_cleaned_up: + project.delete() + else: + logger.info("There were no projects to be cleaned up.") diff --git a/projects/views.py b/projects/views.py index df79c8b23..170a8a769 100644 --- a/projects/views.py +++ b/projects/views.py @@ -228,17 +228,19 @@ def create_flavor(request, project_slug): name = request.POST.get("flavor_name") cpu_req = request.POST.get("cpu_req") mem_req = request.POST.get("mem_req") - gpu_req = request.POST.get("gpu_req") + ephmem_req = request.POST.get("ephmem_req") cpu_lim = request.POST.get("cpu_lim") mem_lim = request.POST.get("mem_lim") + ephmem_lim = request.POST.get("ephmem_lim") flavor = Flavor( name=name, project=project, cpu_req=cpu_req, mem_req=mem_req, - gpu_req=gpu_req, cpu_lim=cpu_lim, mem_lim=mem_lim, + ephmem_req=ephmem_req, + ephmem_lim=ephmem_lim, ) flavor.save() return HttpResponseRedirect( @@ -284,7 +286,7 @@ def get(self, request, project_slug): ) class GrantAccessToProjectView(View): def post(self, request, project_slug): - selected_username = request.POST["selected_user"] + selected_username = request.POST["selected_user"].lower() qs = User.objects.filter(username=selected_username) if len(qs) == 1: diff --git a/pyproject.toml b/pyproject.toml index 78eaf60c2..aa6c55b81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,7 +105,7 @@ extend-exclude = ''' ''' [tool.mypy] -strict = false +strict = true python_version = "3.12" disallow_subclassing_any = false ignore_missing_imports = false @@ -166,12 +166,10 @@ ignore_errors = true module = [ "api.*", "apps.*", - "collections_module.*", "common.*", "customtags.*", "models.*", "monitor.*", - "news.*", "portal.*", "projects.*", "scripts.*", diff --git a/static/common/universities.json b/static/common/universities.json index 7b1ee0dc4..98fa8033d 100644 --- a/static/common/universities.json +++ b/static/common/universities.json @@ -19,8 +19,9 @@ "kth": "KTH Royal Institute of Technology", "liu": "Link\u00f6pings University", "lnu": "Linnaeus University", + "lth": "LTH Lund University", "ltu": "Lule\u00e5 University of Technology", - "lu": "Lunds University", + "lu": "Lund University", "mau": "Malm\u00f6 University", "mdu": "M\u00e4lardalen University", "miun": "Mid Sweden University", diff --git a/static/css/serve-user-guide.css b/static/css/serve-user-guide.css index c6eea9b8f..cf90e79bc 100644 --- a/static/css/serve-user-guide.css +++ b/static/css/serve-user-guide.css @@ -128,6 +128,16 @@ blockquote p { font-style: italic; } +.wiki-article ul li a { + color: var(--serve-teal); +} + +.wiki-article ul li a:hover, +.wiki-article ul li a:focus, +.wiki-article ul li a:active { + color: var(--serve-teal); +} + /* Search form on top of search result form */ .fa-search::before { diff --git a/static/css/serve-utilities.css b/static/css/serve-utilities.css index 1c31e221d..82c202c4e 100644 --- a/static/css/serve-utilities.css +++ b/static/css/serve-utilities.css @@ -82,3 +82,23 @@ label.required::before { .w-80 { width: 80%; } + +.nav-tab-logs { + border-color: #d2d5d8!important; +} +.nav-tab-logs.active { + background-color:#6d5476!important; + border-bottom:#6d5476!important; + font-weight: bold!important; + color: white!important; +} +.nav-tab-logs.disabled { + color: #ffffff11!important; + border:#6c757d00!important; + } + +.nav-tab-error { + background: #d2454fb8!important; + border: #e5626b!important; + color: white!important; +} diff --git a/static/images/logos/apps/gradio-logo.svg b/static/images/logos/apps/gradio-logo.svg new file mode 100644 index 000000000..e680348f0 --- /dev/null +++ b/static/images/logos/apps/gradio-logo.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/logos/apps/streamlit-logo.svg b/static/images/logos/apps/streamlit-logo.svg new file mode 100644 index 000000000..8bcbf410e --- /dev/null +++ b/static/images/logos/apps/streamlit-logo.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/static/js/form-helpers.js b/static/js/form-helpers.js index badc78b8b..87dda2698 100644 --- a/static/js/form-helpers.js +++ b/static/js/form-helpers.js @@ -5,7 +5,7 @@ window.onload = (event) => { const request_account_label = document.querySelector('label[for="id_why_account_needed"]'); const department_label = document.querySelector('label[for="id_department"]'); - const domainRegex = /^(?:(?!\b(?:student|stud)\b\.)[A-Z0-9](?:[\.A-Z0-9-]{0,61}[A-Z0-9])?\.)*?(uu|lu|gu|su|umu|liu|ki|kth|chalmers|ltu|hhs|slu|kau|lnu|oru|miun|mau|mdu|bth|fhs|gih|hb|du|hig|hh|hkr|his|hv|ju|sh)\.se$/i; + const domainRegex = /^(?:(?!\b(?:student|stud)\b\.)[A-Z0-9](?:[\.A-Z0-9-]{0,61}[A-Z0-9])?\.)*?(uu|lu|gu|su|umu|liu|ki|kth|chalmers|ltu|hhs|slu|kau|lth|lnu|oru|miun|mau|mdu|bth|fhs|gih|hb|du|hig|hh|hkr|his|hv|ju|sh)\.se$/i; function changeVisibility() { let shouldHide = false; diff --git a/studio/settings.py b/studio/settings.py index 185a7f582..44877d9ce 100644 --- a/studio/settings.py +++ b/studio/settings.py @@ -211,7 +211,7 @@ ] # Related to user registration and authetication workflow -LOGIN_REDIRECT_URL = "/projects" +LOGIN_REDIRECT_URL = "/projects/" LOGIN_URL = "login" LOGOUT_URL = "logout" diff --git a/templates/apps/logs.html b/templates/apps/logs.html index 55783c3c8..b8e7929ba 100644 --- a/templates/apps/logs.html +++ b/templates/apps/logs.html @@ -8,24 +8,24 @@
-

{{ instance.name }} Logs

{{ instance.status.latest.status_type }}
+

{{ instance.name }} Logs

{{ instance.status.latest.status_type }}
-

Note: Logs appear a few minutes after an app has been launched. The Status on the top right is an indication of the state of the app. {% if instance.app.slug == 'customapp' and instance.volume %} If the app is not running (due to an error) and you have a volume attached to the app, then you can switch between to the tabs below to see logs for the data copy process. This can give you hints if data copy failed.{% endif %}

+

Note: Logs appear a few minutes after an app has been launched. The Status on the top right is an indication of the state of the app. {% if instance.app.slug in 'customapp,gradio,streamlit' and instance.volume %} If the app is not running (due to an error) and you have a volume attached to the app, then you can switch between to the tabs below to see logs for the data copy process. The data copy tab will be shown in red in this case. If the data copy succeeds, then the data copy tab is disabled and can't be accessed. This can give you hints if data copy failed.{% endif %}

-
+
@@ -65,18 +65,19 @@

{{ instance.name }} Logs

{{ instance.name }} Logs
{{ field.field.bottom_help_text }} {% endif %} + + {% if field.errors %} + {% for error in field.errors %} +
+ {{ error }} +
+ {% endfor %} + {% endif %} diff --git a/templates/common/app_card.html b/templates/common/app_card.html index 31cf09413..5b2c0f9fe 100644 --- a/templates/common/app_card.html +++ b/templates/common/app_card.html @@ -81,7 +81,7 @@
{{ app.name }}
{% if app.app.slug in 'shinyapp,shinyproxyapp,dashapp,customapp' %} {% if app.pvc == None %} - + @@ -89,7 +89,7 @@
{{ app.name }}
{% endif %} {% if app.source_code_url %} - + diff --git a/templates/common/footer.html b/templates/common/footer.html index 7a8868ba9..51ae4098c 100644 --- a/templates/common/footer.html +++ b/templates/common/footer.html @@ -24,8 +24,7 @@
  • Home
  • About
  • Privacy policy
  • -
  • Public apps
  • -
  • Public models
  • +
  • Public apps and models
  • Collections
  • diff --git a/templates/common/navbar.html b/templates/common/navbar.html index 912b6ad4b..2a9ec464d 100644 --- a/templates/common/navbar.html +++ b/templates/common/navbar.html @@ -19,12 +19,7 @@ -