Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Newick tree support #3695

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
11 changes: 4 additions & 7 deletions app/grandchallenge/components/form_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,19 +162,16 @@ def get_file_field(self):
type = "uuid"

if type == "uuid":
ext = (
"json"
if self.instance.is_json_kind
else self.instance.kind.lower()
)
extra_help = f"{file_upload_text} .{ext}"
file_extensions = ", ".join(self.instance.file_extensions)
extra_help = f"{file_upload_text} {file_extensions}"
return ModelChoiceField(
queryset=get_objects_for_user(
self.user,
"uploads.change_userupload",
).filter(status=UserUpload.StatusChoices.COMPLETED),
widget=UserUploadSingleWidget(
allowed_file_types=self.instance.file_mimetypes
allowed_file_types=self.instance.file_mimetypes,
allowed_file_extensions=self.instance.file_extensions,
),
help_text=_join_with_br(self.help_text, extra_help),
**self.kwargs,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Generated by Django 4.2.16 on 2024-11-12 11:54

from django.db import migrations, models

import grandchallenge.components.models
import grandchallenge.core.storage
import grandchallenge.core.validators


class Migration(migrations.Migration):

dependencies = [
("components", "0021_alter_componentinterface_kind_and_more"),
]

operations = [
migrations.AlterField(
model_name="componentinterface",
name="kind",
field=models.CharField(
choices=[
("STR", "String"),
("INT", "Integer"),
("FLT", "Float"),
("BOOL", "Bool"),
("JSON", "Anything"),
("CHART", "Chart"),
("2DBB", "2D bounding box"),
("M2DB", "Multiple 2D bounding boxes"),
("DIST", "Distance measurement"),
("MDIS", "Multiple distance measurements"),
("POIN", "Point"),
("MPOI", "Multiple points"),
("POLY", "Polygon"),
("MPOL", "Multiple polygons"),
("LINE", "Line"),
("MLIN", "Multiple lines"),
("ANGL", "Angle"),
("MANG", "Multiple angles"),
("ELLI", "Ellipse"),
("MELL", "Multiple ellipses"),
("3ANG", "Three-point angle"),
("M3AN", "Multiple three-point angles"),
("ATRG", "Affine transform registration"),
("CHOI", "Choice"),
("MCHO", "Multiple choice"),
("IMG", "Image"),
("SEG", "Segmentation"),
("HMAP", "Heat Map"),
("DSPF", "Displacement field"),
("PDF", "PDF file"),
("SQREG", "SQREG file"),
("JPEG", "Thumbnail jpg"),
("PNG", "Thumbnail png"),
("OBJ", "OBJ file"),
("MP4", "MP4 file"),
("NEWCK", "Newick tree-format file"),
("CSV", "CSV file"),
("ZIP", "ZIP file"),
],
help_text="What is the type of this interface? Used to validate interface values and connections between components.",
max_length=5,
),
),
migrations.AlterField(
model_name="componentinterfacevalue",
name="file",
field=models.FileField(
blank=True,
null=True,
storage=grandchallenge.core.storage.ProtectedS3Storage(),
upload_to=grandchallenge.components.models.component_interface_value_path,
validators=[
grandchallenge.core.validators.ExtensionValidator(
allowed_extensions=(
".json",
".zip",
".csv",
".png",
".jpg",
".jpeg",
".pdf",
".sqreg",
".obj",
".mp4",
".newick",
)
),
grandchallenge.core.validators.MimeTypeValidator(
allowed_types=(
"application/json",
"application/zip",
"text/plain",
"application/csv",
"text/csv",
"application/pdf",
"image/png",
"image/jpeg",
"application/octet-stream",
"application/x-sqlite3",
"application/vnd.sqlite3",
"video/mp4",
)
),
],
),
),
]
45 changes: 37 additions & 8 deletions app/grandchallenge/components/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
validate_docker_image,
)
from grandchallenge.components.validators import (
validate_newick_tree_format,
validate_no_slash_at_ends,
validate_safe_path,
)
Expand Down Expand Up @@ -145,6 +146,7 @@ class InterfaceKindChoices(models.TextChoices):
THUMBNAIL_PNG = "PNG", _("Thumbnail png")
OBJ = "OBJ", _("OBJ file")
MP4 = "MP4", _("MP4 file")
NEWICK = "NEWCK", _("Newick tree-format file")

# Legacy support
CSV = "CSV", _("CSV file")
Expand Down Expand Up @@ -270,6 +272,7 @@ def interface_type_file():
* Thumbnail PNG
* OBJ file
* MP4 file
* Newick file
"""
return {
InterfaceKind.InterfaceKindChoices.CSV,
Expand All @@ -280,6 +283,7 @@ def interface_type_file():
InterfaceKind.InterfaceKindChoices.THUMBNAIL_PNG,
InterfaceKind.InterfaceKindChoices.OBJ,
InterfaceKind.InterfaceKindChoices.MP4,
InterfaceKind.InterfaceKindChoices.NEWICK,
}

@staticmethod
Expand All @@ -300,6 +304,7 @@ def interface_type_undisplayable():
InterfaceKind.InterfaceKindChoices.CSV,
InterfaceKind.InterfaceKindChoices.ZIP,
InterfaceKind.InterfaceKindChoices.OBJ,
InterfaceKind.InterfaceKindChoices.NEWICK,
}


Expand Down Expand Up @@ -534,7 +539,7 @@ def default_field(self):
return forms.JSONField

@property
def file_mimetypes(self):
def file_mimetypes(self): # noqa:C901
chrisvanrun marked this conversation as resolved.
Show resolved Hide resolved
if self.kind == InterfaceKind.InterfaceKindChoices.CSV:
return (
"application/csv",
Expand Down Expand Up @@ -565,9 +570,32 @@ def file_mimetypes(self):
)
elif self.kind == InterfaceKind.InterfaceKindChoices.MP4:
return ("video/mp4",)
elif self.kind == InterfaceKind.InterfaceKindChoices.NEWICK:
return ("text/x-nh", "application/octet-stream")
else:
raise RuntimeError(f"Unknown kind {self.kind}")

@property
def file_extensions(self):
exts = [self.file_extension]

if self.kind == InterfaceKind.InterfaceKindChoices.NEWICK:
exts.extend([".nwk", ".tree"])
chrisvanrun marked this conversation as resolved.
Show resolved Hide resolved

return exts

@property
def file_extension(self):
if self.is_json_kind:
return ".json"

if self.is_file_kind:
if self.kind == InterfaceKind.InterfaceKindChoices.NEWICK:
return ".newick"
return f".{self.kind.lower()}"
chrisvanrun marked this conversation as resolved.
Show resolved Hide resolved

raise RuntimeError(f"Unknown kind {self.kind}")
jmsmkn marked this conversation as resolved.
Show resolved Hide resolved
chrisvanrun marked this conversation as resolved.
Show resolved Hide resolved

def create_instance(self, *, image=None, value=None, fileobj=None):
civ = ComponentInterfaceValue.objects.create(interface=self)

Expand Down Expand Up @@ -633,14 +661,11 @@ def _clean_overlay_segments(self):
)

def _clean_relative_path(self):
if self.is_json_kind:
if not self.relative_path.endswith(".json"):
raise ValidationError("Relative path should end with .json")
elif self.is_file_kind and not self.relative_path.endswith(
f".{self.kind.lower()}"
):
if (
self.is_file_kind or self.is_json_kind
) and not self.relative_path.endswith(self.file_extension):
raise ValidationError(
f"Relative path should end with .{self.kind.lower()}"
f"Relative path should end with {self.file_extension}"
)

if self.is_image_kind:
Expand Down Expand Up @@ -1216,6 +1241,7 @@ class ComponentInterfaceValue(models.Model):
".sqreg",
".obj",
".mp4",
".newick",
)
),
MimeTypeValidator(
Expand Down Expand Up @@ -1431,6 +1457,9 @@ def validate_user_upload(self, user_upload):
except JSONDecodeError as e:
raise ValidationError(e)
self.interface.validate_against_schema(value=value)
elif self.interface.kind == InterfaceKindChoices.NEWICK:
validate_newick_tree_format(tree=user_upload.read_object())

self._user_upload_validated = True

def update_size_in_storage(self):
Expand Down
15 changes: 15 additions & 0 deletions app/grandchallenge/components/validators.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from Bio import Phylo
from django.core.exceptions import SuspiciousFileOperation, ValidationError
from django.utils._os import safe_join

Expand All @@ -20,3 +21,17 @@ def validate_safe_path(value):
def validate_no_slash_at_ends(value):
if value[0] == "/" or value[-1] == "/":
raise ValidationError("Path must not begin or end with '/'")


def validate_newick_tree_format(tree):
"""Validates a Newick tree by passing it through a validator"""
parser = Phylo.NewickIO.Parser.from_string(tree)

try:
has_tree = False
for _ in parser.parse():
has_tree = True
if not has_tree:
raise ValueError("No tree found")
chrisvanrun marked this conversation as resolved.
Show resolved Hide resolved
except Exception as e:
raise ValidationError("Invalid Newick tree format:", e)
3 changes: 2 additions & 1 deletion app/grandchallenge/components/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,8 @@ def get(self, request, *args, **kwargs):
UserUploadMultipleWidget.template_name,
{
"widget": UserUploadMultipleWidget(
allowed_file_types=self.interface.file_mimetypes
allowed_file_types=self.interface.file_mimetypes,
allowed_file_extensions=self.interface.file_extensions,
).get_context(
name=widget_name,
value=None,
Expand Down
13 changes: 10 additions & 3 deletions app/grandchallenge/uploads/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,24 @@ class UserUploadWidgetMixin:
template_name = "uploads/widget.html"
input_type = None

def __init__(self, *args, allowed_file_types=None, **kwargs):
def __init__(
self,
*args,
allowed_file_types=None,
allowed_file_extensions=None,
**kwargs,
):
super().__init__(*args, **kwargs)
self.allowed_file_types = allowed_file_types
self.allowed_file_types = allowed_file_types or []
self.allowed_file_extensions = allowed_file_extensions or []

def get_context(self, *args, **kwargs):
context = super().get_context(*args, **kwargs)
widget_id = f'X_{context["widget"]["attrs"]["id"]}'
context["widget"]["attrs"]["id"] = widget_id
context["widget"]["allowed_file_types"] = {
"id": f"{widget_id}AllowedFileTypes",
"value": self.allowed_file_types,
"value": [*self.allowed_file_types, *self.allowed_file_extensions],
chrisvanrun marked this conversation as resolved.
Show resolved Hide resolved
}
return context

Expand Down
24 changes: 15 additions & 9 deletions app/tests/components_tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ def test_average_duration_filtering():
(InterfaceKindChoices.THUMBNAIL_PNG, True, False),
(InterfaceKindChoices.OBJ, True, False),
(InterfaceKindChoices.MP4, True, False),
(InterfaceKindChoices.NEWICK, True, False),
),
)
def test_saved_in_object_store(kind, object_store_required, is_image):
Expand Down Expand Up @@ -220,6 +221,8 @@ def test_saved_in_object_store(kind, object_store_required, is_image):
(InterfaceKindChoices.SQREG, True),
(InterfaceKindChoices.THUMBNAIL_JPG, True),
(InterfaceKindChoices.THUMBNAIL_PNG, True),
(InterfaceKindChoices.MP4, True),
(InterfaceKindChoices.NEWICK, True),
),
)
def test_clean_store_in_db(kind, object_store_required):
Expand Down Expand Up @@ -265,18 +268,21 @@ def test_no_uuid_validation():

@pytest.mark.django_db
@pytest.mark.parametrize(
"kind",
"kind, good_suffix",
(
*InterfaceKind.interface_type_file(),
*InterfaceKind.interface_type_json(),
(InterfaceKind.InterfaceKindChoices.CSV, "csv"),
(InterfaceKind.InterfaceKindChoices.ZIP, "zip"),
(InterfaceKind.InterfaceKindChoices.PDF, "pdf"),
(InterfaceKind.InterfaceKindChoices.SQREG, "sqreg"),
(InterfaceKind.InterfaceKindChoices.THUMBNAIL_JPG, "jpeg"),
(InterfaceKind.InterfaceKindChoices.THUMBNAIL_PNG, "png"),
(InterfaceKind.InterfaceKindChoices.OBJ, "obj"),
(InterfaceKind.InterfaceKindChoices.MP4, "mp4"),
(InterfaceKind.InterfaceKindChoices.NEWICK, "newick"),
*((k, "json") for k in InterfaceKind.interface_type_json()),
),
)
def test_relative_path_file_ending(kind):
if kind in InterfaceKind.interface_type_json():
good_suffix = "json"
else:
good_suffix = kind.lower()

def test_relative_path_file_ending(kind, good_suffix):
i = ComponentInterfaceFactory(
kind=kind,
relative_path=f"foo/bar.{good_suffix}",
Expand Down
Loading