Skip to content

Commit 99397c1

Browse files
committed
Merge branch 'main' into 4721-draft-request-reminders-not-send-often-enough
2 parents 9d8b6a7 + 7ae3005 commit 99397c1

11 files changed

Lines changed: 291 additions & 14 deletions

File tree

app/grandchallenge/algorithms/models.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1762,7 +1762,7 @@ class InvocationStatusChoices(TextChoices):
17621762
CANCELLED = "CANCELLED", _("Cancelled")
17631763

17641764

1765-
class Invocation(UUIDModel):
1765+
class Invocation(CIVForObjectMixin, UUIDModel):
17661766
StatusChoices = InvocationStatusChoices
17671767

17681768
endpoint = models.ForeignKey(Endpoint, on_delete=models.PROTECT)
@@ -1861,6 +1861,13 @@ def orchestrator_kwargs(self):
18611861
def orchestrator(self):
18621862
return EndpointOrchestrator(**self.orchestrator_kwargs)
18631863

1864+
@property
1865+
def is_editable(self):
1866+
if self.status == InvocationStatusChoices.VALIDATING_INPUTS:
1867+
return True
1868+
else:
1869+
return False
1870+
18641871
@cached_property
18651872
def inputs_complete(self):
18661873
# check if all inputs are present and if they all have a value
@@ -1894,3 +1901,31 @@ def update_status(
18941901
self.invoke_duration = invoke_duration
18951902

18961903
self.save()
1904+
1905+
def add_civ(self, *, civ):
1906+
super().add_civ(civ=civ)
1907+
return self.inputs.add(civ)
1908+
1909+
def remove_civ(self, *, civ):
1910+
super().remove_civ(civ=civ)
1911+
return self.inputs.remove(civ)
1912+
1913+
def get_civ_for_interface(self, interface):
1914+
return self.inputs.get(interface=interface)
1915+
1916+
def process_civ_data_objects_and_execute_linked_task(
1917+
self, *, civ_data_objects, user, linked_task=None
1918+
):
1919+
from grandchallenge.algorithms.tasks import (
1920+
execute_invocation_for_inputs,
1921+
)
1922+
1923+
linked_task = execute_invocation_for_inputs.signature(
1924+
kwargs={"invocation_pk": str(self.pk)}, immutable=True
1925+
)
1926+
1927+
return super().process_civ_data_objects_and_execute_linked_task(
1928+
civ_data_objects=civ_data_objects,
1929+
user=user,
1930+
linked_task=linked_task,
1931+
)

app/grandchallenge/algorithms/serializers.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
AlgorithmModel,
2222
Endpoint,
2323
Invocation,
24+
InvocationStatusChoices,
2425
Job,
2526
annotate_input_output_counts,
2627
)
@@ -358,6 +359,7 @@ class InvocationPostSerializer(serializers.ModelSerializer):
358359
view_name="api:algorithms-endpoint-detail",
359360
required=True,
360361
)
362+
status = CharField(source="get_status_display", read_only=True)
361363

362364
class Meta:
363365
model = Invocation
@@ -381,7 +383,7 @@ def __init__(self, *args, **kwargs):
381383
)
382384

383385
def validate(self, data):
384-
endpoint = data.pop("endpoint")
386+
endpoint = data["endpoint"]
385387
inputs = data.pop("inputs")
386388
data["algorithm_interface"] = (
387389
validate_inputs_and_return_matching_interface(
@@ -394,3 +396,32 @@ def validate(self, data):
394396
)
395397

396398
return data
399+
400+
def create(self, validated_data):
401+
civ_data_objects = validated_data.pop("civ_data_objects", [])
402+
403+
invocation = Invocation.objects.create(
404+
**validated_data,
405+
time_limit=settings.ALGORITHM_ENDPOINTS_MAXIMUM_INVOCATION_DURATION,
406+
status=InvocationStatusChoices.VALIDATING_INPUTS,
407+
)
408+
409+
try:
410+
invocation.process_civ_data_objects_and_execute_linked_task(
411+
civ_data_objects=civ_data_objects,
412+
user=self.context["request"].user,
413+
)
414+
except CIVNotEditableException as e:
415+
invocation.refresh_from_db()
416+
if invocation.status == invocation.StatusChoices.CANCELLED:
417+
# this can happen for jobs with multiple inputs
418+
# if one of them fails validation
419+
pass
420+
else:
421+
error_handler = invocation.get_error_handler()
422+
error_handler.handle_error(
423+
error_message=SystemErrorMessages.UNEXPECTED_ERROR,
424+
)
425+
logger.error(e, exc_info=True)
426+
427+
return invocation

app/grandchallenge/algorithms/tasks.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from grandchallenge.algorithms.exceptions import TooManyJobsScheduled
1313
from grandchallenge.components.schemas import GPUTypeChoices
1414
from grandchallenge.components.tasks import (
15+
provision_invocation_input_data,
1516
remove_container_image_from_registry,
1617
)
1718
from grandchallenge.core.celery import (
@@ -325,3 +326,31 @@ def deactivate_old_algorithm_images():
325326
}
326327
).apply_async
327328
)
329+
330+
331+
@acks_late_micro_short_task(retry_on=(LockNotAcquiredException,))
332+
@transaction.atomic
333+
def execute_invocation_for_inputs(*, invocation_pk):
334+
from grandchallenge.algorithms.models import Invocation
335+
336+
with check_lock_acquired():
337+
invocation = Invocation.objects.select_for_update(nowait=True).get(
338+
pk=invocation_pk
339+
)
340+
341+
if not invocation.inputs_complete:
342+
# Nothing to do
343+
return
344+
345+
if invocation.status != Invocation.StatusChoices.VALIDATING_INPUTS:
346+
# this task can be called multiple times with complete inputs,
347+
# and might have been queued for execution already, so ignore
348+
return
349+
350+
invocation.update_status(status=Invocation.StatusChoices.QUEUED)
351+
352+
on_commit(
353+
provision_invocation_input_data.signature(
354+
kwargs=invocation.task_kwargs
355+
).apply_async
356+
)

app/grandchallenge/components/models.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
DICOMImageSetUploadErrorHandler,
7474
EvaluationCIVErrorHandler,
7575
FallbackCIVValidationErrorHandler,
76+
InvocationCIVErrorHandler,
7677
JobCIVErrorHandler,
7778
RawImageUploadSessionErrorHandler,
7879
UserUploadCIVErrorHandler,
@@ -2841,7 +2842,7 @@ def get_current_value_for_interface(self, *, interface, user):
28412842

28422843
def get_error_handler(self, *, linked_object=None):
28432844
# local imports to prevent circular dependency
2844-
from grandchallenge.algorithms.models import Job
2845+
from grandchallenge.algorithms.models import Invocation, Job
28452846
from grandchallenge.archives.models import ArchiveItem
28462847
from grandchallenge.evaluation.models import Evaluation
28472848
from grandchallenge.reader_studies.models import DisplaySet
@@ -2860,6 +2861,8 @@ def get_error_handler(self, *, linked_object=None):
28602861
return JobCIVErrorHandler(job=self)
28612862
elif isinstance(self, Evaluation):
28622863
return EvaluationCIVErrorHandler(job=self)
2864+
elif isinstance(self, Invocation):
2865+
return InvocationCIVErrorHandler(invocation=self)
28632866
elif linked_object and isinstance(linked_object, UserUpload):
28642867
return UserUploadCIVErrorHandler(
28652868
user_upload=linked_object,

app/grandchallenge/core/error_handlers.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,41 @@ def handle_error(self, *, error_message, interface=None, user=None):
8686
)
8787

8888

89+
class InvocationCIVErrorHandler(ErrorHandler):
90+
"""
91+
Error handler for CIV validation errors on invocation creation.
92+
Handle_error() updates an algorithm endpoint invocation.
93+
"""
94+
95+
def __init__(self, *args, invocation, **kwargs):
96+
from grandchallenge.algorithms.models import Invocation
97+
98+
if not invocation or not isinstance(invocation, Invocation):
99+
raise RuntimeError(
100+
"You need to provide an Invocation instance to this error handler."
101+
)
102+
103+
super().__init__(*args, **kwargs)
104+
self._invocation = invocation
105+
106+
def handle_error(self, *, error_message, interface=None, user=None):
107+
if interface:
108+
detailed_error_message = copy.deepcopy(
109+
self._invocation.detailed_error_message
110+
)
111+
detailed_error_message[interface.title] = error_message
112+
self._invocation.update_status(
113+
status=self._invocation.StatusChoices.CANCELLED,
114+
error_message="One or more of the inputs failed validation.",
115+
detailed_error_message=detailed_error_message,
116+
)
117+
else:
118+
self._invocation.update_status(
119+
status=self._invocation.StatusChoices.CANCELLED,
120+
error_message=error_message,
121+
)
122+
123+
89124
class RawImageUploadSessionErrorHandler(ErrorHandler):
90125
"""
91126
Error handler for image imports and image validation.

app/grandchallenge/evaluation/admin.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
SubmissionGroupObjectPermission,
3535
SubmissionUserObjectPermission,
3636
)
37+
from grandchallenge.evaluation.utils import SubmissionKindChoices
38+
from grandchallenge.subdomains.utils import reverse
3739

3840

3941
class PhaseAdminForm(ModelForm):
@@ -100,13 +102,50 @@ class PhaseAdmin(admin.ModelAdmin):
100102
readonly_fields = (
101103
"give_algorithm_editors_job_view_permissions",
102104
"algorithm_interfaces",
105+
"algorithm_interface_configuration_link",
106+
"download_starter_kit_link",
103107
)
104108
form = PhaseAdminForm
105109

106110
@admin.display(boolean=True)
107111
def open_for_submissions(self, instance):
108112
return instance.open_for_submissions
109113

114+
@staticmethod
115+
def algorithm_interface_configuration_link(obj):
116+
if obj.submission_kind != SubmissionKindChoices.ALGORITHM:
117+
return "-"
118+
else:
119+
return format_html(
120+
'<a href="{link}">{link}</a>',
121+
link=reverse(
122+
"evaluation:interface-list",
123+
kwargs={
124+
"challenge_short_name": obj.challenge.short_name,
125+
"slug": obj.slug,
126+
},
127+
),
128+
)
129+
130+
@staticmethod
131+
def download_starter_kit_link(obj):
132+
if (
133+
obj.submission_kind != SubmissionKindChoices.ALGORITHM
134+
or not obj.algorithm_interfaces.exists()
135+
):
136+
return "-"
137+
else:
138+
return format_html(
139+
'<a href="{link}">{link}</a>',
140+
link=reverse(
141+
"evaluation:phase-starter-kit-download",
142+
kwargs={
143+
"challenge_short_name": obj.challenge.short_name,
144+
"slug": obj.slug,
145+
},
146+
),
147+
)
148+
110149

111150
@admin.action(
112151
description="Reevaluate selected submissions",

app/grandchallenge/evaluation/views/__init__.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,16 +1576,12 @@ def get_permission_object(self):
15761576
return self.phase
15771577

15781578

1579-
class PhaseStarterKitDownload(
1580-
CachedPhaseMixin,
1581-
ObjectPermissionRequiredMixin,
1582-
View,
1583-
):
1584-
permission_required = "evaluation.change_phase"
1585-
raise_exception = True
1586-
1587-
def get_permission_object(self):
1588-
return self.phase
1579+
class PhaseStarterKitDownload(UserPassesTestMixin, CachedPhaseMixin, View):
1580+
def test_func(self):
1581+
return (
1582+
self.request.user.has_perm("evaluation.change_phase", self.phase)
1583+
or self.request.user.is_staff
1584+
)
15891585

15901586
def get(self, *_, **__):
15911587
phase = self.phase
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Generated by Django 5.2.14 on 2026-05-22 11:23
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("challenges", "0071_alter_challengerequest_status_and_more"),
10+
("invoices", "0014_invoice_compute_costs_utilized_euros_millicents"),
11+
]
12+
13+
operations = [
14+
migrations.AddConstraint(
15+
model_name="invoice",
16+
constraint=models.CheckConstraint(
17+
condition=models.Q(
18+
models.Q(("payment_type", "POSTPAID"), _negated=True),
19+
models.Q(("payment_status", "INITIALIZED"), _negated=True),
20+
("follow_up_on__isnull", False),
21+
_connector="OR",
22+
),
23+
name="follow_up_on_required_for_initialized_post_paid",
24+
violation_error_message="Follow-up date is required for initialized post-paid invoices.",
25+
),
26+
),
27+
]

app/grandchallenge/invoices/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,13 @@ class Meta:
296296
| Q(follow_up_on__lte=Now() + timedelta(days=365)),
297297
violation_error_message="Follow-up date cannot be more than a year into the future.",
298298
),
299+
models.CheckConstraint(
300+
name="follow_up_on_required_for_initialized_post_paid",
301+
condition=~Q(payment_type=PaymentTypeChoices.POSTPAID)
302+
| ~Q(payment_status=PaymentStatusChoices.INITIALIZED)
303+
| Q(follow_up_on__isnull=False),
304+
violation_error_message="Follow-up date is required for initialized post-paid invoices.",
305+
),
299306
]
300307

301308
def delete(self, *args, **kwargs):

0 commit comments

Comments
 (0)