diff --git a/controlpanel/api/cluster.py b/controlpanel/api/cluster.py index 90b6ec86a..8e6a9cbd8 100644 --- a/controlpanel/api/cluster.py +++ b/controlpanel/api/cluster.py @@ -991,6 +991,8 @@ def _set_values(self, **kwargs): values.update(self.tool.values) values.update(kwargs) + # override the tool image tag with the value stored in the DB + values.update({self.tool.image_tag_key: self.tool.image_tag}) set_values = [] for key, val in values.items(): if val: @@ -1067,6 +1069,22 @@ def get_deployments(cls, user, id_token, search_name=None, search_version=None): deployments.append(deployment) return deployments + @classmethod + def get_chart_details(cls, chart: str) -> tuple[str, str]: + """ + This is a bit of a hack to safely extract the chart version when it includes an 'rc' tag. + This wont be necessary anymore when we track deployed tools in the database. + See https://github.com/ministryofjustice/analytical-platform/issues/6266 + """ + chart_name, chart_version = chart.rsplit("-", 1) + if "rc" not in chart_version: + return chart_name, chart_version + + rc_tag = chart_version + chart_name, chart_version = chart_name.rsplit("-", 1) + chart_version = f"{chart_version}-{rc_tag}" + return chart_name, chart_version + def get_deployment(self, id_token): deployments = self.__class__.get_deployments( self.user, diff --git a/controlpanel/api/helm.py b/controlpanel/api/helm.py index e6b704961..e91512a0f 100644 --- a/controlpanel/api/helm.py +++ b/controlpanel/api/helm.py @@ -158,6 +158,7 @@ def update_helm_repository(force=False): _execute("repo", "update", timeout=None) # timeout = infinity. +# TODO no longer used, remove def get_default_image_tag_from_helm_chart(chart_url, chart_name): proc = _execute("show", "values", chart_url) if proc: @@ -171,6 +172,8 @@ def get_default_image_tag_from_helm_chart(chart_url, chart_name): return None +# TODO this is no longer called from the Your Tools page +# consider removing as part of further refactoring def get_helm_entries(): # Update repository metadata. update_helm_repository() diff --git a/controlpanel/api/migrations/0050_tool_is_deprecated.py b/controlpanel/api/migrations/0050_tool_is_deprecated.py new file mode 100644 index 000000000..d87c3bf1f --- /dev/null +++ b/controlpanel/api/migrations/0050_tool_is_deprecated.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.2 on 2024-11-29 16:25 + +# Third-party +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0049_alter_feedback_suggestions"), + ] + + operations = [ + migrations.AddField( + model_name="tool", + name="is_deprecated", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="tool", + name="deprecated_message", + field=models.TextField( + blank=True, help_text="If no message is provided, a default message will be used." + ), + ), + migrations.AddField( + model_name="tool", + name="is_retired", + field=models.BooleanField(default=False), + ), + ] diff --git a/controlpanel/api/migrations/0051_tool_image_tag.py b/controlpanel/api/migrations/0051_tool_image_tag.py new file mode 100644 index 000000000..6be08f309 --- /dev/null +++ b/controlpanel/api/migrations/0051_tool_image_tag.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2024-12-10 09:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0050_tool_is_deprecated"), + ] + + operations = [ + migrations.AddField( + model_name="tool", + name="image_tag", + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/controlpanel/api/migrations/0052_add_image_tag_value.py b/controlpanel/api/migrations/0052_add_image_tag_value.py new file mode 100644 index 000000000..a058e6f83 --- /dev/null +++ b/controlpanel/api/migrations/0052_add_image_tag_value.py @@ -0,0 +1,26 @@ +# Generated by Django 5.1.2 on 2024-12-10 09:01 + +from django.db import migrations + + +def add_image_tag(apps, schema_editor): + Tool = apps.get_model("api", "Tool") + for tool in Tool.objects.all(): + chart_image_key_name = tool.chart_name.split("-")[0] + values = tool.values or {} + image_tag = values.get("{}.tag".format(chart_image_key_name)) or values.get( + "{}.image.tag".format(chart_image_key_name) + ) + tool.image_tag = image_tag + tool.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0051_tool_image_tag"), + ] + + operations = [ + migrations.RunPython(code=add_image_tag, reverse_code=migrations.RunPython.noop), + ] diff --git a/controlpanel/api/migrations/0053_alter_tool_image_tag.py b/controlpanel/api/migrations/0053_alter_tool_image_tag.py new file mode 100644 index 000000000..a0903be6d --- /dev/null +++ b/controlpanel/api/migrations/0053_alter_tool_image_tag.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.2 on 2024-12-10 09:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0052_add_image_tag_value"), + ] + + operations = [ + migrations.AlterField( + model_name="tool", + name="image_tag", + field=models.CharField(default="", max_length=100), + preserve_default=False, + ), + ] diff --git a/controlpanel/api/migrations/0054_alter_tool_description.py b/controlpanel/api/migrations/0054_alter_tool_description.py new file mode 100644 index 000000000..235c92833 --- /dev/null +++ b/controlpanel/api/migrations/0054_alter_tool_description.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2024-12-11 14:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0053_alter_tool_image_tag"), + ] + + operations = [ + migrations.AlterField( + model_name="tool", + name="description", + field=models.TextField(), + ), + ] diff --git a/controlpanel/api/models/tool.py b/controlpanel/api/models/tool.py index d090967ea..6eddfe883 100644 --- a/controlpanel/api/models/tool.py +++ b/controlpanel/api/models/tool.py @@ -27,8 +27,18 @@ class Tool(TimeStampedModel): "rstudio": "rstudio", "vscode": "vscode", } - - description = models.TextField(blank=True) + DEFAULT_DEPRECATED_MESSAGE = "The selected release has been deprecated and will be retired soon. Please update to a more recent version." # noqa + JUPYTER_DATASCIENCE_CHART_NAME = "jupyter-lab-datascience-notebook" + JUPYTER_ALL_SPARK_CHART_NAME = "jupyter-lab-all-spark" + JUPYTER_LAB_CHART_NAME = "jupyter-lab" + RSTUDIO_CHART_NAME = "rstudio" + VSCODE_CHART_NAME = "vscode" + STATUS_RETIRED = "retired" + STATUS_DEPRECATED = "deprecated" + STATUS_ACTIVE = "active" + STATUS_RESTRICTED = "restricted" + + description = models.TextField(blank=False) chart_name = models.CharField(max_length=100, blank=False) name = models.CharField(max_length=100, blank=False) values = JSONField(default=dict) @@ -51,6 +61,13 @@ class Tool(TimeStampedModel): default=None, ) + is_deprecated = models.BooleanField(default=False) + deprecated_message = models.TextField( + blank=True, help_text="If no message is provided, a default message will be used." + ) + is_retired = models.BooleanField(default=False) + image_tag = models.CharField(max_length=100) + class Meta(TimeStampedModel.Meta): db_table = "control_panel_api_tool" ordering = ("name",) @@ -65,6 +82,8 @@ def url(self, user): def save(self, *args, **kwargs): helm.update_helm_repository(force=True) + # TODO description is now required when creating a release, so this is unlikely to be called + # Consider removing if not self.description: self.description = helm.get_chart_app_version(self.chart_name, self.version) or "" @@ -72,12 +91,45 @@ def save(self, *args, **kwargs): return self @property - def image_tag(self): - chart_image_key_name = self.chart_name.split("-")[0] - values = self.values or {} - return values.get("{}.tag".format(chart_image_key_name)) or values.get( - "{}.image.tag".format(chart_image_key_name) - ) + def get_deprecated_message(self): + if not self.is_deprecated: + return "" + + if self.is_retired: + return "" + + return self.deprecated_message or self.DEFAULT_DEPRECATED_MESSAGE + + @property + def image_tag_key(self): + mapping = { + self.JUPYTER_DATASCIENCE_CHART_NAME: "jupyter.tag", + self.JUPYTER_ALL_SPARK_CHART_NAME: "jupyter.tag", + self.JUPYTER_LAB_CHART_NAME: "jupyterlab.image.tag", + self.RSTUDIO_CHART_NAME: "rstudio.image.tag", + self.VSCODE_CHART_NAME: "vscode.image.tag", + } + return mapping[self.chart_name] + + @property + def status(self): + if self.is_retired: + return self.STATUS_RETIRED.capitalize() + if self.is_deprecated: + return self.STATUS_DEPRECATED.capitalize() + if self.is_restricted: + return self.STATUS_RESTRICTED.capitalize() + return self.STATUS_ACTIVE.capitalize() + + @property + def status_colour(self): + mapping = { + self.STATUS_RETIRED: "red", + self.STATUS_DEPRECATED: "grey", + self.STATUS_RESTRICTED: "yellow", + self.STATUS_ACTIVE: "green", + } + return mapping[self.status.lower()] class ToolDeploymentManager: diff --git a/controlpanel/frontend/consumers.py b/controlpanel/frontend/consumers.py index f34c7f256..b51fdbdb8 100644 --- a/controlpanel/frontend/consumers.py +++ b/controlpanel/frontend/consumers.py @@ -196,7 +196,7 @@ def tool_restart(self, message): log.debug(f"Restarted {tool.name} for {user}") def get_tool_and_user(self, message): - tool = Tool.objects.get(pk=message["tool_id"]) + tool = Tool.objects.get(is_retired=False, pk=message["tool_id"]) if not tool: raise Exception(f"no Tool record found for query {message['tool_id']}") user = User.objects.get(auth0_id=message["user_id"]) diff --git a/controlpanel/frontend/filters.py b/controlpanel/frontend/filters.py index 5c2fd0b0a..ed3430256 100644 --- a/controlpanel/frontend/filters.py +++ b/controlpanel/frontend/filters.py @@ -5,23 +5,67 @@ from controlpanel.api.models.tool import Tool -class ReleaseFilter(django_filters.FilterSet): +class InitialFilterSetMixin(django_filters.FilterSet): + + def __init__(self, data=None, queryset=None, *, request=None, prefix=None): + # if filterset is bound, use initial values as defaults + if data is not None: + # get a mutable copy of the QueryDict + data = data.copy() + + for name, f in self.base_filters.items(): + initial = f.extra.get("initial") + + # filter param is either missing or empty, use initial as default + if not data.get(name) and initial: + data[name] = initial + + super().__init__(data, queryset, request=request, prefix=prefix) + + +class ReleaseFilter(InitialFilterSetMixin): + YES_NO_CHOICES = [("all", "---------"), ("true", "Yes"), ("false", "No")] chart_name = django_filters.ChoiceFilter() - is_restricted = django_filters.BooleanFilter(label="Restricted release?") + # is_restricted = django_filters.BooleanFilter(label="Restricted release?") + status = django_filters.ChoiceFilter( + choices=[ + (Tool.STATUS_ACTIVE, Tool.STATUS_ACTIVE.capitalize()), + (Tool.STATUS_RESTRICTED, Tool.STATUS_RESTRICTED.capitalize()), + (Tool.STATUS_DEPRECATED, Tool.STATUS_DEPRECATED.capitalize()), + (Tool.STATUS_RETIRED, Tool.STATUS_RETIRED.capitalize()), + ("all", "All"), + ], + method="filter_status", + label="Status", + empty_label=None, + initial="active", + ) class Meta: model = Tool - fields = ["chart_name", "is_restricted"] + fields = [ + "chart_name", + ] - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) self.filters["chart_name"].extra["choices"] = ( Tool.objects.values_list("chart_name", "chart_name").order_by().distinct() ) self.filters["chart_name"].field.widget.attrs = {"class": "govuk-select"} - self.filters["is_restricted"].field.widget.choices = [ - ("all", "---------"), - ("true", "Yes"), - ("false", "No"), - ] - self.filters["is_restricted"].field.widget.attrs = {"class": "govuk-select"} + self.filters["status"].field.widget.attrs = {"class": "govuk-select"} + + def filter_status(self, queryset, name, value): + if value == "all": + return queryset + if value == "retired": + return queryset.filter(is_retired=True) + # remove retired tools from the list + queryset = queryset.filter(is_retired=False) + if value == "active": + return queryset.filter(is_restricted=False, is_deprecated=False) + return queryset.filter( + **{ + f"is_{value}": True, + } + ) diff --git a/controlpanel/frontend/forms.py b/controlpanel/frontend/forms.py index 65d2e93b2..301c903f4 100644 --- a/controlpanel/frontend/forms.py +++ b/controlpanel/frontend/forms.py @@ -540,10 +540,14 @@ class Meta: "name", "chart_name", "version", + "image_tag", "values", "is_restricted", "tool_domain", "description", + "is_deprecated", + "deprecated_message", + "is_retired", ] diff --git a/controlpanel/frontend/jinja2/release-create.html b/controlpanel/frontend/jinja2/release-create.html index b63ed43e3..88ff38fc9 100644 --- a/controlpanel/frontend/jinja2/release-create.html +++ b/controlpanel/frontend/jinja2/release-create.html @@ -29,7 +29,7 @@
Status:
- {% if deployment %}
+ {% if deployment and not deployment.is_retired %}
{{ deployment.status | default("") }}
{% else %}
Not deployed
@@ -86,7 +92,8 @@ {{ tool_info.name }}
onclick="window.open('{{ tool_info['url'] }}', '_blank');"
rel="noopener"
target="_blank"
- {% if not deployment %} disabled {% endif %}>
+ id="open-{{ chart_name }}"
+ {% if not deployment or deployment.is_retired %} disabled {% endif %}>
Open
@@ -96,24 +103,29 @@ {{ tool_info.name }}
{% endif %}
data-action-name="restart"
method="post"
- style="display: inline;">
+ style="display: inline;"
+ >
{{ csrf_input }}
- Your current deployment ({{ deployment.chart_name}}-{{ deployment.chart_version }}: {{ deployment.image_tag }}) - is not recognised as a current maintained tool release. You can still use it, - but it is recommended to switch to a new version from the dropdown list. -
+ + +{% if deployment and deployment.is_retired %} + +