From c70dc5a6862bb38b61d1b4626f045ce1eebbba5d Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Tue, 22 Sep 2026 11:47:19 +0200 Subject: [PATCH 01/56] UI Ok, need to show child rows --- .../detail/submission_manager.tag | 80 ++++++++++++++----- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/src/static/riot/competitions/detail/submission_manager.tag b/src/static/riot/competitions/detail/submission_manager.tag index 37530345c..960f007e2 100644 --- a/src/static/riot/competitions/detail/submission_manager.tag +++ b/src/static/riot/competitions/detail/submission_manager.tag @@ -62,25 +62,37 @@ - - - - - - - - - - - - + + + @@ -99,7 +111,18 @@ - + + + @@ -277,6 +300,8 @@ self.next = null self.previous = null + self.expanded_submissions = {} + self.on("mount", function () { $(self.refs.search).dropdown() $(self.refs.status).dropdown() @@ -302,6 +327,16 @@ event.stopPropagation() } + self.get_children = function (submission) { + return _.filter(self.submissions, sub => sub.parent === submission.id) + } + + 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) } @@ -973,5 +1008,10 @@ .soft-deleted background-color #ffdede !important + + .child-submissions-row + td + padding-top 0 + padding-bottom 0 From 09617b3b9a96cbbee49a751649708789f4c55d2e Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Wed, 23 Sep 2026 10:14:36 +0200 Subject: [PATCH 02/56] table shows child subs and UX clean --- .../detail/submission_manager.tag | 112 +++++++++++------- 1 file changed, 71 insertions(+), 41 deletions(-) diff --git a/src/static/riot/competitions/detail/submission_manager.tag b/src/static/riot/competitions/detail/submission_manager.tag index 960f007e2..9e28ff11f 100644 --- a/src/static/riot/competitions/detail/submission_manager.tag +++ b/src/static/riot/competitions/detail/submission_manager.tag @@ -62,38 +62,28 @@
-
- - -
-
ID #File nameOwnerPhaseDateStatusScore - Detailed Results - Actions
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ID #File nameOwnerPhaseDateStatusScore
Aucune soumission enfant trouvée
{ child.id }{ child.filename }{ child.owner }{ child.phase ? child.phase.name : '' }{ pretty_date(child.created_when) }{ child.status }{ get_score(child) }
+
{ submission.id } + + + + { submission.id } + { submission.filename } { submission.owner } { submission.phase.name }
- - - + + + + + + + + + + + + + + @@ -103,7 +93,11 @@ Loading Submissions... - + + + + @@ -215,6 +213,38 @@ + + + +
- - - - - - - - - - - - - - - - - - - - - - - - - - -
ID #File nameOwnerPhaseDateStatusScore
Aucune soumission enfant trouvée
{ child.id }{ child.filename }{ child.owner }{ child.phase ? child.phase.name : '' }{ pretty_date(child.created_when) }{ child.status }{ get_score(child) }
-
+
+ + +
+
ID #File nameOwnerPhaseDateStatusScore + Detailed Results + Actions
No submissions found! Please make a submission
@@ -113,14 +107,18 @@
- - - - { submission.id } +
+ + + + + + { submission.id } +
{ submission.filename }
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ID #File nameOwnerPhaseDateStatusScore
Aucune soumission enfant trouvée
{ child.id }{ child.filename }{ child.owner }{ child.phase ? child.phase.name : '' }{ pretty_date(child.created_when) }{ child.status }{ get_score(child) }
+
@@ -1014,4 +1044,4 @@ padding-top 0 padding-bottom 0 - + \ No newline at end of file From adbbed6d159553f896f8b91f20742e92ff1622e6 Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Wed, 23 Sep 2026 14:43:51 +0200 Subject: [PATCH 03/56] adding task and group colomn to children sub rows --- src/apps/api/serializers/submissions.py | 18 ++++++++++++++++++ .../competitions/detail/submission_manager.tag | 7 ++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/apps/api/serializers/submissions.py b/src/apps/api/serializers/submissions.py index 4069965c4..fd6c409fd 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...""" diff --git a/src/static/riot/competitions/detail/submission_manager.tag b/src/static/riot/competitions/detail/submission_manager.tag index 9e28ff11f..ae7bbc8ae 100644 --- a/src/static/riot/competitions/detail/submission_manager.tag +++ b/src/static/riot/competitions/detail/submission_manager.tag @@ -223,20 +223,21 @@ File name Owner Phase + Task + Group Date Status Score - - Aucune soumission enfant trouvée - { child.id } { child.filename } { 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) } From 7d2af6171561886044cdc905026cccdb4a3ead39 Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Wed, 23 Sep 2026 15:05:21 +0200 Subject: [PATCH 04/56] hide singe task column --- .../riot/competitions/detail/submission_manager.tag | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/static/riot/competitions/detail/submission_manager.tag b/src/static/riot/competitions/detail/submission_manager.tag index ae7bbc8ae..d6a84529b 100644 --- a/src/static/riot/competitions/detail/submission_manager.tag +++ b/src/static/riot/competitions/detail/submission_manager.tag @@ -223,7 +223,7 @@ File name Owner Phase - Task + Task Group Date Status @@ -236,7 +236,7 @@ { child.filename } { child.owner } { child.phase ? child.phase.name : '' } - { child.task ? child.task.name : '' } + { child.task ? child.task.name : '' } { child.participant_group_name || '-' } { pretty_date(child.created_when) } { child.status } @@ -362,6 +362,12 @@ 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] From d9b20e6f043f6e2ac7dd55471361c6ad689ce599 Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Wed, 23 Sep 2026 17:00:15 +0200 Subject: [PATCH 05/56] add sub actions to children --- .../detail/submission_manager.tag | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/static/riot/competitions/detail/submission_manager.tag b/src/static/riot/competitions/detail/submission_manager.tag index d6a84529b..ccc4dce12 100644 --- a/src/static/riot/competitions/detail/submission_manager.tag +++ b/src/static/riot/competitions/detail/submission_manager.tag @@ -228,6 +228,7 @@ Date Status Score + Actions @@ -241,6 +242,61 @@ { pretty_date(child.created_when) } { child.status } { get_score(child) } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 223bbd40d7eb467868701043193cb03d9be9913b Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Mon, 28 Sep 2026 15:34:45 +0200 Subject: [PATCH 06/56] remove file name for children in sub table --- src/static/riot/competitions/detail/submission_manager.tag | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/static/riot/competitions/detail/submission_manager.tag b/src/static/riot/competitions/detail/submission_manager.tag index ccc4dce12..c7ce8b1b7 100644 --- a/src/static/riot/competitions/detail/submission_manager.tag +++ b/src/static/riot/competitions/detail/submission_manager.tag @@ -83,7 +83,6 @@ - No submissions found! Please make a submission @@ -95,7 +94,6 @@ - @@ -220,7 +218,6 @@ ID # - File name Owner Phase Task @@ -234,7 +231,6 @@ { child.id } - { child.filename } { child.owner } { child.phase ? child.phase.name : '' } { child.task ? child.task.name : '' } From 9af3428a22b21e2507b4c596bf3d97c86e325042 Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Thu, 30 Jul 2026 16:15:12 +0200 Subject: [PATCH 07/56] feature improvements: default queue and collapse button --- .../detail/worker-monitor-toggle.tag | 82 +++++++++++++------ src/templates/pages/monitor_queues.html | 3 +- 2 files changed, 59 insertions(+), 26 deletions(-) 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/templates/pages/monitor_queues.html b/src/templates/pages/monitor_queues.html index f7031c1b0..1e6c3eb05 100644 --- a/src/templates/pages/monitor_queues.html +++ b/src/templates/pages/monitor_queues.html @@ -6,6 +6,7 @@ {% endblock %} {% block content %} + {% if user.is_superuser %}
From dc462b6b86ce2cb38e38fc87c043106ccb6f434d Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Thu, 1 Oct 2026 10:47:51 +0200 Subject: [PATCH 08/56] adding butons for flower and rabbitmq --- src/templates/pages/monitor_queues.html | 95 +++++++------------------ 1 file changed, 25 insertions(+), 70 deletions(-) diff --git a/src/templates/pages/monitor_queues.html b/src/templates/pages/monitor_queues.html index 1e6c3eb05..d96195953 100644 --- a/src/templates/pages/monitor_queues.html +++ b/src/templates/pages/monitor_queues.html @@ -1,83 +1,38 @@ {% extends "base.html" %} {% load static %} - {% block extra_head %} {% endblock %} {% block content %} - - {% if user.is_superuser %} -
+
+ + +
-
- - -
+ -
-
{% endif %} - -{% endblock %} +{% endblock %} \ No newline at end of file From 9df4581d10ccf53fa2cf8afc69c4961525b875c0 Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Wed, 30 Sep 2026 14:48:51 +0500 Subject: [PATCH 09/56] Redesign home page announcements with levels, titles and priority - Add title, level (critical/warning/info/plain), is_active, priority and created_when fields to Announcement - Show all active announcements on the home page, ordered by priority (lowest first) then newest first - Render critical, warning and info as red, yellow and blue Semantic UI messages with icons; render plain announcements as text without background or icon - Style links in announcement text as bold and underlined, in the announcement's color - Move announcement styles to announcements.styl - Admin: show level badge, title, active flag and priority; add level and active filters and title/text search - Add tests for active filtering, ordering, level rendering and hiding the box when there are no active announcements --- src/apps/announcements/admin.py | 23 +++++- ...ed_when_announcement_is_active_and_more.py | 39 ++++++++++ src/apps/announcements/models.py | 19 +++++ src/apps/pages/tests/test_announcements.py | 76 +++++++++++++++++++ src/apps/pages/views.py | 7 +- src/static/stylus/announcements.styl | 41 ++++++++++ src/static/stylus/index.styl | 1 + src/templates/pages/home.html | 20 +++-- 8 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 src/apps/announcements/migrations/0005_announcement_created_when_announcement_is_active_and_more.py create mode 100644 src/apps/pages/tests/test_announcements.py create mode 100644 src/static/stylus/announcements.styl 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/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/static/stylus/announcements.styl b/src/static/stylus/announcements.styl new file mode 100644 index 000000000..c36646c06 --- /dev/null +++ b/src/static/stylus/announcements.styl @@ -0,0 +1,41 @@ +.announcement-container + width 100% + +.container-content.announcements + text-align left + + .announcement + margin 0 0 1em + + &:last-child + margin-bottom 0 + + .announcement-body + margin-top 0.4em + + p + margin 0 0 0.5em + + p:last-child + margin-bottom 0 + + a, a:hover + color inherit + font-weight bold + text-decoration underline + + .announcement.ui.message + align-items flex-start + + > .icon + font-size 2em + + .announcement.plain + color #4a4a4a + + .header + font-weight bold + font-size 1.14em + + .announcement-body a, .announcement-body a:hover + color #4183c4 diff --git a/src/static/stylus/index.styl b/src/static/stylus/index.styl index 091b842d7..33b18546f 100644 --- a/src/static/stylus/index.styl +++ b/src/static/stylus/index.styl @@ -1,6 +1,7 @@ @import "src/static/stylus/base_template.styl" @import "src/static/stylus/external_competitions.styl" @import "src/static/stylus/home.styl" +@import "src/static/stylus/announcements.styl" @import "src/static/stylus/forms.styl" @import "src/static/stylus/mixins.styl" @import "src/static/stylus/modals.styl" diff --git a/src/templates/pages/home.html b/src/templates/pages/home.html index 11ac43e20..18c2a2806 100644 --- a/src/templates/pages/home.html +++ b/src/templates/pages/home.html @@ -35,15 +35,25 @@ {% block content %} - {% if announcement %} -
+ {% if announcements %} +

Announcement

-
-
- {{ announcement | safe }} +
+ {% for a in announcements %} + {% if a.level == 'plain' %} +
+ {% else %} +
+ + {% endif %} +
+ {% if a.title %}
{{ a.title }}
{% endif %} + {% if a.text %}
{{ a.text|safe }}
{% endif %} +
+ {% endfor %}
{% endif %} From b51fc2afa4efc97ed54cb75a6288de93573dfe90 Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Wed, 30 Sep 2026 14:59:29 +0500 Subject: [PATCH 10/56] documentation updated --- .../Administrator-procedures.md | 30 ++++++++++++++---- .../docs/_attachments/announcements.png | Bin 0 -> 18842 bytes documentation/docs/_attachments/news.png | Bin 0 -> 18854 bytes 3 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 documentation/docs/_attachments/announcements.png create mode 100644 documentation/docs/_attachments/news.png 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 0000000000000000000000000000000000000000..4283be5f6cb25e11cd057d281eb02131608fc367 GIT binary patch literal 18842 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 literal 0 HcmV?d00001 diff --git a/documentation/docs/_attachments/news.png b/documentation/docs/_attachments/news.png new file mode 100644 index 0000000000000000000000000000000000000000..81eabd4a965f3afcaa5df1c6c5d2de7d852abd0b GIT binary patch literal 18854 zcmeIZcT`i+*DZ<_6&0ljh%^D|U_g3PQRyJPgC;}ak&)d{ zR(hsQMt126a7?^%9{8U&<7AJF?7F&*g2GE>1qIf(E>6}q_EuzMUa4^glvC0*JcRPZ-Y*%loz^^_*NAlNwu%o+;T;vV;P{79;e#6~?KdeFM zj_wzCXS0>glv|Gz{N`cXey-~Qr;^(kNqldE$OW>7aG|(`jAXJo0$5gysfh#~z6H?A%tDlXtyX z0A}EyBSV=cccpI!P^ub^us0j(2l3zFQArs+0LZfIlq=~HN|BqK6{G1K^7MN zF_5Uz-K`z;t6)Wo@qH?V>*d5-nlbp+txx;(Z1Swv!3rTF95uIR6~vx1{9(<$(amDa z){93!L6SR1C^6GZTe_-=v0Hmh+A#YBQ>a~Jc-bbU!kk^b!_g^rCU-~5+%`s|`9Tf*{{{a59;)(AF zhO|Xmg?iss*w^;gHx1!67-c^f@H>E>VwfxD?Vn)xXIohtvxY$#?;h)|NEmU1M(J%< zBOXo5zZjycid}edV+=>in0&?QblEwzp4Fq;>4{uwDz9Jtvt$<9+%4+j?uFnNdB@Fr z@8ABobne|Hnv$GA23E!pX`d9I+1Px}D;w{xTb{rDmLB{G#&NS!X-4!8&4+0=s&fZQ z@~6%Ex4GXP=V~EisH5KI7hGtkx7Ep?d)Iag|Nh};igemQm6+RIQUD_v*(bSp5VvijUwAZ&JaW0|}_q_7};FA;W6Q>2}*ugO0A_g|{Sw+mn;8-;T2a(pV<1+1CCcRg3EecS=x;lijSQxECCF z^fTIwt&3^PB#CyId$sBL#t`G((o6}hm!0%sENE*t%^T7;MsCwu6*cunb&l*r`?@L` zzlg$|@E$$&qGWUk^$TYDp#f<_l^OC4Uo;%mLNeI3-Q?SBhr=F8;65paGwg7YFT)Wc z7E?&!SeIO>T;|-U{7T=x72o-`_B6IMc50~ge3`^s^evPZ)q9*od|7N6BU$IJ;w}tE zD_dQ}+D#OijF~3ekdu1&aATWJaEF^rzP82U=Qp9$8;pViwq&W29536i>E0`uT#<}SeJ&N9((acNE*Jje3TFJQX{_0kZw47B-9f@@xTL-)QsmAigMteHIQb{ZJa%|Fq`uQjYIeTdl&psX+-_F?UeK z2V+gy=M4&e**Q7ZISb$J6h6>5$^A|9I%KYqs1r=#<}- z(f3CGk-YIWf%M8$eO2ivb&9Zp=HG7zXKb#B-m%rSd1Pza@AV}2x4_c9!$&@B4XOU` z{NGO%DEF23&)IH?(!J5QVeYRl8Y@QsPX0=p7nfV1W>+c(^B044RGt-ECoDzgy;}c9N(HnOck(+8&@@idz<4G#i2yWBMrvF?|-<(^&x{ zWa1?vA!3PEC1W#Ro~#?Dq0AwdAgXO^EU>OyqU9oRP^w)uwgg);-9E}as$^Tp~4NKAMYZr;W*Q-2DL3LUj9?9hsZ@|)cWj_b;Qm1en{bT|1^BClFil1HNd!!ogO zaXw+Yhjn%uqRjBRH8iylhOMpo)aD8kYrUewN*aP$Y)pZtiY)-8Hq3^ z7;Pgd3JJ*d(ckvI4%Xwk6Q(H6@lpp@d+{;za)njX)zX#x6~WO=q9YM}q)tr3UBSt; zD!1xI)vbM9`{B&}_PcBKdb`_oPktAB7hlj6*tc_E52qP*x-z%?g;3tIM7+3q^&55A zgL1X~fw5kr8Pj=RtNRY%-@iUmr*Jj8p*(Nb*n8B>cD_wpIV`kGA#jO%iYJ*gm_*fm zge4!n*dy38dCqx{pmC^?^0@mX$Y1&d@z!2)ELAp@j-){nJ`E%}RU;-3CL!kx zAHRKU^H}k#$1C4gRx=H(Wna6$)_!e#H8NAv-1hVe+iSLhr$yUvuk+>t#X^I!ZvKlM z4|hvOOFB!)XFX<1H{v@M=YDtCJ(vzHfq#DX==ldF^SR{qI;~+Xkxc){2QL(#8$6ef zOu8i^wBW|i?%}htHag-=a_0Nv*f#Nt@6QC);?=yX6<3|8E>Ib8d2mK(Ca1f}jjX+wy+gZzl%`3{|%p1uu)=ZP#U3nB}Q!{W=GglL@DgXI}lB(K78Znxs zqj+I2ce`IaXYXqDhushOr}DGLucO*c%8hq8TBITFtpjT7-J(gdMvX@EMh)PiYVBiT zX%3hwF(tJ<)lBk2O=!&x@45Q&dIc&6<;c0Y>o|rosGG`aHfMiP5<+C8XC?Myhezk_ z16^;|#+#FiRebg|)7)!x`!u?=^=WYr2|w%$$704-T~`+P)3&o7e%}bL**9&Ttn|*= zzJsZgPZtT8IB)sn@I&Rt1&VMw520)3x@C#A641@!=Niuo!b!$4)mho2-@UXpTbw>L zre~)^n+S$d&P$)N(^Wra<2zG3JG%r-qN&m4`mPcpIIg@ejcfwd~E&k8cyM~_Ko4XTt z6K_}@{g6X9LgjN#yc_~XNtG8?F6_!1k;*q&aj$IH`z3Nw#XJRh1(G#2+b(-EojI=AxYf~PJYJ?D>yLJHDG{uE=Y5k3usPaCZRDK+92Q@qu?v&0PPAn9yR(e?Ogk$QuZ zlvSr2W{zgT>*SM80k@83hziXu_^7!kIS)Gd99h8t@KzQvZ+l=E@f8t{$&n%W3;L11 zRn2)8PK!5|cyA7{_y?5laqhY|eQCJ2?>feJVo*nNIVdQU&p(|a)Q_-CeQ6vDFxieg zS=%}KwWlf9eW-M3xYB5&_JTGruzMSRD5b&1o-MRU`2N&MSd!Bo&dimWsOvE5h z{`TyvC%Vw_Cl^W7`e|(B6q7ZBEwX-YLX%ii@>kP0$&g>=WNF`V-z9TqCQFlW&bpaC ze;Je3`7{U`SlE}|zun*5#FH|(J)SW$?X*^(0vFa|QRXQR{CV3+?Ma;wom&JG@DX~EB(9YntX{r%+GT{QjK z`7N9OjTacl(BgL3%C4{l-2xCb=-IDaTbR^MNdS>az=h ztCc?ljXdIM{deQ7r!lPmy>9Jkr^Sh}<0C8%`@;&4>fjcOBK9+rL&EkyQuR=m-I%Lh zUWS`&Wk3!S(Tr9;d&;U{ENX9OUij5UeT5uG&ULlYAS%^=yCF)m>9iw~{WPg;O)tGU zaG@xgd_@{!g*+FfX3Bp7;k)qh2ee`+q(-%B1?&1JtqS8;iQktN(RXr?_j!VBURVM@ zH~)BalJJO8YU1>Qs0fdZv~44v-OJj3jk2h1E?d~SoFLHOZr=_SfJjC78FR@=xhG+C z-dJi(QgUOeH_&EBnrU%SUM)qLEoH;=2P#+pu5PH;PrKx3zE*+<^8x!rsHglE1F^?* zI^{K@@WuV1@#`fiF~8J~XLHlDANfpern#o4oI_N}9c?wH2OR8DCRq5N=pp*H1?iej zwJ!Vapc7E$UA^K{!jyj!&3_nSOHY3}HJz*>hE9DqY{F+dkIrGBOTUJS z%LaUKzfybutoyWi&Ws$qrMf0A8?S}x0Xr&azZ*<;q+e% z7jjR8f=D|}7H!*Z9-#P~W#$g?(X5!G?RB zf76-c&(&g^7X|d|vd#-pU5t>Et}F-)x1TK}dHI(P99ZD0UR)AjI$A#8clMw?*2*VIW|CH2;Y`22sa;h zXk{T`ju4t7T&^D(-X3@V8%)6Ii65YQ)be%Ow;6Mf@i_jDfM;=53`$U@d*Zk6Af?9UuQ(6PF=RG3In|9U=x=&rRUv z%Eto8p-oxVm-g)eLWfd-Ij$#7FM|Mt4o*oTMyEPJmpD0ug4#R?!x??H5;od@8eCHZj-$9}}FKZlu$QB)q%XNcAobRaiy_Pd6)GJ&426=8VBT^ZkThyZCCYzL8f;VV?K^q z72(ban-o3gel(pB>u5OIun=0vf%l)So`v+Y1iQImpJL6jZYW>|(a1a57HS%&rZ9(7 zCk|cgy`BAWBf%oTwawY+fk5j30*0RKLOE^IjX@6%*Ttum3-)+gQHZt036{~oRWN^_ zPDn>t3eOW-NXKl|)G52TH?0X}SYrme_)jm~pC6nm1+NR7> zxfz_ZY}ipe(4E3k1}%Y=Du{LD*|kr}@k$Rjb5xhxU-__vC;oaI2BhH-ehEBJX<_Ga z&!y?Ktu#uT1gZ;b+MTtiD`(7tX9R&LC(H102FU9ci_fOS5!qV9p+r?9*NqMZ}`mY1`=D6^j`X8 zrZ|1D=<3Zj9)St)i+22{1O7%>GrkS0BOw7Ln08dD`N?JAGP4C-G&W%gw5`Xi7lAt zzqrr%41^m;80rg3-nU`94rfSEC|D2qm# zSe#m~M|lN8<|tYg`)G*8)#*hg#N&rYx*#9QXh;BmVFB4WlKc5BD8AlrO(c!_L?z=W zi*xDq9mr9tB|w!Qjp3ZyO+8pjuhohF1pOImX3$$4&T{EEy%02A4?)}Q($k=$;XFPIu_`ISS;hi-)h9!OX~ke9-nBnto_^yb zN-{X1YP+`SE7Id5M_nkhRKV_D!Il8!uXoAfz$>ENG1c1nM(`_kk)lLgbq zm^bd1WRhsJ=v5mVc3N1*CYF0}?c~s$U}NB2U1MnL=?Tcjgrh-=2V3n6`X1T;Lj9M> ztY}X`6FmzIuP`7EHoMt&Z^9cOvei&j7%EG1A?#upYA#6~<{P#di>d!wr4l+6tJOit zHFcuobhpea6Bk+dqI+mHDcyYEXWX^8oIz4^xj^P?qX$pzT7fpFOu?bUhf%*3hwYo~ z_GdE8-|p=!AsnH}Rs7uigLEWQ85V80XXe=*1LVNLrmvK@>q@g6x*EM9X&n{#fez9E zOK1}P+$!hRyuA>9-AK5KNh({D4U_o8MdL5NwdVJUB7Fz5^)-8RgUStg(1EfD_2c= zZo%=d`$?j^bu9knI&_C73D1^dYo5yG ziAvv$FshHXqx)B+#GJ7q4dfq0TwzlNK;i*9@!vN-Rifgk&rt^b2YPSCTm-UL8~%?R z*n&8o{5QK*S^r;odgadl0cGibQ5s)+GG;4ocMWE~k((Unl-S>7KeCYbQI?(i;@?tw zMn#oiSbN$ze)jS*ce8R_cQ4g+ZU7DT>!9jI(IDtGD?#%+|b8fx*kHqhDM ztJ+;fRt2ory-|IQp#X~c4DKwjX%nP)bE3CBZ0&?n7V!fcTrAJOXSOly8!yUBs?N zhv=951Ium*&pt^$zcLnKr{BniAm=fti1+4`5pltxz$0c(QMvSVPk=fPnBgJ&!A0%mtaMiZ7 zrtPLw-rX3)E-^V)AKXcoJn-D?-{RwC9~j@Q-i((aUDQ>!o80MpEpQlB5I411%P1@O za3(RruIRu38&BE6=%QE%V``aGo_FRQRHGm=_?holh);6281Jy8mpx29?3mP=I@bp2 z#2Yqfse0e??kD(7%aW13^Szo$kMY4}j7dnY{y30tI*})(oq8E5=8u0hifAo#mu+jJ z?+Qa9jr1CGqmkoDJRK(R3fdIaN8XO&k-4;8*Yf?TXN~wQZ@4y_G+8$lG<6z(Pg)dg zEtL~mFT5G@{BrxaO?K^#?80B$!d5qGJi>d8wZYMxPpliQ2RulAPddh{2k&{$6)aT` zzM;kWW9pF>qdR8RW5+UY(HeV7OS;D2vVA+J>5d3hOXNZl*APaNoL2dRUM>U52X+~O z__RYKmEt3wtZc%FD$)E-MT%!>a7TY$l@#-Aj!Af(Q}V{Lkw`YSWGPC`EJwte)G90N zUUO8X#0>3T(210cmUq2TNA$#a#KONtvUAVzOa?EFmu-$t?h+Y=t=73Xd3B?ka{Wrp zU1TEhjB!6i4x2WLV(-A1No=P=eTuP^W|Vy>`8^p58DK6IjNAJAVN56!lm|p{%l!ry zkC=5t_QV$`OLEEYV}8y~*9{=7Bgb6E%`r*)H_+aH~p<&9D2Ue)@8YI+JK1m z=t#$V_@t`VhOJ6(g>lOC+jiZMZLv9I&P~iCofYt;_sV&gnP+QsF{9fO&i}JmAQzz( zy3L^M{5@`}kD2|_=p=7J9i^PuPhYJV9c7KvevfwDy_Qbfn#Q11&e!5my54QNLa@xw8Yt((D?x^O) z-5lBdDWWVSqC2r-n0ZN_aex@G`Kx~w5v5Z#ukczX^W^6?P~VXz$iwDHZvL*_Y`|1T zl@2+2c+Tvzdq!=CUM`$;&^O51nKI5#J@8&kK3R)K7d1E@Hd8PYs&WIOeNyLnTdmfS zHcl=jBa3Q!szuNac?}>xqQTrBJ9KyggE*=-uN3Hr#DV)o8=+<%HT zOlYccBE8A!mLsJl{UPbU2uCR|d^;s3*J;Fa9Gp~zjadXcj0W&$ZQk_qP|WRfRc!Dw zHw)s@#irGh;p1LvEF81Yoq$=-!Tf3GLHm>RV6}t*l^rTyVS|MS z2PV@TkT>(NyB*|N0hi_i=$(b!0Bm0_QUk?*vTht6iPcK+o3@l~^D|5@^66NPKu+r` z+wl{0iH$BEOfhR&jLckX$0bAWa@Ua4<%BFWaT!cSi<2>NZVW@+(aK9WNWLNPz`Dmm z|C!aL3ZKk)O-HGuI9OoB_p$P{%IiivaiViCu!x5yZe=d(4~w@Ua?cJD=UQT}o1%0^ zV7s4{dCu38WN;e>r3?Ig4FCj=U>`E6e&B~}J+a%2kDIf-VbXrL(NVLu$`9k)aP_%* zP}pY3u3|Uz0cVez_wkatQ9@I`^YzMb)bZ8dIPPDABl6+{R^R$Ujp04#3Z%^T?o{q6 zcv&vH_`Nmy?e251>|TLZb`YVa(1sH#R*MGap=p9m%+}yDK+G7_VB#+}I6be@Ax^!U zU^z|_3NwSH-5h2b%zjY|RWOe}CMJiXi|VH24wC%zs@jCs#~jmd3ZS5H{KwpVgs*DT zx00p;K7$JVg;^o!y#r(3T3Hc*%jM2yJI8X^TngSjmRHg9na4Veh8|{#mby_MMJseV zVqAbEEtv;-F!eW-X1h!e&fr27^>R==<#}dj;4gZ z?SQ|QfGoP(A(b`u2kg|P+##pCyXDh&r7cjY$)z|dV$!R-ukj1e{XmQRVd5ON>-P)d zI)W!e8pD|iI(dc=&|bL*v}rZ%SQ=|jd8|Ch^KsP zGpG85^vCv%4wY1pcfW$!f%`*MKpN|^oedFMizF0LqlQNxane{7Tgau%~WP= z^TJ+1Cd3Tj76_gz^Yp#|Gw*u$c)DDr!y!Zrq+2z|jzVkl{;u!ZO5v*box0h*cGIPh zsjOlpXl7S6|BEp=Gl!x92q!rWN&^F(awT!@d1f4Gk zZ(NpeQI@;Z8Pgou9_ANsx7VFT9S62whf2ShcZ|?*JC?Hp4PS`StN0|~8|IwED+(<7 z10DFPj-BhHWL4*%6zz9lB(y-z|7<#hISjOuDE|8IN@c-x%T{?_Ij_;b4KhK1I_Iva5 zDU&@h0pWQ_hY1!w`n`OZSd?75*Dlhn;rqOveqx00W(jSs;Y9f)%;IpnCej&@W0UA= zfX22SOpJJ=aDuIXJXrG@-5MfDg`sLzdM9<{y?bXvZYVOTK1D*S*xh9<))k_uLcz!D zf3k6Th92rSis>hw8&t6soN>&QZP684ThK7Ozbt0TG4|+WQltmkjw7CsE z@(xE;g3~#M!*r0eyN2sf{sZ3DT7q4#YuceVadX{LR4>IAj5aJD(Oan{U|rv66wH}Z zm6kRIIR7$rQ1pMho}T{fz82hF)9-YY;-wc_0xiW@c~sg^8yhJ2vH!Q*O(wi=|Ge}p z7@2FqTw))53QIJu{5j9yYTnbW133RLF^MrRLHgoYV^g`D+l-Op7BN^~lMXNIs{lE~ ze+_d!uytQKzY;QKSy`RMQO{T5z6{aZ@ysqVI=GAtG!D3#Ps>=#lnqY1jB5RY^h%() z5sKL7wE#x^eem@lKdcJ6r!fM&6EOGIIn|WbBm)#U=jWU;RcY1tjsTpGMa)aUWAhIe zdwXCvGW9l--H*-zR5hM!L|H*k*zL6RzZY1iVGMTcKLNQoDfGJc*4{a3qb;GYVv$p@ zsKXf^)D1|hjgr=a3@}9Vl0LOQ3L%5G99G&dez&^=Ppzh=gEqe`2076 zYJ^yzbn8A2bzO=NqNAorkIFRd5!*|Pm%an|K}D?#iWiMXu9$htYF;_U*cE~7)P1<~ z%;TTuOpsG`Dq*@rm7|2Ib5~9{M&^?{xtiyb%js3m#i#4Ic;Y@(ft|M*K)fv?1WL=5Phj>_pPY;9 z3ma6Kjr_@XZoe=iWoX=ITJMv@P#V{m1Y7_p`=j6GQlM7YL7e{lU2)W7VMyJ;?+UQ! zPDT5@o5mZZbsKZF!b!LXYsbIKZgzwf z6!CT~#}9Hboc<2_=;M%(VF6#gi_|%9gZ`vHja+f9P$_Gu(3Rx5q%QnvaVeP-VA8?5 zIXeSezf&OC^b23B=2e&077z*m&iJyDHoNO0>0x>9A)Rt%LXe$PwbmYE!pP9dX5Uug z?zl{bLB_mP>+>}KQ6N7;MHf&t1XtUQ`St#%0OPzBgyly^*YGyzK(n2WlTz-n{y11hK(;>T(rO6^u)hLP2D^y^Obw# ztQ~bDZMdF;#8M(JIH6x8I2)mcnR||wBnBJ+ZMb%_7P4lV^cJMiA&075k-h<@Isc!TJRZvV43Oa$nRczC&?ZArpT1A zgknq0gj#}Q7PY7UqGbBk)0F`(rBAW}FA-g44|YFTM^Yt%^%Ree^1rOeM_x#1vOaBy zCI4b^71LB=v+5(!>&qQTE??E=yp(5H_U-6ZyIoc8w6BRW25h)!5VbCx>khCqm){qIgJfU1+JNY%BJ(c|Er?I4r)Rp3nXi8URf{~t$qsY;%r zUH-U$REh~;54lff1%I4igxqz9{dCMKJsh{$F=fi*+7a-F^)g4BaS1rf*pEruC8tap zXaEx_n}~D~U+h=RuSfznx;hJ8ozEW@9@*)vux_+1Ub9TDFWLf|?qJ_;3oJ-s0bN(k zQxSAJa~4)UFkn6B&EI1}2XWH2SePcEC~n=xlI8KAB|tjbi8*iu78`0^-(|>-SDjQ` zXg_)jR3ipkRzPf+g8JDWej+}lJs?lWGtLVTrm>WB_|9J0x zT3ff1xXgCZ5%Z4BhV<)GLG#14otvc|$@p3ynYn?4oA;x=J*I6V@2C22e8Bsue+0ra z7Rf;_Eb{!^KdUffepq|Hdn%e@>ab!ZO>{Ly*t!s85(vWtd@HeD@64nJGL`J{+;tP! zW6xm3;UWHl!OA_L;zj7IUn|3bQ3m2+Am9qJXT9oqoGV?^qe*^$`d3AlX^Gd(wf;57 zgnTdekOd&g>SqBIkmx?0Q~1YvFame7zMj1UF=8YI1ROE41+`lQzC0a2dm`Y| zP(Lkt_V@AJrQEGSpZRY8{mw9cpo3;kCFpD*@9q=A?%)&xPGR5D&xPD_vAW;1{`ZYI zKJppEjYKYI1X=_D596Q+1HF5>L7)U~v3vr$eCwM33D7O{nLh?epK$pz$HM7$I
Fq2TMRG=CpAS-|%U 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 Q>~)o&YdkA@`s%~~0KatgJOBUy literal 0 HcmV?d00001 From 721d830218eaa6fe0262194788e9d2893dc32cb5 Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Thu, 1 Oct 2026 12:43:16 +0500 Subject: [PATCH 11/56] Fixed factory_boy post-generation deprecation warnings in test factories - skip_postgeneration_save = True added to UserFactory, CompetitionFactory, TaskFactory, PhaseFactory and SubmissionScoreFactory - UserFactory super_user hook now saves the user - CompetitionFactory collaborators hook now saves the competition after adding collaborators --- src/factories.py | 9 +++++++++ 1 file changed, 9 insertions(+) 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) From 0005c4654d31bb6ee4d84592b09ea954e29ba2f0 Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Thu, 1 Oct 2026 11:05:27 +0500 Subject: [PATCH 12/56] Competition detail serializer now uses a list of public fields for non-admins - ADMIN_ONLY_FIELDS replaced by Meta.public_fields and Meta.admin_fields; Meta.fields is built from both - Non-admins now receive only the fields in public_fields - enable_detailed_results, show_detailed_results_in_submission_panel, show_detailed_results_in_leaderboard, forum and forum_enabled are now visible to non-admins - Tests updated: admins see all fields, non-admins see no admin fields; secret key checks removed --- src/apps/api/serializers/competitions.py | 60 +++++++++--------------- src/apps/api/tests/test_competitions.py | 17 +++---- 2 files changed, 29 insertions(+), 48 deletions(-) diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index f2a4faf19..748eba5fb 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', + 'docker_image', + 'competition_type', + 'reward', + 'contact_email', + 'report', 'enable_detailed_results', 'show_detailed_results_in_submission_panel', 'show_detailed_results_in_leaderboard', + 'forum', + 'forum_enabled', + ) + # 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', + 'registration_auto_approve', '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', - 'forum_enabled', - 'enable_human_in_the_loop' ) + 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/tests/test_competitions.py b/src/apps/api/tests/test_competitions.py index 307aa93b9..80a30f970 100644 --- a/src/apps/api/tests/test_competitions.py +++ b/src/apps/api/tests/test_competitions.py @@ -180,32 +180,27 @@ 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_with_secret_key_do_not_see_admin_only_fields(self): """ @@ -215,7 +210,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): From 5ee76f893718633d348b0546523bdcb023e3d029 Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Thu, 1 Oct 2026 11:24:37 +0500 Subject: [PATCH 13/56] Submission get_details now returns detailed result only when detailed results are enabled - SubmissionFilesSerializer.get_detailed_result returns None when the competition's enable_detailed_results is off - Tests added for get_details with detailed results enabled and disabled --- src/apps/api/serializers/submissions.py | 2 ++ src/apps/api/tests/test_submissions.py | 30 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/apps/api/serializers/submissions.py b/src/apps/api/serializers/submissions.py index fd6c409fd..0fbb42e05 100644 --- a/src/apps/api/serializers/submissions.py +++ b/src/apps/api/serializers/submissions.py @@ -291,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_submissions.py b/src/apps/api/tests/test_submissions.py index a57b024c0..a9a0cdbfd 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): From a2d6bf0d465e13162f538fdda3f82ef2eaf3ebc2 Mon Sep 17 00:00:00 2001 From: Obada Haddad-Soussac <11889208+ObadaS@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:42:26 +0200 Subject: [PATCH 14/56] Update version.json --- version.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/version.json b/version.json index c155bea47..be09df3d5 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "tag_name": "v1.33", - "release_name": "v1.33", - "html_url": "https://github.com/codalab/codabench/releases/tag/v1.33" + "tag_name": "v1.34", + "release_name": "v1.34", + "html_url": "https://github.com/codalab/codabench/releases/tag/v1.34" } From 0c34a5a13919887e4233f4d36178198f6d986585 Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Mon, 28 Sep 2026 13:00:26 +0500 Subject: [PATCH 15/56] Add bulk download of prediction and scoring results - Add download_many_prediction_results and download_many_scoring_results endpoints - Rename download_many to download_many_submissions - Share auth, validation and permission checks in _download_many_files - Only include results of finished submissions, skipping missing or hidden files - Prefix downloaded file names with sub_, pred_res or sco_res_ - Remove unreachable results loop - Add prediction/scoring result options to the bulk action dropdown --- src/apps/api/views/submissions.py | 92 +++++++++++-------- src/static/js/ours/client.js | 14 +-- .../detail/submission_manager.tag | 28 ++++-- 3 files changed, 82 insertions(+), 52 deletions(-) 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/static/js/ours/client.js b/src/static/js/ours/client.js index 3336913a1..850f08d78 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 ---------------------------------------------------------------------*/ diff --git a/src/static/riot/competitions/detail/submission_manager.tag b/src/static/riot/competitions/detail/submission_manager.tag index c7ce8b1b7..b697aa5c7 100644 --- a/src/static/riot/competitions/detail/submission_manager.tag +++ b/src/static/riot/competitions/detail/submission_manager.tag @@ -17,7 +17,9 @@ @@ -861,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'); @@ -872,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) => { @@ -982,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); @@ -1024,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() From 9a0ca37984dc822b801f93c577a42ed5f701fd08 Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Mon, 28 Sep 2026 13:39:31 +0500 Subject: [PATCH 16/56] bulk download tests added --- src/apps/api/tests/test_submissions.py | 250 +++++++++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/src/apps/api/tests/test_submissions.py b/src/apps/api/tests/test_submissions.py index a9a0cdbfd..d8b294b87 100644 --- a/src/apps/api/tests/test_submissions.py +++ b/src/apps/api/tests/test_submissions.py @@ -787,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 From 882bc8bc4004df9be9745ae00090cc98c0e3182f Mon Sep 17 00:00:00 2001 From: didayolo Date: Fri, 26 Jun 2026 15:16:08 +0200 Subject: [PATCH 17/56] Move and rename button --- src/static/riot/tasks/management.tag | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/static/riot/tasks/management.tag b/src/static/riot/tasks/management.tag index 20bed9ae1..1c3367404 100644 --- a/src/static/riot/tasks/management.tag +++ b/src/static/riot/tasks/management.tag @@ -362,8 +362,8 @@
-
Update
Cancel
+
Save
From 377dae8c253893008ddc504a160f89bd01fb8f87 Mon Sep 17 00:00:00 2001 From: Ihsan Ullah Date: Fri, 2 Oct 2026 11:18:25 +0500 Subject: [PATCH 18/56] Add back competition detail fields for non-admins - Move fact_sheet, registration_auto_approve, make_programs_available and make_input_data_available to public_fields in CompetitionDetailSerializer - Add test that non-admins get the fields used by the competition page --- src/apps/api/serializers/competitions.py | 8 ++++---- src/apps/api/tests/test_competitions.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index 748eba5fb..9b76dfb7e 100644 --- a/src/apps/api/serializers/competitions.py +++ b/src/apps/api/serializers/competitions.py @@ -408,6 +408,10 @@ class Meta: 'show_detailed_results_in_leaderboard', 'forum', 'forum_enabled', + 'fact_sheet', + 'make_programs_available', + 'make_input_data_available', + 'registration_auto_approve', ) # Fields only visible to competition admins (creator, collaborators, staff/superusers) admin_fields = ( @@ -417,11 +421,7 @@ class Meta: 'queue', 'auto_run_submissions', 'enable_human_in_the_loop', - 'registration_auto_approve', 'can_participants_make_submissions_public', - 'make_programs_available', - 'make_input_data_available', - 'fact_sheet', ) fields = public_fields + admin_fields diff --git a/src/apps/api/tests/test_competitions.py b/src/apps/api/tests/test_competitions.py index 80a30f970..258072c54 100644 --- a/src/apps/api/tests/test_competitions.py +++ b/src/apps/api/tests/test_competitions.py @@ -202,6 +202,29 @@ def test_non_admins_do_not_see_admin_only_fields(self): for field in CompetitionDetailSerializer.Meta.admin_fields: assert field not in resp.data, field + 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): """ A logged-out user and an unrelated user open an unpublished competition with its secret key. From db25c378ad7cb8760cb6a4772e37285145d64148 Mon Sep 17 00:00:00 2001 From: didayolo Date: Thu, 28 May 2026 15:33:39 +0200 Subject: [PATCH 19/56] Add leave organization button --- src/apps/api/views/profiles.py | 12 ++++ src/apps/profiles/views.py | 7 ++- src/static/js/ours/client.js | 3 + .../riot/profiles/organization_edit.tag | 10 ++-- .../profiles/organization_detail.html | 57 +++++++++++++------ 5 files changed, 66 insertions(+), 23 deletions(-) diff --git a/src/apps/api/views/profiles.py b/src/apps/api/views/profiles.py index 00d963d26..3da17e84c 100644 --- a/src/apps/api/views/profiles.py +++ b/src/apps/api/views/profiles.py @@ -260,6 +260,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/profiles/views.py b/src/apps/profiles/views.py index 161fd5ffe..0503c1f02 100644 --- a/src/apps/profiles/views.py +++ b/src/apps/profiles/views.py @@ -418,11 +418,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/static/js/ours/client.js b/src/static/js/ours/client.js index 850f08d78..7fad86fe3 100644 --- a/src/static/js/ours/client.js +++ b/src/static/js/ours/client.js @@ -337,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/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