Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/apps/api/serializers/submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class Meta:
'task',
'auto_run',
'can_make_submissions_public',
'is_soft_deleted',
)
read_only_fields = (
'pk',
Expand All @@ -67,7 +68,10 @@ class Meta:
)

def get_filename(self, instance):
return basename(instance.data.data_file.name)
if instance.data and instance.data.data_file:
return basename(instance.data.data_file.name)
# NOTE: if submission data is None, it means it is soft deleted
return "Deleted File"

def get_auto_run(self, instance):
# returns this submission's competition auto_run_submissions Flag
Expand Down
97 changes: 96 additions & 1 deletion src/apps/api/tests/test_submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from competitions.models import Submission, CompetitionParticipant
from factories import UserFactory, CompetitionFactory, PhaseFactory, CompetitionParticipantFactory, SubmissionFactory, \
TaskFactory, OrganizationFactory, DataFactory
TaskFactory, OrganizationFactory, DataFactory, LeaderboardFactory

from datasets.models import Data
from profiles.models import Membership
Expand Down Expand Up @@ -544,3 +544,98 @@ def test_cannot_re_run_submissions_with_specific_task_without_bot_user(self):
resp = self.client.post(url)
assert resp.status_code == 403
assert resp.data["detail"] == "You do not have permission to re-run submissions"


class SubmissionSoftDeletionTest(APITestCase):
def setUp(self):
self.creator = UserFactory(username='creator', password='creator')
self.participant = UserFactory(username='participant', password='participant')

self.leaderboard = LeaderboardFactory()
self.comp = CompetitionFactory(created_by=self.creator)
self.phase = PhaseFactory(competition=self.comp)

# Approved participant
CompetitionParticipantFactory(user=self.participant, competition=self.comp, status=CompetitionParticipant.APPROVED)

# Submissions
self.submission = SubmissionFactory(
phase=self.phase,
owner=self.participant,
status=Submission.FINISHED,
is_soft_deleted=False,
leaderboard=None
)

self.leaderboard_submission = SubmissionFactory(
phase=self.phase,
owner=self.participant,
status=Submission.FINISHED,
is_soft_deleted=False,
leaderboard=self.leaderboard
)

self.running_submission = SubmissionFactory(
phase=self.phase,
owner=self.participant,
status=Submission.SUBMITTED,
is_soft_deleted=False,
leaderboard=None
)

self.soft_deleted_submission = SubmissionFactory(
phase=self.phase,
owner=self.participant,
status=Submission.FINISHED,
is_soft_deleted=True,
leaderboard=None
)

def test_cannot_delete_submission_if_not_owner(self):
"""Ensure that a non-owner cannot soft delete a submission."""
self.client.login(username="other_user", password="other")
url = reverse("submission-soft-delete", args=[self.submission.pk])
resp = self.client.delete(url)

assert resp.status_code == 403
assert resp.data["error"] == "You are not allowed to delete this submission"

def test_cannot_delete_leaderboard_submission(self):
"""Ensure that a leaderboard submission cannot be deleted."""
self.client.login(username="participant", password="participant")
url = reverse("submission-soft-delete", args=[self.leaderboard_submission.pk])
resp = self.client.delete(url)

assert resp.status_code == 403
assert resp.data["error"] == "You are not allowed to delete a leaderboard submission"

def test_cannot_delete_running_submission(self):
"""Ensure that a running submission cannot be deleted."""
self.client.login(username="participant", password="participant")
url = reverse("submission-soft-delete", args=[self.running_submission.pk])
resp = self.client.delete(url)

assert resp.status_code == 403
assert resp.data["error"] == "You are not allowed to delete a running submission"

def test_cannot_delete_already_soft_deleted_submission(self):
"""Ensure that an already soft-deleted submission cannot be deleted again."""
self.client.login(username="participant", password="participant")
url = reverse("submission-soft-delete", args=[self.soft_deleted_submission.pk])
resp = self.client.delete(url)

assert resp.status_code == 400
assert resp.data["error"] == "Submission already deleted"

def test_can_soft_delete_submission_successfully(self):
"""Ensure a valid submission can be soft deleted successfully by its owner."""
self.client.login(username="participant", password="participant")
url = reverse("submission-soft-delete", args=[self.submission.pk])
resp = self.client.delete(url)

assert resp.status_code == 200
assert resp.data["message"] == "Submission deleted successfully"

# Refresh from DB to verify
self.submission.refresh_from_db()
assert self.submission.is_soft_deleted is True
2 changes: 1 addition & 1 deletion src/apps/api/views/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def check_delete_permissions(self, request, dataset):
if dataset.submission.first():
sub = dataset.submission.first()
if sub.phase:
return 'Cannot delete submission: submission belongs to an existing competition'
return 'Cannot delete submission: submission belongs to an existing competition. Please visit the competition and delete your submission from there.'


class DataGroupViewSet(ModelViewSet):
Expand Down
38 changes: 37 additions & 1 deletion src/apps/api/views/submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class SubmissionViewSet(ModelViewSet):
queryset = Submission.objects.all().order_by('-pk')
permission_classes = []
filter_backends = (DjangoFilterBackend, SearchFilter)
filter_fields = ('phase__competition', 'phase', 'status')
filter_fields = ('phase__competition', 'phase', 'status', 'is_soft_deleted')
search_fields = ('data__data_file', 'description', 'name', 'owner__username')
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [renderers.CSVRenderer]

Expand Down Expand Up @@ -119,13 +119,21 @@ def get_queryset(self):
if not self.request.user.is_authenticated:
return Submission.objects.none()

# Check if admin is requesting to see soft-deleted submissions
show_is_soft_deleted = self.request.query_params.get('show_is_soft_deleted', 'false').lower() == 'true'

if not self.request.user.is_superuser and not self.request.user.is_staff and not self.request.user.is_bot:
# if you're the creator of the submission or a collaborator on the competition
qs = qs.filter(
Q(owner=self.request.user) |
Q(phase__competition__created_by=self.request.user) |
Q(phase__competition__collaborators__in=[self.request.user.pk])
).distinct()

# By default, exclude soft-deleted submissions unless explicitly requested by an admin
if not show_is_soft_deleted:
qs = qs.filter(is_soft_deleted=False)

qs = qs.select_related(
'phase',
'phase__competition',
Expand Down Expand Up @@ -179,6 +187,34 @@ def destroy(self, request, *args, **kwargs):
self.perform_destroy(submission)
return Response(status=status.HTTP_204_NO_CONTENT)

@action(detail=True, methods=('DELETE',))
def soft_delete(self, request, pk):
submission = self.get_object()

# Check if submission exists
if not submission:
return Response({'error': 'Submission not found'}, status=status.HTTP_404_NOT_FOUND)

# Check if owner is requesting soft delete
if submission.owner != request.user:
return Response({'error': 'You are not allowed to delete this submission'}, status=status.HTTP_403_FORBIDDEN)

# Check if submission is finished and on the leaderboard
if submission.status == Submission.FINISHED and submission.on_leaderboard:
return Response({'error': 'You are not allowed to delete a leaderboard submission'}, status=status.HTTP_403_FORBIDDEN)

# Check if submission is in running state
if submission.status not in [Submission.FAILED, Submission.FINISHED]:
return Response({'error': 'You are not allowed to delete a running submission'}, status=status.HTTP_403_FORBIDDEN)

# Check if submission is not already soft deleted
if submission.is_soft_deleted:
return Response({'error': 'Submission already deleted'}, status=status.HTTP_400_BAD_REQUEST)

# soft delete submission and return success response
submission.soft_delete()
return Response({'message': 'Submission deleted successfully'}, status=status.HTTP_200_OK)

@action(detail=False, methods=('DELETE',))
def delete_many(self, request, *args, **kwargs):
qs = self.get_queryset()
Expand Down
29 changes: 29 additions & 0 deletions src/apps/competitions/migrations/0052_auto_20250129_1058.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 2.2.28 on 2025-01-29 10:58

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('competitions', '0051_merge_20241203_1313'),
]

operations = [
migrations.AddField(
model_name='submission',
name='is_soft_deleted',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='submission',
name='soft_deleted_when',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='submission',
name='data',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submission', to='datasets.Data'),
),
]
40 changes: 39 additions & 1 deletion src/apps/competitions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ class Submission(ChaHubSaveMixin, models.Model):
status_details = models.TextField(null=True, blank=True)
phase = models.ForeignKey(Phase, related_name='submissions', on_delete=models.CASCADE)
appear_on_leaderboards = models.BooleanField(default=False)
data = models.ForeignKey("datasets.Data", on_delete=models.CASCADE, related_name='submission')
data = models.ForeignKey("datasets.Data", on_delete=models.SET_NULL, related_name='submission', null=True, blank=True)
md5 = models.CharField(max_length=32, null=True, blank=True)

prediction_result = models.FileField(upload_to=PathWrapper('prediction_result'), null=True, blank=True,
Expand Down Expand Up @@ -561,9 +561,47 @@ class Submission(ChaHubSaveMixin, models.Model):

fact_sheet_answers = JSONField(null=True, blank=True, max_length=4096)

# True when submission owner deletes a submission
is_soft_deleted = models.BooleanField(default=False)
# DataTime of when a submission is soft_deleted
soft_deleted_when = models.DateTimeField(null=True, blank=True)

def __str__(self):
return f"{self.phase.competition.title} submission PK={self.pk} by {self.owner.username}"

def soft_delete(self):
"""
Soft delete the submission: remove files but keep record in DB.
Also deletes associated SubmissionDetails and cleans up storage.
"""

# Remove related files from storage
# 'save=False' prevents a database save, which is handled later after marking the submission as soft-deleted.
self.prediction_result.delete(save=False)
self.prediction_result_file_size = 0
self.scoring_result.delete(save=False)
self.scoring_result_file_size = 0
self.detailed_result.delete(save=False)
self.detailed_result_file_size = 0

# Delete related SubmissionDetails files and records
for detail in self.details.all():
detail.data_file.delete(save=False) # Delete file from storage
detail.delete() # Remove record from DB

# Clear the data field if no other submissions are using it
other_submissions_using_data = Submission.objects.filter(data=self.data).exclude(pk=self.pk).exists()
if not other_submissions_using_data:
self.data.delete()

# Clear the data field for this submission
self.data = None

# Mark submission as deleted
self.is_soft_deleted = True
self.soft_deleted_when = now()
self.save()

def delete(self, **kwargs):

# Check if any other submissions are using the same data
Expand Down
Loading