Skip to content

Commit

Permalink
Feature/deprecate tool (#1404)
Browse files Browse the repository at this point in the history
* Add is_deprecated flag to Tool model

* Add field to mark release retired and add deprecation message

* Disable open tool button

* Handle retired and deprecated tools in the frontend

- If a tool is depcrecated, show the deprecation message when it is selected
- If a tool is retired, hide it and show a message that user must upgrade

* Display deprecation message as a warning

* Show retired message as a warning

* Disable buttons based on selected release

If a deployed release is selected, enable open and restart button.
If a undeployed release is selected, enable deploy button only.

* Replace image_tag property with model field

* Add image_tag to the release detail, create pages

Use the value stored in the DB when deploying the tool

* Make tool description a required field

* Fix queryset when looking form related tool

Previously all restricted tools were excluded. However, allowing tools
to be deprecated means we can update this logic. As some restricted tools
may not be deprecated, so should not display an "unsupported" message.

* Fix bug finding tools using rc chart version

* Filter tool releases by status in superuser view

* Add status tag in release admin, and allow filtering by status
  • Loading branch information
michaeljcollinsuk authored Dec 17, 2024
1 parent 93561bc commit 7044c8b
Show file tree
Hide file tree
Showing 22 changed files with 602 additions and 187 deletions.
18 changes: 18 additions & 0 deletions controlpanel/api/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions controlpanel/api/helm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
Expand Down
31 changes: 31 additions & 0 deletions controlpanel/api/migrations/0050_tool_is_deprecated.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
18 changes: 18 additions & 0 deletions controlpanel/api/migrations/0051_tool_image_tag.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
26 changes: 26 additions & 0 deletions controlpanel/api/migrations/0052_add_image_tag_value.py
Original file line number Diff line number Diff line change
@@ -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),
]
19 changes: 19 additions & 0 deletions controlpanel/api/migrations/0053_alter_tool_image_tag.py
Original file line number Diff line number Diff line change
@@ -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,
),
]
18 changes: 18 additions & 0 deletions controlpanel/api/migrations/0054_alter_tool_description.py
Original file line number Diff line number Diff line change
@@ -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(),
),
]
68 changes: 60 additions & 8 deletions controlpanel/api/models/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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",)
Expand All @@ -65,19 +82,54 @@ 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 ""

super().save(*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:
Expand Down
2 changes: 1 addition & 1 deletion controlpanel/frontend/consumers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
66 changes: 55 additions & 11 deletions controlpanel/frontend/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
)
4 changes: 4 additions & 0 deletions controlpanel/frontend/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,10 +540,14 @@ class Meta:
"name",
"chart_name",
"version",
"image_tag",
"values",
"is_restricted",
"tool_domain",
"description",
"is_deprecated",
"deprecated_message",
"is_retired",
]


Expand Down
Loading

0 comments on commit 7044c8b

Please sign in to comment.