diff --git a/documentation/docs/Developers_and_Administrators/Administrator-procedures.md b/documentation/docs/Developers_and_Administrators/Administrator-procedures.md index 216477ee4..656446420 100644 --- a/documentation/docs/Developers_and_Administrators/Administrator-procedures.md +++ b/documentation/docs/Developers_and_Administrators/Administrator-procedures.md @@ -95,16 +95,34 @@ Once you log in an account with superuser privileges, you have access to the "Dj From this interface, you can change a user's quota, change their staff and superuser status, change the featured competitions displayed on the homepage, manage user accounts and more. -#### Edit announcement and news +#### Edit announcements -In the Django admin interface, click on `Announcements` or `New posts`: +In the Django admin interface, click on `Announcements`: -![](_attachments/de2a053f-c804-4c2a-a7ff-0df189c1d79c_17534366434691174.jpg) +![](_attachments/announcements.png) -For announcement, only the first announcement is read by the front page. For news, all objects are read as separate news. -You can create and edit objects using the interface. Write the announcement and news using HTML to format the text, add links, and more: +Every announcement marked **Is active** is shown on the front page, inside the "Announcement" box. Each announcement has: -![](_attachments/38c0d1ce-9b27-44bb-804b-c4eed55613df_17534366434285173.jpg) +- **Title** (optional): shown in bold above the text. +- **Level**: controls the style. + - `Critical`: red with an exclamation icon + - `Warning`: yellow with a warning icon + - `Info`: blue with an info icon + - `Plain`: plain text with no background or icon +- **Text**: the body, written in HTML. Links are shown bold and underlined in the announcement's color. +- **Is active**: untick to hide an announcement without deleting it. +- **Priority**: controls the order. Lower numbers are shown first; announcements with the same priority are shown newest first. + +You can create and edit announcements using the interface. Write the text using HTML to format it, add links, and more: + +#### Edit news + +In the Django admin interface, click on `News posts`: + +![](_attachments/news.png) + +All news objects are read by the front page as separate news. +You can create and edit news using the interface. Write the news using HTML to format the text, add links, and more. #### Delete a user diff --git a/documentation/docs/_attachments/announcements.png b/documentation/docs/_attachments/announcements.png new file mode 100644 index 000000000..4283be5f6 Binary files /dev/null and b/documentation/docs/_attachments/announcements.png differ diff --git a/documentation/docs/_attachments/news.png b/documentation/docs/_attachments/news.png new file mode 100644 index 000000000..81eabd4a9 Binary files /dev/null and b/documentation/docs/_attachments/news.png differ diff --git a/src/apps/announcements/admin.py b/src/apps/announcements/admin.py index 892fb0080..1bc259d75 100644 --- a/src/apps/announcements/admin.py +++ b/src/apps/announcements/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.utils.html import format_html from . import models @@ -8,10 +9,28 @@ class NewsPostExpansion(admin.ModelAdmin): search_fields = ["id", "title", "link"] +LEVEL_COLORS = { + models.Announcement.LEVEL_CRITICAL: "#db2828", + models.Announcement.LEVEL_WARNING: "#f2c037", + models.Announcement.LEVEL_INFO: "#2185d0", +} + + class AnnouncementExpansion(admin.ModelAdmin): - list_display = ["id", "text_limited"] - list_display_links = ["id", "text_limited"] + list_display = ["id", "level_badge", "title", "text_limited", "is_active", "priority"] + list_display_links = ["id", "title", "text_limited"] + list_filter = ["level", "is_active"] + search_fields = ["title", "text"] ordering = ('-id',) + fields = ("title", "level", "text", "is_active", "priority") + + @admin.display(description="level", ordering="level") + def level_badge(self, obj): + return format_html( + '{}', + LEVEL_COLORS.get(obj.level, "#767676"), + obj.get_level_display(), + ) @admin.display(description="text", ordering="text") def text_limited(self, obj): diff --git a/src/apps/announcements/migrations/0005_announcement_created_when_announcement_is_active_and_more.py b/src/apps/announcements/migrations/0005_announcement_created_when_announcement_is_active_and_more.py new file mode 100644 index 000000000..bc3c82301 --- /dev/null +++ b/src/apps/announcements/migrations/0005_announcement_created_when_announcement_is_active_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.17 on 2026-09-30 09:21 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('announcements', '0004_alter_announcement_id_alter_newspost_id'), + ] + + operations = [ + migrations.AddField( + model_name='announcement', + name='created_when', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name='announcement', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='announcement', + name='level', + field=models.CharField(choices=[('critical', 'Critical'), ('warning', 'Warning'), ('info', 'Info'), ('plain', 'Plain')], default='info', max_length=10), + ), + migrations.AddField( + model_name='announcement', + name='priority', + field=models.PositiveIntegerField(default=0, help_text='Lower priority is shown first.'), + ), + migrations.AddField( + model_name='announcement', + name='title', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/src/apps/announcements/models.py b/src/apps/announcements/models.py index fbb0a4d1f..f2d3c6c4c 100644 --- a/src/apps/announcements/models.py +++ b/src/apps/announcements/models.py @@ -3,7 +3,26 @@ class Announcement(models.Model): + LEVEL_CRITICAL = "critical" + LEVEL_WARNING = "warning" + LEVEL_INFO = "info" + LEVEL_PLAIN = "plain" + LEVELS = [ + (LEVEL_CRITICAL, "Critical"), + (LEVEL_WARNING, "Warning"), + (LEVEL_INFO, "Info"), + (LEVEL_PLAIN, "Plain"), + ] + + title = models.CharField(max_length=200, blank=True) text = models.TextField(null=True, blank=True) + level = models.CharField(max_length=10, choices=LEVELS, default=LEVEL_INFO) + is_active = models.BooleanField(default=True) + priority = models.PositiveIntegerField(default=0, help_text="Lower priority is shown first.") + created_when = models.DateTimeField(default=now) + + def __str__(self): + return self.title or f"Announcement {self.pk}" class NewsPost(models.Model): diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index f2a4faf19..9b76dfb7e 100644 --- a/src/apps/api/serializers/competitions.py +++ b/src/apps/api/serializers/competitions.py @@ -378,33 +378,13 @@ class CompetitionDetailSerializer(serializers.ModelSerializer): queue = QueuePublicSerializer(read_only=True) whitelist_emails = serializers.SerializerMethodField() - # Fields only visible to competition admins (creator, collaborators, staff/superusers) - ADMIN_ONLY_FIELDS = ( - 'secret_key', - 'whitelist_emails', - 'collaborators', - 'queue', - 'enable_detailed_results', - 'show_detailed_results_in_submission_panel', - 'show_detailed_results_in_leaderboard', - 'auto_run_submissions', - 'forum', - 'forum_enabled', - 'enable_human_in_the_loop', - 'registration_auto_approve', - 'can_participants_make_submissions_public', - 'make_programs_available', - 'make_input_data_available', - 'fact_sheet', - ) - class Meta: model = Competition - fields = ( + # Fields visible to everyone who can access the competition + public_fields = ( 'id', 'title', 'published', - 'secret_key', 'created_by', 'owner_display_name', 'created_when', @@ -414,31 +394,36 @@ class Meta: 'pages', 'phases', 'leaderboards', - 'collaborators', 'participant_status', - 'registration_auto_approve', 'description', 'participants_count', 'submissions_count', - 'queue', - 'enable_detailed_results', - 'show_detailed_results_in_submission_panel', - 'show_detailed_results_in_leaderboard', - 'auto_run_submissions', - 'can_participants_make_submissions_public', - 'make_programs_available', - 'make_input_data_available', 'docker_image', 'competition_type', - 'fact_sheet', - 'forum', 'reward', 'contact_email', 'report', - 'whitelist_emails', + 'enable_detailed_results', + 'show_detailed_results_in_submission_panel', + 'show_detailed_results_in_leaderboard', + 'forum', 'forum_enabled', - 'enable_human_in_the_loop' + 'fact_sheet', + 'make_programs_available', + 'make_input_data_available', + 'registration_auto_approve', + ) + # Fields only visible to competition admins (creator, collaborators, staff/superusers) + admin_fields = ( + 'secret_key', + 'whitelist_emails', + 'collaborators', + 'queue', + 'auto_run_submissions', + 'enable_human_in_the_loop', + 'can_participants_make_submissions_public', ) + fields = public_fields + admin_fields def get_leaderboards(self, instance): try: @@ -466,10 +451,11 @@ def to_representation(self, instance): representation = super().to_representation(instance) user = self.context['request'].user - # If user is not admin/creator/collaborator then do not include the admin-only fields + # If user is not admin/creator/collaborator then only include the public fields if not instance.user_has_admin_permission(user): - for field in self.ADMIN_ONLY_FIELDS: - representation.pop(field, None) + for field in list(representation): + if field not in self.Meta.public_fields: + representation.pop(field) return representation diff --git a/src/apps/api/serializers/submissions.py b/src/apps/api/serializers/submissions.py index 4069965c4..0fbb42e05 100644 --- a/src/apps/api/serializers/submissions.py +++ b/src/apps/api/serializers/submissions.py @@ -11,6 +11,7 @@ from api.serializers.tasks import TaskSerializer from api.serializers.submission_leaderboard import SubmissionScoreSerializer from competitions.models import Submission, SubmissionDetails, CompetitionParticipant, Phase +from competitions.views import _group_display_name from datasets.models import Data from utils.data import make_url_sassy @@ -28,6 +29,7 @@ class SubmissionSerializer(serializers.ModelSerializer): created_when = serializers.DateTimeField() auto_run = serializers.SerializerMethodField(read_only=True) can_make_submissions_public = serializers.SerializerMethodField(read_only=True) + participant_group_name = serializers.SerializerMethodField(read_only=True) class Meta: model = Submission @@ -56,6 +58,7 @@ class Meta: 'auto_run', 'can_make_submissions_public', 'is_soft_deleted', + 'participant_group_name', ) read_only_fields = ( 'pk', @@ -80,6 +83,21 @@ def get_can_make_submissions_public(self, instance): # returns this submission's competition can_participants_make_submissions_public Flag return instance.phase.competition.can_participants_make_submissions_public + def get_participant_group_name(self, instance): + if not instance.queue_id or not instance.phase_id: + return None + + competition = instance.phase.competition + + group = competition.participant_groups.filter( + queue_id=instance.queue_id + ).exclude(queue_id__isnull=True).first() + + if group: + return _group_display_name(group.name, competition.pk) + + return None + class SubmissionCreationSerializer(DefaultUserCreateMixin, serializers.ModelSerializer): """Used for creation _and_ status updates...""" @@ -273,6 +291,8 @@ def get_prediction_result(self, instance): def get_detailed_result(self, instance): if instance.detailed_result.name: + if not instance.phase.competition.enable_detailed_results: + return None return make_url_sassy(instance.detailed_result.name) def get_scoring_result(self, instance): diff --git a/src/apps/api/tests/test_competitions.py b/src/apps/api/tests/test_competitions.py index 307aa93b9..258072c54 100644 --- a/src/apps/api/tests/test_competitions.py +++ b/src/apps/api/tests/test_competitions.py @@ -180,32 +180,50 @@ def test_invalid_secret_key_does_not_give_access_to_private_competition(self): # ---------- Fields ---------- - def test_admins_see_admin_only_fields(self): + def test_admins_see_all_fields(self): """ The creator, a collaborator and a superuser request a published competition. - Expects every admin-only field in the response, including the competition's secret key. + Expects every field in the response. """ for user in self.admins: resp = self._get(self.public_comp, user) assert resp.status_code == 200 - for field in CompetitionDetailSerializer.ADMIN_ONLY_FIELDS: + for field in CompetitionDetailSerializer.Meta.fields: assert field in resp.data, field - assert resp.data['secret_key'] == str(self.public_comp.secret_key) def test_non_admins_do_not_see_admin_only_fields(self): """ Participants, an unrelated user and a logged-out user request a published competition. - Expects the public fields in the response, no admin-only field, - and the competition's secret key absent from the whole response body. + Expects no admin-only field in the response. """ for user in self.non_admins: resp = self._get(self.public_comp, user) assert resp.status_code == 200 - for field in ('id', 'title', 'created_by', 'phases', 'leaderboards'): - assert field in resp.data, field - for field in CompetitionDetailSerializer.ADMIN_ONLY_FIELDS: + for field in CompetitionDetailSerializer.Meta.admin_fields: assert field not in resp.data, field - assert str(self.public_comp.secret_key) not in resp.content.decode() + + def test_non_admins_see_fields_used_by_competition_page(self): + """ + Participants, an unrelated user and a logged-out user request a published competition. + Expects every field the competition page uses for non-admins in the response. + """ + # Listed here instead of using Meta.public_fields, so moving one of them to admin_fields fails this test + fields_used_by_competition_page = [ + 'fact_sheet', + 'registration_auto_approve', + 'make_programs_available', + 'make_input_data_available', + 'enable_detailed_results', + 'show_detailed_results_in_submission_panel', + 'show_detailed_results_in_leaderboard', + 'forum', + 'forum_enabled', + ] + for user in self.non_admins: + resp = self._get(self.public_comp, user) + assert resp.status_code == 200 + for field in fields_used_by_competition_page: + assert field in resp.data, field def test_non_admins_with_secret_key_do_not_see_admin_only_fields(self): """ @@ -215,7 +233,7 @@ def test_non_admins_with_secret_key_do_not_see_admin_only_fields(self): for user in [None, self.other_user]: resp = self._get(self.private_comp, user, secret_key=str(self.private_comp.secret_key)) assert resp.status_code == 200 - for field in CompetitionDetailSerializer.ADMIN_ONLY_FIELDS: + for field in CompetitionDetailSerializer.Meta.admin_fields: assert field not in resp.data, field def test_admins_see_hidden_leaderboards(self): diff --git a/src/apps/api/tests/test_submissions.py b/src/apps/api/tests/test_submissions.py index a57b024c0..d8b294b87 100644 --- a/src/apps/api/tests/test_submissions.py +++ b/src/apps/api/tests/test_submissions.py @@ -422,6 +422,36 @@ def test_anonymous_cannot_get_details_of_finished_leaderboard_submission(self): resp = self.client.get(url) assert resp.status_code == 200 + def test_get_details_returns_detailed_result_when_detailed_results_enabled(self): + """ + Uses a submission that has a detailed result file, with enable_detailed_results set. + Expect the detailed result url in the response. + """ + self.comp.enable_detailed_results = True + self.comp.save() + Submission.objects.filter(pk=self.existing_submission.pk).update(detailed_result='detailed_result/test.html') + url = reverse('submission-get-details', args=(self.existing_submission.pk,)) + + self.client.force_login(self.participant) + resp = self.client.get(url) + assert resp.status_code == 200 + assert resp.data['detailed_result'] is not None + + def test_get_details_does_not_return_detailed_result_when_detailed_results_disabled(self): + """ + Uses a submission that has a detailed result file, with enable_detailed_results not set. + Expect no detailed result url in the response. + """ + self.comp.enable_detailed_results = False + self.comp.save() + Submission.objects.filter(pk=self.existing_submission.pk).update(detailed_result='detailed_result/test.html') + url = reverse('submission-get-details', args=(self.existing_submission.pk,)) + + self.client.force_login(self.participant) + resp = self.client.get(url) + assert resp.status_code == 200 + assert resp.data['detailed_result'] is None + class SubmissionUpdateTest(APITestCase): def setUp(self): @@ -757,3 +787,253 @@ def test_can_submit_during_active_phase_with_future_end_date(self): ) resp = self.post_submission(phase) assert resp.status_code == 201 + + +def fake_make_url_sassy(path): + return f"https://storage/{path}" + + +@mock.patch('api.serializers.submissions.make_url_sassy', fake_make_url_sassy) +class SubmissionBulkDownloadTests(APITestCase): + """ + Tests for the bulk download endpoints of SubmissionViewSet: + download_many_submissions, download_many_prediction_results and download_many_scoring_results. + make_url_sassy is mocked so that each URL is predictable and contains the file path. + """ + + def setUp(self): + self.superuser = UserFactory(is_superuser=True, is_staff=True) + self.creator = UserFactory(username='creator') + self.collaborator = UserFactory(username='collab') + self.participant = UserFactory(username='participant') + self.other_user = UserFactory(username='other_user') + + self.comp = CompetitionFactory(created_by=self.creator, collaborators=[self.collaborator]) + self.phase = PhaseFactory(competition=self.comp) + + # Result files are only referenced by path, they don't exist in the storage. + # File sizes are set so that Submission.save() doesn't try to read them from the storage. + # Finished submission with both result files + self.finished_submission = SubmissionFactory( + phase=self.phase, + owner=self.participant, + status=Submission.FINISHED, + prediction_result='prediction_result/finished_prediction.zip', + prediction_result_file_size=1, + scoring_result='scoring_result/finished_scoring.zip', + scoring_result_file_size=1, + ) + # Failed submission with result files, which must not be included in results downloads + self.failed_submission = SubmissionFactory( + phase=self.phase, + owner=self.participant, + status=Submission.FAILED, + prediction_result='prediction_result/failed_prediction.zip', + prediction_result_file_size=1, + scoring_result='scoring_result/failed_scoring.zip', + scoring_result_file_size=1, + ) + # Finished submission without any result files + self.no_results_submission = SubmissionFactory( + phase=self.phase, + owner=self.participant, + status=Submission.FINISHED, + ) + # Submission of another user in another competition + self.other_submission = SubmissionFactory(owner=self.other_user, status=Submission.FINISHED) + + self.participant_pks = [ + self.finished_submission.pk, + self.failed_submission.pk, + self.no_results_submission.pk, + ] + + self.submissions_url = reverse('submission-download-many-submissions') + self.prediction_results_url = reverse('submission-download-many-prediction-results') + self.scoring_results_url = reverse('submission-download-many-scoring-results') + self.all_urls = [self.submissions_url, self.prediction_results_url, self.scoring_results_url] + + def download(self, url, pks): + return self.client.post(url, {'pks': pks}, format='json') + + # ------------------------------------------------------------------ + # Validation and permissions (shared by all three endpoints) + # ------------------------------------------------------------------ + + def test_anonymous_user_cannot_download(self): + """Anonymous user calls all three bulk download endpoints. Expect 403 from each.""" + for url in self.all_urls: + resp = self.download(url=url, pks=self.participant_pks) + assert resp.status_code == 403 + + def test_pks_is_required(self): + """Request with an empty `pks` list. Expect 400 with a "`pks` field is required" error.""" + self.client.force_login(self.participant) + resp = self.download(url=self.submissions_url, pks=[]) + assert resp.status_code == 400 + assert resp.data['error'] == '`pks` field is required' + + def test_pks_must_be_a_list(self): + """Request with a single id instead of a list. Expect 400 with a "`pks` must be a list" error.""" + self.client.force_login(self.participant) + resp = self.download(url=self.submissions_url, pks=self.finished_submission.pk) + assert resp.status_code == 400 + assert resp.data['error'] == '`pks` must be a list' + + def test_invalid_submission_id_returns_404(self): + """Request with one valid and one non-existent submission id. Expect 404.""" + self.client.force_login(self.participant) + resp = self.download(url=self.submissions_url, pks=[self.finished_submission.pk, 999999]) + assert resp.status_code == 404 + + def test_owner_creator_collaborator_and_superuser_can_download(self): + """Submission owner, competition creator, collaborator and superuser download submissions. Expect 200 for each.""" + for user in [self.participant, self.creator, self.collaborator, self.superuser]: + self.client.force_login(user) + resp = self.download(url=self.submissions_url, pks=self.participant_pks) + assert resp.status_code == 200 + + def test_other_user_cannot_download(self): + """User who is neither owner nor organizer calls all three endpoints. Expect 403 from each.""" + self.client.force_login(self.other_user) + for url in self.all_urls: + resp = self.download(url=url, pks=self.participant_pks) + assert resp.status_code == 403 + + def test_cannot_download_if_any_submission_is_not_allowed(self): + """Participant requests their own submission together with another user's. Expect 403 for the whole request.""" + self.client.force_login(self.participant) + resp = self.download( + url=self.submissions_url, + pks=[self.finished_submission.pk, self.other_submission.pk], + ) + assert resp.status_code == 403 + + def test_organizer_cannot_download_files_of_another_competition(self): + """Competition creator calls all three endpoints for a submission from a competition they don't organize. Expect 403 from each.""" + self.client.force_login(self.creator) + for url in self.all_urls: + resp = self.download(url=url, pks=[self.other_submission.pk]) + assert resp.status_code == 403 + + # ------------------------------------------------------------------ + # Downloaded files + # ------------------------------------------------------------------ + + def test_download_submissions_includes_all_statuses(self): + """ + Participant downloads finished, failed and result-less submissions. + Expect all 3 submission files, each named with the `sub_` prefix and pointing to its data file. + """ + self.client.force_login(self.participant) + resp = self.download(url=self.submissions_url, pks=self.participant_pks) + assert resp.status_code == 200 + assert len(resp.data) == 3 + + files_by_id = {int(f['name'].split('_')[1]): f for f in resp.data} + assert set(files_by_id) == set(self.participant_pks) + for sub in [self.finished_submission, self.failed_submission, self.no_results_submission]: + file = files_by_id[sub.pk] + assert file['name'].startswith(f"sub_{sub.pk}_") + assert file['url'] == f"https://storage/{sub.data.data_file.name}" + + def test_download_prediction_results_only_includes_finished_submissions_with_a_file(self): + """ + Participant downloads prediction results of finished, failed and result-less submissions. + Expect only the finished submission's prediction result, named with the `pred_res_` prefix. + """ + self.client.force_login(self.participant) + resp = self.download(url=self.prediction_results_url, pks=self.participant_pks) + assert resp.status_code == 200 + assert len(resp.data) == 1 + + file = resp.data[0] + assert file['name'].startswith(f"pred_res_{self.finished_submission.pk}_") + assert file['name'].endswith('finished_prediction.zip') + assert file['url'] == 'https://storage/prediction_result/finished_prediction.zip' + + def test_download_scoring_results_only_includes_finished_submissions_with_a_file(self): + """ + Participant downloads scoring results of finished, failed and result-less submissions. + Expect only the finished submission's scoring result, named with the `sco_res_` prefix. + """ + self.client.force_login(self.participant) + resp = self.download(url=self.scoring_results_url, pks=self.participant_pks) + assert resp.status_code == 200 + assert len(resp.data) == 1 + + file = resp.data[0] + assert file['name'].startswith(f"sco_res_{self.finished_submission.pk}_") + assert file['name'].endswith('finished_scoring.zip') + assert file['url'] == 'https://storage/scoring_result/finished_scoring.zip' + + # ------------------------------------------------------------------ + # Phase output visibility + # ------------------------------------------------------------------ + + def test_hide_output_hides_results_from_participant_but_not_submissions(self): + """ + Phase has hide_output set and the participant downloads all three file types. + Expect empty lists for prediction and scoring results, but all 3 submission files. + """ + self.phase.hide_output = True + self.phase.save() + self.client.force_login(self.participant) + + resp = self.download(url=self.prediction_results_url, pks=self.participant_pks) + assert resp.status_code == 200 + assert resp.data == [] + + resp = self.download(url=self.scoring_results_url, pks=self.participant_pks) + assert resp.status_code == 200 + assert resp.data == [] + + # The submitted files themselves are never hidden from their owner + resp = self.download(url=self.submissions_url, pks=self.participant_pks) + assert resp.status_code == 200 + assert len(resp.data) == 3 + + def test_hide_output_does_not_hide_results_from_organizers(self): + """ + Phase has hide_output set and creator, collaborator and superuser download results. + Expect each of them to still get the finished submission's prediction and scoring result. + """ + self.phase.hide_output = True + self.phase.save() + + for user in [self.creator, self.collaborator, self.superuser]: + self.client.force_login(user) + resp = self.download(url=self.prediction_results_url, pks=self.participant_pks) + assert len(resp.data) == 1 + resp = self.download(url=self.scoring_results_url, pks=self.participant_pks) + assert len(resp.data) == 1 + + def test_hide_prediction_output_only_hides_prediction_results(self): + """ + Phase has hide_prediction_output set and the participant downloads results. + Expect no prediction results, but the scoring result is still returned. + """ + self.phase.hide_prediction_output = True + self.phase.save() + self.client.force_login(self.participant) + + resp = self.download(url=self.prediction_results_url, pks=self.participant_pks) + assert resp.data == [] + + resp = self.download(url=self.scoring_results_url, pks=self.participant_pks) + assert len(resp.data) == 1 + + def test_hide_score_output_only_hides_scoring_results(self): + """ + Phase has hide_score_output set and the participant downloads results. + Expect no scoring results, but the prediction result is still returned. + """ + self.phase.hide_score_output = True + self.phase.save() + self.client.force_login(self.participant) + + resp = self.download(url=self.scoring_results_url, pks=self.participant_pks) + assert resp.data == [] + + resp = self.download(url=self.prediction_results_url, pks=self.participant_pks) + assert len(resp.data) == 1 diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index 4a3bacb00..6ea72384f 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -369,16 +369,16 @@ def register(self, request, pk): participant.status = 'approved' elif competition.registration_auto_approve: participant.status = 'approved' - send_participation_accepted_emails(participant) + send_participation_accepted_emails(participant, request) else: # check if user is in whitelist emails then approve directly # Using lower case because some users have used uppercased emails addresses if user.email.lower() in list(competition.whitelist_emails.values_list('email', flat=True)): participant.status = 'approved' - send_participation_accepted_emails(participant) + send_participation_accepted_emails(participant, request) else: participant.status = 'pending' - send_participation_requested_emails(participant) + send_participation_requested_emails(participant, request) participant.save() return Response({'participant_status': participant.status}, status=status.HTTP_201_CREATED) @@ -1045,7 +1045,7 @@ def update(self, request, *args, **kwargs): 'denied': send_participation_denied_emails, } if participation_status in emails: - emails[participation_status](participant) + emails[participation_status](participant, request) return super().update(request, *args, **kwargs) diff --git a/src/apps/api/views/profiles.py b/src/apps/api/views/profiles.py index 00d963d26..a6a3a90a0 100644 --- a/src/apps/api/views/profiles.py +++ b/src/apps/api/views/profiles.py @@ -1,5 +1,4 @@ import json - from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.db.models import Q @@ -21,6 +20,7 @@ from profiles.helpers import send_mail from profiles.models import Organization, Membership from profiles.views import send_delete_account_confirmation_mail +from utils.email import get_link_context User = get_user_model() @@ -187,6 +187,7 @@ def invite_users(self, request, pk=None): 'user': member.user, 'invite_url': f'{reverse("profiles:organization_accept_invite")}?token={member.token}', 'organization': org.name, + **get_link_context(request), }, subject=f'You have been invited to join {org.name}', html_file="profiles/emails/invite.html", @@ -260,6 +261,18 @@ def validate_invite(self, request): mem_ser = MembershipSerializer(membership) return Response(mem_ser.data, status=status.HTTP_200_OK) + @action(detail=True, methods=['delete'], permission_classes=[IsAuthenticated]) + def leave_organization(self, request, pk=None): + organization = self.get_object() + try: + member = organization.membership_set.get(user=request.user) + except Membership.DoesNotExist: + raise ValidationError('You are not a member of this organization') + if member.group == Membership.OWNER: + raise PermissionDenied('The owner cannot leave the organization') + organization.users.remove(request.user) + return Response({'success': True, 'message': 'You have left the organization'}, status=status.HTTP_200_OK) + @action(detail=True, methods=['delete']) def delete_organization(self, request, pk=None): try: diff --git a/src/apps/api/views/submissions.py b/src/apps/api/views/submissions.py index c3fab49de..6e39d0349 100644 --- a/src/apps/api/views/submissions.py +++ b/src/apps/api/views/submissions.py @@ -18,7 +18,7 @@ from profiles.models import Organization, Membership from api.pagination import DynamicChoicePagination from tasks.models import Task -from api.serializers.submissions import SubmissionCreationSerializer, SubmissionSerializer, SubmissionFilesSerializer, SubmissionDetailSerializer +from api.serializers.submissions import SubmissionCreationSerializer, SubmissionSerializer, SubmissionFilesSerializer from competitions.models import Submission, SubmissionDetails, Phase, CompetitionParticipant from leaderboards.strategies import put_on_leaderboard_by_submission_rule from leaderboards.models import SubmissionScore, Column, Leaderboard @@ -388,9 +388,27 @@ def re_run_many_submissions(self, request): submission.re_run() return Response({}) - # TODO: The 3 functions download many should be bundled inside a genereic with the function like "get_prediction_result" as a parameter instead of the same code 3 times @action(detail=False, methods=('POST',)) - def download_many(self, request): + def download_many_submissions(self, request): + return self._download_many_files(request=request, file_type='submission') + + @action(detail=False, methods=('POST',)) + def download_many_prediction_results(self, request): + return self._download_many_files(request=request, file_type='prediction_result') + + @action(detail=False, methods=('POST',)) + def download_many_scoring_results(self, request): + return self._download_many_files(request=request, file_type='scoring_result') + + def _download_many_files(self, request, file_type): + """ + Returns a list of {name, url} for one file of each requested submission. + `file_type` is one of 'submission', 'prediction_result' or 'scoring_result'. + """ + # Only allow authenticated users to proceed + if not request.user.is_authenticated: + raise PermissionDenied("You must be logged in to download submissions") + pks = request.data.get('pks') if not pks: return Response({"error": "`pks` field is required"}, status=400) @@ -402,55 +420,55 @@ def download_many(self, request): # Get submissions submissions = Submission.objects.filter(pk__in=pks).select_related( "owner", - "phase", + "phase__competition", "data" ) if len(list(submissions)) != len(pks): return Response({"error": "One or more submission IDs are invalid"}, status=404) - # Nicolas Homberg : should create a function for this ? - # Check permissions - if not request.user.is_authenticated: - raise PermissionDenied("You must be logged in to download submissions") - # Allow admins - if request.user.is_superuser or request.user.is_staff: - allowed = True - else: - # Build one Q object for "owner OR organizer" - organiser_q = ( + # Admins can download any submission. Everyone else can only download + # submissions they own or that belong to a competition they organize. + is_admin = request.user.is_superuser or request.user.is_staff + if not is_admin: + can_download = ( + Q(owner=request.user) | Q(phase__competition__created_by=request.user) | Q(phase__competition__collaborators=request.user) ) - # Submissions that violate the rule - disallowed = submissions.exclude(Q(owner=request.user) | organiser_q) - allowed = not disallowed.exists() - if not allowed: - raise PermissionDenied( - "You do not have permission to download one or more of the requested submissions" - ) + # Deny if any requested submission is one this user is not allowed to download + if submissions.exclude(can_download).exists(): + raise PermissionDenied( + "You do not have permission to download one or more of the requested submissions" + ) + serializer = SubmissionFilesSerializer(context=self.get_serializer_context()) files = [] - for sub in submissions: - file_path = sub.data.data_file.name.split('/')[-1] - short_name = f"{sub.id}_{sub.owner}_PhaseId{sub.phase.id}_{sub.data.created_when.strftime('%Y-%m-%d:%M-%S')}_{file_path}" - # url = sub.data.data_file.url - url = SubmissionDetailSerializer(sub.data, context=self.get_serializer_context()).data['data_file'] - # url = SubmissionFilesSerializer(sub, context=self.get_serializer_context()).data['data_file'] - files.append({"name": short_name, "url": url}) + # Results are only downloaded for finished submissions + if file_type != 'submission' and sub.status != Submission.FINISHED: + continue - return Response(files) + if file_type == 'submission': + file = sub.data.data_file + url = serializer.get_data_file(instance=sub) + prefix = 'sub_' + elif file_type == 'prediction_result': + file = sub.prediction_result + url = serializer.get_prediction_result(instance=sub) + prefix = 'pred_res_' + else: + file = sub.scoring_result + url = serializer.get_scoring_result(instance=sub) + prefix = 'sco_res_' - for sub in submissions: - if sub.status not in [Submission.FINISHED]: # Submission.FAILED, Submission.CANCELLED + # url is None when the file doesn't exist, or when the serializer hides it + # from this user (phase hide_output / hide_prediction_output / hide_score_output) + if not url: continue - file_path = sub.data.data_file.name.split('/')[-1] - complete_name = f"res_{sub.id}_{sub.owner}_PhaseId{sub.phase.id}_{sub.data.created_when.strftime('%Y-%m-%d:%M-%S')}_{file_path}" - result_url = SubmissionDetailSerializer(sub.data, context=self.get_serializer_context()).get_scoring_result(sub) - # detailed results is already in the results zip file but For very large detailed results it could be helpfull to remove it - # detailed_result_url = serializer.get_scoring_result(sub) - files.append({"name": complete_name, "url": result_url}) + file_path = file.name.split('/')[-1] + download_name = f"{prefix}{sub.id}_{sub.owner}_PhaseId{sub.phase.id}_{sub.data.created_when.strftime('%Y-%m-%d:%M-%S')}_{file_path}" + files.append({"name": download_name, "url": url}) return Response(files) diff --git a/src/apps/competitions/emails.py b/src/apps/competitions/emails.py index 33316daa0..fa695b798 100644 --- a/src/apps/competitions/emails.py +++ b/src/apps/competitions/emails.py @@ -1,17 +1,18 @@ -from utils.email import codalab_send_mail, codalab_send_markdown_email +from utils.email import codalab_send_mail, codalab_send_markdown_email, get_link_context def get_organizer_emails(competition): return [user.email for user in competition.all_organizers if not user.is_deleted] -def send_participation_requested_emails(participant): +def send_participation_requested_emails(participant, request): if participant.user.is_deleted: return context = { 'participant': participant, - 'user': participant.user + 'user': participant.user, + **get_link_context(request), } # Notify Organizers codalab_send_mail( @@ -32,13 +33,14 @@ def send_participation_requested_emails(participant): ) -def send_participation_accepted_emails(participant): +def send_participation_accepted_emails(participant, request): if participant.user.is_deleted: return context = { 'participant': participant, - 'user': participant.user + 'user': participant.user, + **get_link_context(request), } codalab_send_mail( context_data=context, @@ -57,13 +59,14 @@ def send_participation_accepted_emails(participant): ) -def send_participation_denied_emails(participant): +def send_participation_denied_emails(participant, request): if participant.user.is_deleted: return context = { 'participant': participant, - 'user': participant.user + 'user': participant.user, + **get_link_context(request), } # Notify Organizers codalab_send_mail( diff --git a/src/apps/forums/models.py b/src/apps/forums/models.py index 9e5848018..dcff36f31 100644 --- a/src/apps/forums/models.py +++ b/src/apps/forums/models.py @@ -3,6 +3,8 @@ from django.db import models from django.urls import reverse +from utils.email import get_link_context + from .helpers import send_mail @@ -63,7 +65,8 @@ def notify_user(self, user, post=None): context={ 'thread': self, 'user': user, - 'new_post': self.posts.last() if post is None else post + 'new_post': self.posts.last() if post is None else post, + **get_link_context(), }, subject='New post in %s' % self.title, html_file="forums/emails/new_post.html", diff --git a/src/apps/pages/tests/test_announcements.py b/src/apps/pages/tests/test_announcements.py new file mode 100644 index 000000000..35749711d --- /dev/null +++ b/src/apps/pages/tests/test_announcements.py @@ -0,0 +1,76 @@ +from datetime import timedelta + +from django.test import TestCase +from django.urls import reverse +from django.utils.timezone import now + +from announcements.models import Announcement + + +class HomePageAnnouncementTests(TestCase): + + def get_home(self): + return self.client.get(reverse('pages:home')) + + def test_only_active_announcements_are_shown(self): + """ + Creates one active and one inactive announcement and loads the home page. + Expects only the active announcement in the context and the inactive + announcement's text to be absent from the rendered page. + """ + active = Announcement.objects.create(title="Active", text="active text") + Announcement.objects.create(title="Inactive", text="inactive text", is_active=False) + + resp = self.get_home() + + assert list(resp.context['announcements']) == [active] + assert "inactive text" not in resp.content.decode() + + def test_announcements_ordered_by_priority_then_newest(self): + """ + Creates two priority 0 announcements with different creation dates and + one priority 5 announcement that is the newest of all. + Expects the lower priority to come first, and within the same priority + the newest to come first: [newer priority 0, older priority 0, priority 5]. + """ + current = now() + low_priority_old = Announcement.objects.create(title="A", priority=0, created_when=current - timedelta(days=2)) + low_priority_new = Announcement.objects.create(title="B", priority=0, created_when=current) + high_priority = Announcement.objects.create(title="C", priority=5, created_when=current + timedelta(days=1)) + + resp = self.get_home() + + assert list(resp.context['announcements']) == [low_priority_new, low_priority_old, high_priority] + + def test_levels_render_with_their_styles(self): + """ + Creates one announcement of each level and loads the home page. + Expects critical, warning and info to render as Semantic UI messages with + the negative, warning and info classes, and plain to render as a + simple "announcement plain" block with no icon. + """ + Announcement.objects.create(level=Announcement.LEVEL_CRITICAL, text="critical text") + Announcement.objects.create(level=Announcement.LEVEL_WARNING, text="warning text") + Announcement.objects.create(level=Announcement.LEVEL_INFO, text="info text") + Announcement.objects.create(level=Announcement.LEVEL_PLAIN, text="plain text") + + content = self.get_home().content.decode() + + assert 'class="ui icon message announcement negative"' in content + assert 'class="ui icon message announcement warning"' in content + assert 'class="ui icon message announcement info"' in content + assert 'class="announcement plain"' in content + # Plain announcements have no icon between their wrapper and content + plain_block = content.split('class="announcement plain"')[1].split("plain text")[0] + assert "icon" not in plain_block + + def test_announcement_box_hidden_without_announcements(self): + """ + Creates only an inactive announcement and loads the home page. + Expects the announcement box not to be rendered at all. + """ + Announcement.objects.create(text="hidden", is_active=False) + + content = self.get_home().content.decode() + + assert "announcement-container" not in content diff --git a/src/apps/pages/views.py b/src/apps/pages/views.py index d68e5da4d..732eee3ad 100644 --- a/src/apps/pages/views.py +++ b/src/apps/pages/views.py @@ -14,11 +14,8 @@ class HomeView(TemplateView): def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - announcement = Announcement.objects.all().first() - context['announcement'] = announcement.text if announcement else None - - news_posts = NewsPost.objects.all().order_by('-id') - context['news_posts'] = news_posts + context['announcements'] = Announcement.objects.filter(is_active=True).order_by("priority", "-created_when") + context['news_posts'] = NewsPost.objects.all().order_by('-id') return context diff --git a/src/apps/profiles/views.py b/src/apps/profiles/views.py index 161fd5ffe..6c2a92e8b 100644 --- a/src/apps/profiles/views.py +++ b/src/apps/profiles/views.py @@ -5,8 +5,7 @@ from django.contrib import messages from django.contrib.auth import authenticate, login from django.db.models import Q -from django.contrib.sites.shortcuts import get_current_site -from django.core.mail import EmailMessage, EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives from django.http import Http404 from django.shortcuts import render, redirect from django.contrib.auth import views as auth_views @@ -28,7 +27,7 @@ from datasets.models import Data from tasks.models import Task from forums.models import Post -from utils.email import codalab_send_mail +from utils.email import codalab_send_mail, get_link_context class LoginView(auth_views.LoginView): @@ -108,29 +107,29 @@ def activate(request, uidb64, token): def activateEmail(request, user, to_email): - mail_subject = 'Activate your user account.' - message = render_to_string('profiles/emails/template_activate_account.html', { - 'username': user.username, - 'domain': settings.DOMAIN_NAME, + context = { + 'user': user, 'uid': urlsafe_base64_encode(force_bytes(user.pk)), 'token': account_activation_token.make_token(user), - 'protocol': 'https' if request.is_secure() else 'http' - }) - email = EmailMessage(mail_subject, message, to=[to_email]) - if email.send(): - messages.success(request, f'Dear {user.username}, please go to your email {to_email} inbox and click on \ - the activation link to confirm and complete the registration. *Note: Check your spam folder.') - else: - messages.error(request, f'Problem sending confirmation email to {to_email}, check if you typed it correctly.') + **get_link_context(request), + } + codalab_send_mail( + context_data=context, + subject='Activate your user account.', + html_file='profiles/emails/template_activate_account.html', + text_file='profiles/emails/template_activate_account.txt', + to_email=[to_email] + ) + messages.success(request, f'Dear {user.username}, please go to your email {to_email} inbox and click on \ + the activation link to confirm and complete the registration. *Note: Check your spam folder.') def send_delete_account_confirmation_mail(request, user): context = { 'user': user, - 'domain': get_current_site(request).domain, 'uid': urlsafe_base64_encode(force_bytes(user.pk)), 'token': account_deletion_token.make_token(user), - 'protocol': 'https' if request.is_secure() else 'http' + **get_link_context(request), } codalab_send_mail( context_data=context, @@ -167,7 +166,7 @@ def send_user_deletion_notice_to_admin(user): 'tasks': tasks, 'queues': queues, 'posts': posts, - 'domain': settings.DOMAIN_NAME + **get_link_context(), } codalab_send_mail( context_data=context, @@ -418,11 +417,14 @@ def get_context_data(self, **kwargs): context['organization'] = OrganizationDetailSerializer(self.object).data membership = self.object.membership_set.filter(user=self.request.user) if len(membership) == 1: - context['is_editor'] = membership.first().group in Membership.EDITORS_GROUP - context['is_member'] = membership.first().group in Membership.SETTABLE_PERMISSIONS + group = membership.first().group + context['is_editor'] = group in Membership.EDITORS_GROUP + context['is_member'] = group in Membership.SETTABLE_PERMISSIONS + context['is_owner'] = group == Membership.OWNER else: context['is_editor'] = False context['is_member'] = False + context['is_owner'] = False return context diff --git a/src/factories.py b/src/factories.py index 2ff6ecbd0..daeed43b6 100644 --- a/src/factories.py +++ b/src/factories.py @@ -24,6 +24,7 @@ class Meta: '_username', '_i', ) + skip_postgeneration_save = True _username = factory.Faker('user_name') _i = factory.Sequence(lambda n: n) @@ -50,11 +51,14 @@ def super_user(self, created, extracted, **kwargs): if extracted: self.is_superuser = True self.is_staff = True + if created: + self.save() class CompetitionFactory(DjangoModelFactory): class Meta: model = Competition + skip_postgeneration_save = True title = factory.Sequence(lambda n: f'Competition {n}') created_by = factory.SubFactory(UserFactory) @@ -71,6 +75,8 @@ def collaborators(self, created, extracted, **kwargs): if extracted: for user in extracted: self.collaborators.add(user) + # Competition.save() adds the collaborators as approved participants + self.save() class DataFactory(DjangoModelFactory): @@ -95,6 +101,7 @@ class Meta: class TaskFactory(DjangoModelFactory): class Meta: model = Task + skip_postgeneration_save = True name = factory.Sequence(lambda n: f'Task {n}') created_by = factory.SubFactory(UserFactory) @@ -128,6 +135,7 @@ class Meta: class PhaseFactory(DjangoModelFactory): class Meta: model = Phase + skip_postgeneration_save = True competition = factory.SubFactory(CompetitionFactory) start = factory.LazyFunction(now) @@ -206,6 +214,7 @@ class Meta: class SubmissionScoreFactory(DjangoModelFactory): class Meta: model = SubmissionScore + skip_postgeneration_save = True column = factory.SubFactory(ColumnFactory) score = factory.LazyAttribute(lambda n: random.choice(range(1, 11)) / 10) diff --git a/src/static/js/ours/client.js b/src/static/js/ours/client.js index 3336913a1..7fad86fe3 100644 --- a/src/static/js/ours/client.js +++ b/src/static/js/ours/client.js @@ -128,13 +128,15 @@ CODALAB.api = { return CODALAB.api.request('GET', `${URLS.API}submissions/${id}/get_detail_result/`) }, download_many_submissions: function (pks) { - return CODALAB.api.request( - 'POST', - URLS.API + "submissions/download_many/", - { pks: pks } // body is JSON by convention - ); + return CODALAB.api.request('POST', URLS.API + "submissions/download_many_submissions/", { pks: pks }); }, - + download_many_prediction_results: function (pks) { + return CODALAB.api.request('POST', URLS.API + "submissions/download_many_prediction_results/", { pks: pks }); + }, + download_many_scoring_results: function (pks) { + return CODALAB.api.request('POST', URLS.API + "submissions/download_many_scoring_results/", { pks: pks }); + }, + /*--------------------------------------------------------------------- Leaderboards ---------------------------------------------------------------------*/ @@ -335,6 +337,9 @@ CODALAB.api = { delete_organization: (id) => { return CODALAB.api.request('DELETE', `${URLS.API}organizations/${id}/delete_organization/`) }, + leave_organization: (id) => { + return CODALAB.api.request('DELETE', `${URLS.API}organizations/${id}/leave_organization/`) + }, /*--------------------------------------------------------------------- Participants ---------------------------------------------------------------------*/ diff --git a/src/static/riot/competitions/detail/submission_manager.tag b/src/static/riot/competitions/detail/submission_manager.tag index 37530345c..b697aa5c7 100644 --- a/src/static/riot/competitions/detail/submission_manager.tag +++ b/src/static/riot/competitions/detail/submission_manager.tag @@ -17,7 +17,9 @@ @@ -82,6 +84,7 @@ Actions + No submissions found! Please make a submission @@ -91,7 +94,10 @@ Loading Submissions... - + + +
@@ -99,7 +105,22 @@
- { submission.id } + + +
+ + + + + + { submission.id } +
+ + { submission.filename } { submission.owner } { submission.phase.name } @@ -192,6 +213,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID #OwnerPhaseTaskGroupDateStatusScoreActions
{ child.id }{ child.owner }{ child.phase ? child.phase.name : '' }{ child.task ? child.task.name : '' }{ child.participant_group_name || '-' }{ pretty_date(child.created_when) }{ child.status }{ get_score(child) } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + @@ -277,6 +385,8 @@ self.next = null self.previous = null + self.expanded_submissions = {} + self.on("mount", function () { $(self.refs.search).dropdown() $(self.refs.status).dropdown() @@ -302,6 +412,22 @@ event.stopPropagation() } + self.get_children = function (submission) { + return _.filter(self.submissions, sub => sub.parent === submission.id) + } + + self.has_multiple_tasks = function (submission) { + let children = self.get_children(submission) + let task_ids = _.uniq(_.map(children, child => _.get(child, 'task.id')).filter(id => id != null)) + return task_ids.length > 1 + } + + self.toggle_expand = function (submission, event) { + event.stopPropagation() + self.expanded_submissions[submission.id] = !self.expanded_submissions[submission.id] + self.update() + } + self.filter_children = submissions => { return _.filter(submissions, sub => !sub.parent) } @@ -737,7 +863,15 @@ CODALAB.events.trigger('submission_clicked') } - self.bulk_download = function () { + // file_type is one of "submissions", "predictions" or "results" + self.bulk_download = function (file_type) { + const download_options = { + submissions: {api: CODALAB.api.download_many_submissions, zip_name: "bulk_submissions.zip"}, + prediction_results: {api: CODALAB.api.download_many_prediction_results, zip_name: "bulk_prediction_results.zip"}, + scoring_results: {api: CODALAB.api.download_many_scoring_results, zip_name: "bulk_scoring_results.zip"}, + } + const {api, zip_name} = download_options[file_type] + const statusBox = document.getElementById('downloadStatus'); const progressEl = document.getElementById('downloadProgress'); const textEl = document.getElementById('progressText'); @@ -748,7 +882,7 @@ textEl.textContent = "Preparing download..."; // Kick the API request - const req = CODALAB.api.download_many_submissions(self.checked_submissions); + const req = api(self.checked_submissions); // Common error handler const handleError = (err) => { @@ -858,7 +992,7 @@ const blob = await zip.generateAsync({ type: "blob" }); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); - link.download = "bulk_submissions.zip"; + link.download = zip_name; document.body.appendChild(link); link.click(); document.body.removeChild(link); @@ -900,14 +1034,14 @@ case "delete": self.delete_selected_submissions() break; - case "download": + case "download_submissions": self.bulk_download("submissions") break; - case "download_results": - self.bulk_download("results") + case "download_scoring_results": + self.bulk_download("scoring_results") break; - case "download_prediction": - self.bulk_download("predictions") + case "download_prediction_results": + self.bulk_download("prediction_results") break; case "rerun": self.rerun_selected_submissions() @@ -973,5 +1107,10 @@ .soft-deleted background-color #ffdede !important + + .child-submissions-row + td + padding-top 0 + padding-bottom 0 - + \ No newline at end of file diff --git a/src/static/riot/competitions/detail/worker-monitor-toggle.tag b/src/static/riot/competitions/detail/worker-monitor-toggle.tag index 48ea73e50..f13f859fe 100644 --- a/src/static/riot/competitions/detail/worker-monitor-toggle.tag +++ b/src/static/riot/competitions/detail/worker-monitor-toggle.tag @@ -41,18 +41,21 @@ +
-
- Queues stats -
+
Queues stats
+
-
+
- - + + @@ -62,7 +65,7 @@ - +
{ qs.source_name } @@ -83,24 +86,24 @@
+
+ + Queue stats collapsed. +
+
Public compute workers { sortedWorkers().length }
- -
-
@@ -110,7 +113,7 @@ - + @@ -128,9 +131,8 @@ - -
WorkerWorkers Status Last seen
{ formatLastSeen(getLastSeenValue(worker)) }
+
No public compute workers detected
Waiting for the first websocket snapshot.
@@ -140,20 +142,25 @@
-
Public workers list collapsed.
-
-
- Private compute workers - { sortedPrivateWorkers().length } + +
+
+
+ Private compute workers + { sortedPrivateWorkers().length } +
+
- -
+
@@ -183,9 +190,8 @@ - -
{ formatLastSeen(getLastSeenValue(worker)) }
+
No private compute workers detected
Waiting for the first websocket snapshot.
@@ -195,6 +201,10 @@
+
+ + Private workers list collapsed. +
@@ -425,6 +435,8 @@ self.dragOffsetX = 0 self.dragOffsetY = 0 self.publicWorkersCollapsed = false + self.queueStatsCollapsed = false + self.privateWorkersCollapsed = false self.panelResizeObserver = null self.queueKey = function (worker) { @@ -462,6 +474,18 @@ self.update() } + self.toggleQueueStats = function (event) { + if (event) { event.preventDefault(); event.stopPropagation() } + self.queueStatsCollapsed = !self.queueStatsCollapsed + self.update() + } + + self.togglePrivateWorkers = function (event) { + if (event) { event.preventDefault(); event.stopPropagation() } + self.privateWorkersCollapsed = !self.privateWorkersCollapsed + self.update() + } + self.toggleWorkersPanel = function () { if (self.inlineMode) return @@ -481,6 +505,14 @@ self.update() } + self.sortedQueueStats = function () { + return (self.queueStats || []).slice().sort(function (a, b) { + if (a.source_name === 'default') return -1 + if (b.source_name === 'default') return 1 + return a.source_name.localeCompare(b.source_name) + }) + } + self.close_workers_socket = function (allowReconnect) { if (self.wsReconnectTimer) { clearTimeout(self.wsReconnectTimer) diff --git a/src/static/riot/datasets/management.tag b/src/static/riot/datasets/management.tag index e79714c0b..f599da38e 100644 --- a/src/static/riot/datasets/management.tag +++ b/src/static/riot/datasets/management.tag @@ -178,6 +178,15 @@
+

+ The zip file must contain the files directly at its root, not nested inside a + subfolder. A scoring or ingestion program needs metadata.yaml plus the + program file(s) (e.g. score.py) at the root. See + Competition Bundle Structure, Data Types And Their Role + for what each type means and how it is used. +

+ diff --git a/src/static/riot/profiles/organization_edit.tag b/src/static/riot/profiles/organization_edit.tag index 2a2c52ca8..536118da1 100644 --- a/src/static/riot/profiles/organization_edit.tag +++ b/src/static/riot/profiles/organization_edit.tag @@ -56,10 +56,8 @@
-
Submit
- - - +
Submit
+
Back to Organization Page