diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index 81e233523..7d157e103 100644 --- a/src/apps/api/serializers/competitions.py +++ b/src/apps/api/serializers/competitions.py @@ -242,6 +242,9 @@ class Meta: 'allow_robot_submissions', 'competition_type', 'fact_sheet', + 'reward', + 'contact_email', + 'report', ) def validate_phases(self, phases): @@ -330,6 +333,9 @@ class Meta: 'competition_type', 'fact_sheet', 'forum', + 'reward', + 'contact_email', + 'report', ) def get_leaderboards(self, instance): @@ -359,6 +365,9 @@ class Meta: 'logo', 'description', 'competition_type', + 'reward', + 'contact_email', + 'report', ) diff --git a/src/apps/api/serializers/submissions.py b/src/apps/api/serializers/submissions.py index db4b91e91..35800f300 100644 --- a/src/apps/api/serializers/submissions.py +++ b/src/apps/api/serializers/submissions.py @@ -16,6 +16,7 @@ from utils.data import make_url_sassy from tasks.models import Task +from queues.models import Queue class SubmissionScoreSerializer(serializers.ModelSerializer): @@ -113,6 +114,7 @@ class SubmissionCreationSerializer(DefaultUserCreateMixin, serializers.ModelSeri filename = serializers.SerializerMethodField(read_only=True) tasks = serializers.PrimaryKeyRelatedField(queryset=Task.objects.all(), required=False, write_only=True, many=True) phase = serializers.PrimaryKeyRelatedField(queryset=Phase.objects.all(), required=True) + queue = serializers.PrimaryKeyRelatedField(queryset=Queue.objects.all(), required=False, allow_null=True) class Meta: model = Submission @@ -130,6 +132,7 @@ class Meta: 'tasks', 'fact_sheet_answers', 'organization', + 'queue' ) extra_kwargs = { 'secret': {"write_only": True}, @@ -181,10 +184,16 @@ def validate(self, attrs): return data def update(self, submission, validated_data): - # TODO: Test, could you change the phase of a submission? + + # Cannot change submission if secret key is not valid if submission.secret != validated_data.get('secret'): raise PermissionDenied("Submission secret invalid") + # Task of a submission cannot be updated + if "task" in validated_data: + raise PermissionDenied("Task of a submission cannot be update") + + # Update status if it is there in validated data if "status" in validated_data: # Received a status update, let the frontend know from channels.layers import get_channel_layer diff --git a/src/apps/api/tests/test_leaderboards.py b/src/apps/api/tests/test_leaderboards.py index 5d54ab0e9..5455ef173 100644 --- a/src/apps/api/tests/test_leaderboards.py +++ b/src/apps/api/tests/test_leaderboards.py @@ -23,7 +23,18 @@ def test_getting_many_submissions_doesnt_cause_too_many_queries(self): assert resp.status_code == 200 -# TODO: Test listing all leaderboards isn't a thing? +class LeaderboardTest(APITestCase): + def setUp(self): + leaderboard1 = factories.LeaderboardFactory() + leaderboard2 = factories.LeaderboardFactory() + _ = factories.ColumnFactory(leaderboard=leaderboard1, index=0) + _ = factories.ColumnFactory(leaderboard=leaderboard2, index=0) + + def test_get_all_leaderboards(self): + url = reverse('leaderboard-list') + resp = self.client.get(url) + assert resp.status_code == 200 + assert resp.data == [] class HiddenLeaderboardTests(APITestCase): diff --git a/src/apps/api/tests/test_submissions.py b/src/apps/api/tests/test_submissions.py index 0d8e54bc6..e91a64ecb 100644 --- a/src/apps/api/tests/test_submissions.py +++ b/src/apps/api/tests/test_submissions.py @@ -243,6 +243,34 @@ def test_who_can_see_detailed_result_when_visualization_is_true(self): assert resp.status_code == 403 +class SubmissionUpdateTest(APITestCase): + def setUp(self): + self.user = UserFactory(username='test') + self.task1 = TaskFactory(created_by=self.user) + self.task2 = TaskFactory(created_by=self.user) + self.competition = CompetitionFactory(created_by=self.user) + self.phase = PhaseFactory(competition=self.competition, tasks=[self.task1]) + self.secret = '7df3600c-1234-5678-bbc8-bbe91f42d875' + self.submission = SubmissionFactory( + task=self.task1, + phase=self.phase, + status=Submission.FINISHED, + secret=self.secret + ) + + def test_submission_task_update(self): + url = reverse('submission-detail', args=(self.submission.pk,)) + + # Update task + resp = self.client.patch(url, { + "task": self.task2.id, + "secret": self.secret + }) + assert resp.status_code == 403 + assert resp.data["detail"] == "Submission task cannot be updated" + assert self.submission.task.id == self.task1.id # task not updated + + class OrganizationSubmissionTests(APITestCase): def setUp(self): # Competition and creator diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index 8fcb69a4b..e1b74758f 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -409,11 +409,15 @@ def results(self, request, pk, format=None): raise PermissionDenied("You are not a competition admin or superuser") selected_phase = request.GET.get('phase') data = self.collect_leaderboard_data(competition, selected_phase) - if format == 'zip': with SpooledTemporaryFile() as tmp: with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as archive: for leaderboard in data: + + # Check if the leaderboard is empty + if not data[leaderboard]: + continue + stringIO = StringIO() columns = list(data[leaderboard][list(data[leaderboard].keys())[0]]) dict_writer = csv.DictWriter(stringIO, fieldnames=(["Username", "fact_sheet_answers"] + columns)) @@ -437,6 +441,14 @@ def results(self, request, pk, format=None): elif len(data) == 0: raise ValidationError("No Matching Leaderboard") leaderboard_title = list(data.keys())[0] + + # Check if the leaderboard data is empty + if not data[leaderboard_title]: + # Return an empty CSV + response = HttpResponse(content_type='text/csv') + response['Content-Disposition'] = f'attachment; filename="{leaderboard_title}.csv"' + return response + response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = f'attachment; filename="{leaderboard_title}.csv"' columns = list(data[leaderboard_title][list(data[leaderboard_title].keys())[0]].keys()) diff --git a/src/apps/api/views/leaderboards.py b/src/apps/api/views/leaderboards.py index 315b1c1ff..2b7ed2524 100644 --- a/src/apps/api/views/leaderboards.py +++ b/src/apps/api/views/leaderboards.py @@ -1,5 +1,6 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ModelViewSet +from rest_framework.response import Response from api.permissions import LeaderboardNotHidden, LeaderboardIsOrganizerOrCollaborator from api.serializers.leaderboards import LeaderboardEntriesSerializer from api.serializers.submissions import SubmissionScoreSerializer @@ -23,6 +24,10 @@ def get_permissions(self): return [permission() for permission in self.permission_classes] + def list(self, request, *args, **kwargs): + # Return an empty list for the leaderboard-list endpoint + return Response([]) + class SubmissionScoreViewSet(ModelViewSet): queryset = SubmissionScore.objects.all() diff --git a/src/apps/api/views/submissions.py b/src/apps/api/views/submissions.py index 896b4731e..7e6b5970f 100644 --- a/src/apps/api/views/submissions.py +++ b/src/apps/api/views/submissions.py @@ -53,6 +53,8 @@ def check_object_permissions(self, request, obj): try: if request.data.get('secret') is None or uuid.UUID(request.data.get('secret')) != obj.secret: raise PermissionDenied("Submission secrets do not match") + if request.data.get('task'): + raise PermissionDenied("Submission task cannot be updated") except TypeError: raise ValidationError(f"Secret: ({request.data.get('secret')}) not a valid UUID") diff --git a/src/apps/competitions/migrations/0026_auto_20201110_1932.py b/src/apps/competitions/migrations/0026_auto_20201110_1932.py index b6496c0ba..eb23c9663 100644 --- a/src/apps/competitions/migrations/0026_auto_20201110_1932.py +++ b/src/apps/competitions/migrations/0026_auto_20201110_1932.py @@ -1,18 +1,19 @@ # Generated by Django 2.2.13 on 2020-11-10 19:32 +# Manual edit on 2023-08-22 by Adrien Pavao import django.contrib.postgres.fields.jsonb from django.db import migrations -from competitions.models import Competition +def set_blank_fact_sheet_to_null(apps, schema_editor): + Competition = apps.get_model('competitions', 'Competition') + for comp in Competition.objects.all(): + if comp.fact_sheet == "": + comp.fact_sheet = None + comp.save() -class Migration(migrations.Migration): - def set_blank_fact_sheet_to_null(self, schema_editor): - for comp in Competition.objects.all(): - if comp.fact_sheet == "": - comp.fact_sheet = None - comp.save() +class Migration(migrations.Migration): dependencies = [ ('competitions', '0025_submission_is_specific_task_re_run'), @@ -24,5 +25,5 @@ def set_blank_fact_sheet_to_null(self, schema_editor): name='fact_sheet', field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=None, max_length=4096, null=True), ), - migrations.RunPython(set_blank_fact_sheet_to_null), + migrations.RunPython(set_blank_fact_sheet_to_null, migrations.RunPython.noop), ] diff --git a/src/apps/competitions/migrations/0035_auto_20230806_0715.py b/src/apps/competitions/migrations/0035_auto_20230806_0715.py new file mode 100644 index 000000000..68b33c11d --- /dev/null +++ b/src/apps/competitions/migrations/0035_auto_20230806_0715.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.17 on 2023-08-06 07:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0034_auto_20230727_1147'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='contact_email', + field=models.EmailField(blank=True, max_length=256, null=True), + ), + migrations.AddField( + model_name='competition', + name='report', + field=models.CharField(blank=True, max_length=256, null=True), + ), + migrations.AddField( + model_name='competition', + name='reward', + field=models.CharField(blank=True, max_length=256, null=True), + ), + ] diff --git a/src/apps/competitions/migrations/0036_submission_queue.py b/src/apps/competitions/migrations/0036_submission_queue.py new file mode 100644 index 000000000..8145013b5 --- /dev/null +++ b/src/apps/competitions/migrations/0036_submission_queue.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.17 on 2023-08-24 07:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('queues', '0001_initial'), + ('competitions', '0035_auto_20230806_0715'), + ] + + operations = [ + migrations.AddField( + model_name='submission', + name='queue', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submissions', to='queues.Queue'), + ), + ] diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index b30988106..f1b17412a 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -54,6 +54,10 @@ class Competition(ChaHubSaveMixin, models.Model): fact_sheet = JSONField(blank=True, null=True, max_length=4096, default=None) + contact_email = models.EmailField(max_length=256, null=True, blank=True) + reward = models.CharField(max_length=256, null=True, blank=True) + report = models.CharField(max_length=256, null=True, blank=True) + def __str__(self): return f"competition-{self.title}-{self.pk}-{self.competition_type}" @@ -456,6 +460,8 @@ class Submission(ChaHubSaveMixin, models.Model): is_public = models.BooleanField(default=False) is_specific_task_re_run = models.BooleanField(default=False) worker_hostname = models.CharField(max_length=255, blank=True, null=True) + queue = models.ForeignKey('queues.Queue', on_delete=models.SET_NULL, null=True, blank=True, + related_name='submissions') is_migrated = models.BooleanField(default=False) created_by_migration = models.ForeignKey(Phase, related_name='migrated_submissions', on_delete=models.CASCADE, null=True, diff --git a/src/apps/competitions/unpackers/v2.py b/src/apps/competitions/unpackers/v2.py index 4f5488f22..bce1e3bf5 100644 --- a/src/apps/competitions/unpackers/v2.py +++ b/src/apps/competitions/unpackers/v2.py @@ -18,6 +18,8 @@ def __init__(self, *args, **kwargs): "description": self.competition_yaml.get("description", ""), "competition_type": self.competition_yaml.get("competition_type", "competition"), "fact_sheet": self.competition_yaml.get("fact_sheet", None), + "reward": self.competition_yaml.get("reward", None), + "contact_email": self.competition_yaml.get("contact_email", None), "pages": [], "phases": [], "leaderboards": [], diff --git a/src/apps/pages/views.py b/src/apps/pages/views.py index bf32519b7..a555e80fa 100644 --- a/src/apps/pages/views.py +++ b/src/apps/pages/views.py @@ -1,6 +1,4 @@ from datetime import timedelta - -from django.http import HttpResponse from django.utils.timezone import now from django.views.generic import TemplateView from django.db.models import Count, Q @@ -59,15 +57,29 @@ class ServerStatusView(TemplateView): template_name = 'pages/server_status.html' def get_context_data(self, *args, **kwargs): - if not self.request.user.is_staff: - raise HttpResponse(status=404) show_child_submissions = self.request.GET.get('show_child_submissions', False) + # Get all submissions qs = Submission.objects.all() + + # If user is not super user then: + # filter this user's own submissions + # and + # submissions running on queue which belongs to this user + if not self.request.user.is_superuser: + qs = qs.filter( + Q(owner=self.request.user) | + Q(phase__competition__queue__isnull=False, phase__competition__queue__owner=self.request.user) + ) + + # filter for fetching last 2 days submissions qs = qs.filter(created_when__gte=now() - timedelta(days=2)) + + # filter out child submissions i.e. submission has no parent if not show_child_submissions: qs = qs.filter(parent__isnull=True) + qs = qs.order_by('-created_when') qs = qs.select_related('phase__competition', 'owner') @@ -78,8 +90,8 @@ def get_context_data(self, *args, **kwargs): for submission in context['submissions']: # Get filesize from each submissions's data submission.file_size = self.format_file_size(submission.data.file_size) - # Get queue from each submission's competition - queue_name = "*" if submission.phase.competition.queue is None else submission.phase.competition.queue.name + # Get queue from each submission + queue_name = "*" if submission.queue is None else submission.queue.name submission.competition_queue = queue_name return context diff --git a/src/static/img/trophy.png b/src/static/img/trophy.png new file mode 100644 index 000000000..c810f3408 Binary files /dev/null and b/src/static/img/trophy.png differ diff --git a/src/static/riot/competitions/detail/_header.tag b/src/static/riot/competitions/detail/_header.tag index 3b34c066f..7b1314c40 100644 --- a/src/static/riot/competitions/detail/_header.tag +++ b/src/static/riot/competitions/detail/_header.tag @@ -15,6 +15,12 @@ +
+