diff --git a/package.json b/package.json index 11d82838a..c33169f2e 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,12 @@ "version": "0.0.1", "dependencies": { "jquery": "^4.0.0", + "js-beautify": "^1.15.4", "npm-watch": "^0.13.0", "riot": "^3.13.2", "stylus": "^0.64.0", "uglify-js": "^3.19.3" }, - "devDependencies": {}, "watch": { "build-stylus": { "patterns": [ diff --git a/src/apps/api/serializers/leaderboards.py b/src/apps/api/serializers/leaderboards.py index 41f61b168..cf6c56dbd 100644 --- a/src/apps/api/serializers/leaderboards.py +++ b/src/apps/api/serializers/leaderboards.py @@ -112,7 +112,7 @@ def get_submissions(self, instance): leaderboard=instance, is_specific_task_re_run=False ) - .select_related('owner') + .select_related('owner', 'queue') .prefetch_related('scores') .annotate(primary_col=Sum('scores__score', filter=Q(scores__column=primary_col))) ) diff --git a/src/apps/api/serializers/submission_leaderboard.py b/src/apps/api/serializers/submission_leaderboard.py index 5e86fd3fc..8cbabf3cb 100644 --- a/src/apps/api/serializers/submission_leaderboard.py +++ b/src/apps/api/serializers/submission_leaderboard.py @@ -26,11 +26,43 @@ class SubmissionLeaderBoardSerializer(serializers.ModelSerializer): slug_url = serializers.CharField(source='owner.slug_url') organization = SimpleOrganizationSerializer(allow_null=True) created_when = serializers.DateTimeField() + queue_id = serializers.SerializerMethodField() + queue_name = serializers.SerializerMethodField() + + def _get_effective_queue(self, obj): + if obj.queue: + return obj.queue + + if obj.parent and obj.parent.queue: + return obj.parent.queue + + if obj.phase and obj.phase.competition and obj.phase.competition.queue: + return obj.phase.competition.queue + + return None + + def _get_display_queue_name(self, obj): + queue = self._get_effective_queue(obj) + if not queue: + return None + + raw_name = queue.name or "" + group_name = raw_name.rsplit("__", 1)[-1] # comp10__CLB -> CLB, APHP -> APHP + submission_parent_id = obj.parent_id or obj.id + + return f"{submission_parent_id}_{group_name}" + + def get_queue_name(self, obj): + return self._get_display_queue_name(obj) + + def get_queue_id(self, obj): + queue = self._get_effective_queue(obj) + return queue.id if queue else None class Meta: model = Submission fields = ( 'id', 'parent', 'owner', 'leaderboard_id', 'fact_sheet_answers', 'task', 'scores', 'display_name', 'slug_url', 'organization', - 'detailed_result', 'created_when' + 'detailed_result', 'created_when', 'queue_name', 'queue_id', ) diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index 53b9d2193..6eb994f12 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -1,7 +1,7 @@ import zipfile import json import csv -from collections import OrderedDict +from collections import Counter, OrderedDict from io import StringIO from django.http import HttpResponse from tempfile import SpooledTemporaryFile @@ -424,26 +424,43 @@ def collect_leaderboard_data(self, competition, phase_pk=None): phase_id = phases[0].id leaderboard = Leaderboard.objects.prefetch_related('columns').get(phases=phase_id) - leaderboard_titles = {phase['id']: f'{leaderboard.title} - {phase["name"]}({phase["id"]})' for phase in submission_query} + leaderboard_titles = { + phase['id']: f'{leaderboard.title} - {phase["name"]}({phase["id"]})' + for phase in submission_query + } leaderboard_data = {title: {} for title in leaderboard_titles.values()} for phase in submission_query: generated_columns = OrderedDict() for task in phase['tasks']: for col in leaderboard.columns.all(): - generated_columns.update({f'{col.key}-{task["id"]}': f'{task["name"]}({task["id"]})-{col.title}'}) + generated_columns.update({ + f'{col.key}-{task["id"]}': f'{task["name"]}({task["id"]})-{col.title}' + }) + for submission in phase['submissions']: - submission_key = f'{submission["owner"]}-{submission["parent"] or submission["id"]}' - if submission_key not in leaderboard_data[leaderboard_titles[phase['id']]].keys(): - leaderboard_data[leaderboard_titles[phase['id']]].update({submission_key: OrderedDict()}) - if 'fact_sheet_answers' in submission.keys() and submission['fact_sheet_answers']: - leaderboard_data[leaderboard_titles[phase['id']]][submission_key]\ - .update({'fact_sheet_answers': submission['fact_sheet_answers']}) + queue_name = submission.get('queue_name') or '' + submission_key = f'{submission["owner"]}-{submission["id"]}' + if queue_name: + submission_key = f'{submission_key}-{queue_name}' + + if submission_key not in leaderboard_data[leaderboard_titles[phase['id']]]: + leaderboard_data[leaderboard_titles[phase['id']]][submission_key] = OrderedDict() + + if submission.get('fact_sheet_answers'): + leaderboard_data[leaderboard_titles[phase['id']]][submission_key].update({ + 'fact_sheet_answers': submission['fact_sheet_answers'] + }) + for col_title in generated_columns.values(): leaderboard_data[leaderboard_titles[phase['id']]][submission_key].update({col_title: ""}) + for score in submission['scores']: score_column = generated_columns[f'{score["column_key"]}-{submission["task"]}'] - leaderboard_data[leaderboard_titles[phase['id']]][submission_key].update({score_column: score['score']}) + leaderboard_data[leaderboard_titles[phase['id']]][submission_key].update({ + score_column: score['score'] + }) + return leaderboard_data @action(detail=True, methods=['GET'], renderer_classes=[JSONRenderer, CSVRenderer, ZipRenderer]) @@ -773,6 +790,23 @@ def rerun_submissions(self, request, pk): @action(detail=True, methods=['GET'], permission_classes=[AllowAny]) def get_leaderboard(self, request, pk): phase = self.get_object() + + def _clean_group_label(raw_name, submission_parent_id=None): + if not raw_name: + return None + + label = str(raw_name) + + if submission_parent_id is not None: + prefix = f"{submission_parent_id}_" + if label.startswith(prefix): + label = label[len(prefix):] + + if "__" in label: + label = label.rsplit("__", 1)[1] + + return label or None + if phase.competition.fact_sheet: fact_sheet_keys = [ ( @@ -792,21 +826,73 @@ def get_leaderboard(self, request, pk): 'submissions': [], 'tasks': [], 'fact_sheet_keys': fact_sheet_keys or None, - 'primary_index': query['leaderboard']['primary_index'] + 'primary_index': query['leaderboard']['primary_index'], + 'has_group_queues': False, } - columns = [col for col in query['columns']] + columns = list(query['columns']) submissions_keys = {} submission_detailed_results = {} + group_name_by_user_queue = {} + for group in phase.competition.participant_groups.filter( + queue__isnull=False + ).select_related('queue').prefetch_related('user_set'): + cleaned_group_name = _clean_group_label(group.name) + for user in group.user_set.all(): + group_name_by_user_queue[(user.username, group.queue_id)] = cleaned_group_name + + parent_ids = { + s['parent'] + for s in query['submissions'] + if s['parent'] is not None + } + parent_task_counts = Counter( + (s['parent'], s['task']) + for s in query['submissions'] + if s['parent'] is not None + ) + for submission in query['submissions']: - submission_key = f"{submission['owner']}{submission['parent'] or submission['id']}" + if submission['id'] in parent_ids: + continue + + submission_parent_id = submission.get('parent') or submission.get('id') + raw_queue_name = submission.get('queue_name') or '' + queue_id = submission.get('queue_id') + + group_name = group_name_by_user_queue.get( + (submission['owner'], queue_id) + ) if queue_id else None + + group_label = _clean_group_label( + group_name or raw_queue_name, + submission_parent_id=submission_parent_id + ) + + display_group = ( + f"{submission_parent_id}_{group_label}" + if group_label + else None + ) + + parent_id = submission['parent'] + task_id = submission.get('task') + + # Cas particulier: plusieurs submissions d'un même parent sans queue explicite + is_multi_group_null_queue = ( + parent_id is not None + and not queue_id + and parent_task_counts.get((parent_id, task_id), 0) > 1 + ) + + if is_multi_group_null_queue: + submission_key = f"{submission['owner']}{parent_id}_{submission['id']}" + else: + submission_key = f"{submission['owner']}{submission_parent_id}_{group_label or ''}" + # gather detailed result from submissions for each task - # detailed_results are gathered based on submission key - # `id` is used to fetch the right detailed result in detailed results page - # `detailed_result` url is not needed submission_detailed_results.setdefault(submission_key, []).append({ - # 'detailed_result': submission['detailed_result'], 'task': submission['task'], 'id': submission['id'] }) @@ -821,23 +907,18 @@ def get_leaderboard(self, request, pk): 'fact_sheet_answers': submission['fact_sheet_answers'], 'slug_url': submission['slug_url'], 'organization': submission['organization'], - 'created_when': submission['created_when'] + 'created_when': submission['created_when'], + 'queue_name': display_group, }) - for score in submission['scores']: + if queue_id or is_multi_group_null_queue: + response['has_group_queues'] = True - # to check if a column is found - # this is useful because of `hidden` field - # if a column is hidden it will not be shown here so - # we will not return that score to the front-end + for score in submission['scores']: column_found = False - # default precision is set to 2 precision = 2 - # default hidden is set to false hidden = False - # loop over columns to find a column with the same index - # replace default precision with column precision for col in columns: if col["index"] == score["index"]: precision = col["precision"] @@ -847,13 +928,8 @@ def get_leaderboard(self, request, pk): tempScore = score tempScore['task_id'] = submission['task'] - # round the score to 'precision' decimal points tempScore['score'] = str(round(float(tempScore["score"]), precision)) - # only add scores to the scores list - # if this column is found - # and - # column is not hidden if column_found and not hidden: response['submissions'][submissions_keys[submission_key]]['scores'].append(tempScore) @@ -877,7 +953,6 @@ def get_leaderboard(self, request, pk): # --- end pagination addition --- for task in query['tasks']: - # This can be used to rendered variable columns on each task tempTask = { 'name': task['name'], 'id': task['id'], diff --git a/src/apps/competitions/admin.py b/src/apps/competitions/admin.py index dc5f489c0..b13b703cd 100644 --- a/src/apps/competitions/admin.py +++ b/src/apps/competitions/admin.py @@ -1,9 +1,13 @@ from django.contrib import admin +from django import forms +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.contrib.auth.models import Group + from django.utils.translation import gettext_lazy as _ import json import csv from django.http import HttpResponse -from profiles.models import User +from profiles.models import CustomGroup, User from . import models @@ -409,3 +413,45 @@ class PhaseExpansion(admin.ModelAdmin): admin.site.register(models.Page, PageExpansion) admin.site.register(models.Phase, PhaseExpansion) admin.site.register(models.Submission, SubmissionExpansion) + + +class CustomGroupAdminForm(forms.ModelForm): + users = forms.ModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + widget=FilteredSelectMultiple("Users", is_stacked=False), + help_text="Add/Remove users for this group." + ) + + class Meta: + model = CustomGroup + fields = ('name', 'permissions', 'queue', 'users') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + self.fields['users'].initial = self.instance.user_set.all() + + +@admin.register(CustomGroup) +class CustomGroupAdmin(admin.ModelAdmin): + form = CustomGroupAdminForm + list_display = ('name', 'queue') + search_fields = ('name',) + filter_horizontal = ('permissions',) + fieldsets = ( + (None, {'fields': ('name',)}), + ('Permissions', {'fields': ('permissions',)}), + ('Utilisateurs', {'fields': ('users',)}), + ('Options', {'fields': ('queue',)}), + ) + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + + def save_related(self, request, form, formsets, change): + super().save_related(request, form, formsets, change) + form.instance.user_set.set(form.cleaned_data['users']) + + +admin.site.unregister(Group) diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index 34e739e5d..87b0b5513 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -13,7 +13,7 @@ from celery_config import app, app_for_vhost from leaderboards.models import SubmissionScore -from profiles.models import User, Organization +from profiles.models import CustomGroup, User, Organization from utils.data import PathWrapper from utils.storage import BundleStorage from PIL import Image @@ -55,6 +55,9 @@ class Competition(models.Model): make_programs_available = models.BooleanField(default=False) make_input_data_available = models.BooleanField(default=False) + participant_groups = models.ManyToManyField(CustomGroup, blank=True, related_name='competitions', verbose_name="group of participants", + help_text="Competition owner being able to create groups of users.") + queue = models.ForeignKey('queues.Queue', on_delete=models.SET_NULL, null=True, blank=True, related_name='competitions') diff --git a/src/apps/competitions/tasks.py b/src/apps/competitions/tasks.py index eeaa2ffae..5047de391 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -8,14 +8,10 @@ from io import BytesIO from tempfile import TemporaryDirectory, NamedTemporaryFile -# import json -# import urllib - import oyaml as yaml import requests from celery._state import app_or_default from django.conf import settings -# from django_redis import get_redis_connection from django.core.exceptions import ObjectDoesNotExist from django.core.files.base import ContentFile from django.db.models import Subquery, OuterRef, Count, Case, When, Value, F @@ -24,10 +20,9 @@ from django.utils.timezone import now from rest_framework.exceptions import ValidationError -from celery_config import app # , app_for_vhost +from celery_config import app from competitions.models import Submission, CompetitionCreationTaskStatus, SubmissionDetails, Competition, \ CompetitionDump, Phase -# from queues.models import Queue from competitions.unpackers.utils import CompetitionUnpackingException from competitions.unpackers.v1 import V15Unpacker from competitions.unpackers.v2 import V2Unpacker @@ -36,12 +31,9 @@ from datasets.models import Data from utils.data import make_url_sassy from utils.email import codalab_send_markdown_email -from channels.layers import get_channel_layer -from asgiref.sync import async_to_sync import logging -# from utils.worker_utils import WORKER_HEARTBEAT_TTL, WORKERS_REGISTRY_KEY, extract_queue_names, is_compute_worker, known_compute_queue_names logger = logging.getLogger(__name__) COMPETITION_FIELDS = [ @@ -127,6 +119,40 @@ MAX_EXECUTION_TIME_LIMIT = int(os.environ.get('MAX_EXECUTION_TIME_LIMIT', 600)) # time limit of the default queue +def _get_user_group_queues(user, competition): + all_user_groups = list( + competition.participant_groups + .filter(user__pk=user.pk) + .select_related('queue') + .distinct() + ) + + if not all_user_groups: + return [] + + groups_with_queue = [g for g in all_user_groups if g.queue_id is not None] + has_groups_without_queue = any(g.queue_id is None for g in all_user_groups) + + if not groups_with_queue: + return [] + + seen_ids = set() + queues = [] + for group in groups_with_queue: + if group.queue_id not in seen_ids: + seen_ids.add(group.queue_id) + queues.append(group.queue) + + if has_groups_without_queue: + competition_queue = competition.queue + if competition_queue is None: + queues.append(None) + elif competition_queue.id not in seen_ids: + queues.append(competition_queue) + + return queues + + def _send_to_compute_worker(submission, is_scoring): run_args = { "user_pk": submission.owner.pk, @@ -208,24 +234,24 @@ def _send_to_compute_worker(submission, is_scoring): time_padding = 60 * 20 # 20 minutes time_limit = submission.phase.execution_time_limit + time_padding - if submission.phase.competition.queue: # if the competition is running on a custom queue, not the default queue - submission.queue = submission.phase.competition.queue - run_args['execution_time_limit'] = submission.phase.execution_time_limit # use the competition time limit - submission.save(update_fields=["queue"]) + effective_queue = submission.queue or submission.phase.competition.queue + + if effective_queue: + run_args['execution_time_limit'] = submission.phase.execution_time_limit + if submission.queue != effective_queue: + submission.queue = effective_queue + submission.save(update_fields=["queue"]) + if submission.status == Submission.SUBMITTING: - # Don't want to mark an already-prepared submission as "submitted" again, so - # only do this if we were previously "SUBMITTING" submission.status = Submission.SUBMITTED submission.save(update_fields=["status"]) def _enqueue_after_commit(): - # priority of scoring tasks is higher, we don't want to wait around for - # many submissions to be scored while we're waiting for results priority = 10 if is_scoring else 0 - if submission.phase.competition.queue: + if effective_queue: celery_app = app_or_default() with celery_app.connection() as new_connection: - new_connection.virtual_host = str(submission.phase.competition.queue.vhost) + new_connection.virtual_host = str(effective_queue.vhost) task = celery_app.send_task( 'compute_worker_run', args=(run_args,), @@ -289,12 +315,7 @@ def send_child_id(submission, child_id): @app.task(queue='site-worker', soft_time_limit=60) def _run_submission(submission_pk, task_pks=None, is_scoring=False): - """This function is wrapped so that when we run tests we can run this function not - via celery""" - select_models = ( - 'phase', - 'phase__competition', - ) + select_models = ('phase', 'phase__competition') prefetch_models = ( 'details', 'phase__tasks__input_data', @@ -306,24 +327,35 @@ def _run_submission(submission_pk, task_pks=None, is_scoring=False): submission = qs.get(pk=submission_pk) if submission.is_specific_task_re_run: - # Should only be one task for a specified task submission tasks = Task.objects.filter(pk__in=task_pks) elif task_pks is None: tasks = submission.phase.tasks.all() else: tasks = submission.phase.tasks.filter(pk__in=task_pks) + tasks = list(tasks.order_by('pk')) - tasks = tasks.order_by('pk') + if submission.parent is None and not is_scoring: + group_queues = _get_user_group_queues(submission.owner, submission.phase.competition) + else: + group_queues = [] + + is_multi_task = len(tasks) > 1 + is_multi_queue = len(group_queues) > 1 + + if is_multi_task or is_multi_queue: + if is_multi_task and is_multi_queue: + combos = [(task, queue) for task in tasks for queue in group_queues] + elif is_multi_task: + override = group_queues[0] if group_queues else None + combos = [(task, override) for task in tasks] + else: + combos = [(tasks[0], queue) for queue in group_queues] - if len(tasks) > 1: - # The initial submission object becomes the parent submission and we create children for each task submission.has_children = True submission.save() - send_parent_status(submission) - for task in tasks: - # TODO: make a duplicate submission method and use it here + for task, queue in combos: child_sub = Submission( owner=submission.owner, phase=submission.phase, @@ -331,16 +363,22 @@ def _run_submission(submission_pk, task_pks=None, is_scoring=False): participant=submission.participant, parent=submission, task=task, - fact_sheet_answers=submission.fact_sheet_answers + fact_sheet_answers=submission.fact_sheet_answers, + queue=queue, ) child_sub.save(ignore_submission_limit=True) _send_to_compute_worker(child_sub, is_scoring=False) send_child_id(submission, child_sub.id) + else: - # The initial submission object is the only submission if not submission.task: submission.task = tasks[0] submission.save() + + if group_queues and submission.queue != group_queues[0]: + submission.queue = group_queues[0] + submission.save(update_fields=['queue']) + _send_to_compute_worker(submission, is_scoring) @@ -804,18 +842,3 @@ def submission_status_cleanup(): sub.parent.cancel(status=Submission.FAILED) else: sub.cancel(status=Submission.FAILED) - - -# ------------------------------------------------- -def _broadcast_worker_state(payload): - channel_layer = get_channel_layer() - if not channel_layer: - return - - async_to_sync(channel_layer.group_send)( - "compute_workers", - { - "type": "worker.health", - "worker": payload, - }, - ) diff --git a/src/apps/competitions/urls.py b/src/apps/competitions/urls.py index 705c1d77c..b4b1a90ac 100644 --- a/src/apps/competitions/urls.py +++ b/src/apps/competitions/urls.py @@ -13,4 +13,9 @@ path('upload/', views.CompetitionUpload.as_view(), name="upload"), path('public/', views.CompetitionPublic.as_view(), name="public"), path('/detailed_results//', views.CompetitionDetailedResults.as_view(), name="detailed_results"), + + # Groups + path('/groups/create/', views.competition_create_group, name='competition_create_group'), + path('/groups//update/', views.competition_update_group), + path('/groups//delete/', views.competition_delete_group), ] diff --git a/src/apps/competitions/views.py b/src/apps/competitions/views.py index 33d4b7a78..d99691764 100644 --- a/src/apps/competitions/views.py +++ b/src/apps/competitions/views.py @@ -1,8 +1,22 @@ +import json from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import Http404 +from django.http import Http404, JsonResponse, HttpResponseForbidden, HttpResponseBadRequest, HttpResponseRedirect from django.views.generic import TemplateView, DetailView +from django.core.serializers.json import DjangoJSONEncoder + +from django.contrib.auth.decorators import login_required +from django.views.decorators.http import require_POST +from django.shortcuts import get_object_or_404 + +from profiles.models import CustomGroup, User from .models import Competition, CompetitionParticipant +from django.db import transaction +from django.urls import reverse +from django.contrib import messages + +from queues.models import Queue +from django.core.exceptions import PermissionDenied class CompetitionManagement(LoginRequiredMixin, TemplateView): @@ -21,6 +35,50 @@ class CompetitionUpdateForm(LoginRequiredMixin, DetailView): template_name = 'competitions/form.html' queryset = Competition.objects.all() + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + comp = self.object + + groups_qs = comp.participant_groups.select_related('queue').prefetch_related('user_set') + + participant_user_ids = list( + CompetitionParticipant.objects.filter(competition=comp) + .values_list('user_id', flat=True) + ) + + participant_user_ids_set = set(participant_user_ids) + + ctx['available_groups_json'] = json.dumps([ + { + 'id': g.id, + 'name': _group_display_name(g.name, comp.pk), + 'queue': g.queue.name if g.queue else None, + 'queue_id': g.queue.pk if g.queue else None, + 'members': [u.username for u in g.user_set.all() if u.pk in participant_user_ids_set], + } + for g in groups_qs + ], cls=DjangoJSONEncoder) + + ctx['selected_group_ids_json'] = json.dumps( + list(comp.participant_groups.values_list('id', flat=True)), + cls=DjangoJSONEncoder + ) + + ctx['available_queues_json'] = json.dumps( + list(Queue.objects.all().values('id', 'name')), + cls=DjangoJSONEncoder + ) + + ctx['available_users_json'] = json.dumps( + list( + User.objects + .filter(pk__in=participant_user_ids, is_active=True) + .values('id', 'username', 'email') + ), + cls=DjangoJSONEncoder + ) + return ctx + def get_object(self, *args, **kwargs): competition = super().get_object(*args, **kwargs) @@ -104,3 +162,225 @@ def get_context_data(self, **kwargs): class CompetitionDetailedResults(TemplateView): template_name = 'competitions/detailed_results.html' + + +# Group feature ---------------------- +@login_required +@require_POST +def competition_create_group(request, pk): + competition = get_object_or_404(Competition, pk=pk) + + user = request.user + if not (user.is_superuser or user == competition.created_by or user in competition.collaborators.all()): + return HttpResponseForbidden("Not allowed") + + if request.content_type == 'application/json': + try: + payload = json.loads(request.body.decode()) + except Exception: + return HttpResponseBadRequest("Invalid JSON") + name = (payload.get('name') or '').strip() + queue_id = payload.get('queue_id') + user_ids = payload.get('user_ids') or [] + else: + name = (request.POST.get('name') or '').strip() + queue_id = request.POST.get('queue_id') or None + user_ids = request.POST.getlist('user_ids[]') or [] + if not user_ids and request.POST.get('user_ids'): + user_ids = [u.strip() for u in request.POST.get('user_ids').split(',') if u.strip()] + + if not name: + return HttpResponseBadRequest("Missing name") + + stored_name = _group_stored_name(competition.pk, name) + + allowed_user_ids = set( + CompetitionParticipant.objects.filter(competition=competition) + .values_list('user_id', flat=True) + ) + + try: + with transaction.atomic(): + group = CustomGroup(name=stored_name) + if queue_id: + try: + queue = Queue.objects.get(pk=queue_id) + group.queue = queue + except Queue.DoesNotExist: + group.queue = None + group.save() + + user_ids_int = [] + try: + user_ids_int = [int(u) for u in user_ids] + except Exception: + user_ids_int = [] + + if user_ids_int: + invalid = [uid for uid in user_ids_int if uid not in allowed_user_ids] + if invalid: + raise ValueError(f"Some users are not participants of this competition: {invalid}") + + users_qs = User.objects.filter(pk__in=user_ids_int) + group.user_set.set(users_qs) + + competition.participant_groups.add(group) + + members = list(group.user_set.values_list('username', flat=True)) + + group_data = { + 'id': group.id, + 'name': name, + 'queue': group.queue.name if group.queue else None, + 'queue_id': group.queue.pk if group.queue else None, + 'members': members, + } + + except ValueError as e: + return HttpResponseBadRequest(str(e)) + except Exception as e: + return HttpResponseBadRequest("Error creating group: %s" % str(e)) + + if ( + request.content_type.startswith('application/json') + or request.headers.get('x-requested-with') == 'XMLHttpRequest' + or 'application/json' in request.headers.get('accept', '') + ): + return JsonResponse({'status': 'ok', 'group': group_data}) + + messages.success(request, "Groupe créé") + return HttpResponseRedirect(reverse('competitions:edit', kwargs={'pk': competition.pk})) + + +@login_required +@require_POST +def competition_update_group(request, pk, group_id): + competition = get_object_or_404(Competition, pk=pk) + group = get_object_or_404(CustomGroup, pk=group_id) + + user = request.user + if not (user.is_superuser or user == competition.created_by or user in competition.collaborators.all()): + return HttpResponseForbidden("Not allowed") + + if not competition.participant_groups.filter(pk=group.pk).exists(): + return HttpResponseBadRequest("Group does not belong to this competition") + + if request.content_type == 'application/json': + try: + payload = json.loads(request.body.decode()) + except Exception: + return HttpResponseBadRequest("Invalid JSON") + name = (payload.get('name') or '').strip() + queue_id = payload.get('queue_id') + user_ids = payload.get('user_ids', []) or [] + else: + name = (request.POST.get('name') or '').strip() + queue_id = request.POST.get('queue_id') or None + user_ids = request.POST.getlist('user_ids[]') or [] + + if not name: + return HttpResponseBadRequest("Missing name") + + stored_name = _group_stored_name(competition.pk, name) + + allowed_user_ids = set( + CompetitionParticipant.objects.filter(competition=competition) + .values_list('user_id', flat=True) + ) + + try: + with transaction.atomic(): + group.name = stored_name + if queue_id: + group.queue = Queue.objects.filter(pk=queue_id).first() + else: + group.queue = None + group.save() + + try: + user_ids_int = [int(u) for u in user_ids] + except Exception: + user_ids_int = [] + + if user_ids_int: + invalid = [uid for uid in user_ids_int if uid not in allowed_user_ids] + if invalid: + raise ValueError(f"Some users are not participants of this competition: {invalid}") + + group.user_set.set(User.objects.filter(pk__in=user_ids_int)) + except ValueError as e: + return HttpResponseBadRequest(str(e)) + except Exception as e: + return HttpResponseBadRequest("Error updating group: %s" % str(e)) + + resp = { + 'status': 'ok', + 'group': { + 'id': group.id, + 'name': name, + 'queue': group.queue.name if group.queue else None, + 'queue_id': group.queue.pk if group.queue else None, # manquant + 'members': list(group.user_set.values_list('username', flat=True)), + } + } + + if ( + request.content_type.startswith('application/json') + or request.headers.get('x-requested-with') == 'XMLHttpRequest' + or 'application/json' in request.headers.get('accept', '') + ): + return JsonResponse(resp) + + messages.success(request, "Groupe modifié") + return HttpResponseRedirect(reverse('competitions:edit', kwargs={'pk': competition.pk})) + + +@login_required +@require_POST +def competition_delete_group(request, pk, group_id): + competition = get_object_or_404(Competition, pk=pk) + + user = request.user + if not ( + user.is_superuser + or user == competition.created_by + or user in competition.collaborators.all() + ): + raise PermissionDenied("Not allowed") + + group = get_object_or_404( + CustomGroup, + pk=group_id, + competitions=competition + ) + + try: + with transaction.atomic(): + competition.participant_groups.remove(group) + group.delete() + + except Exception as e: + return HttpResponseBadRequest(f"Error deleting group: {str(e)}") + + if ( + request.content_type.startswith("application/json") + or request.headers.get("x-requested-with") == "XMLHttpRequest" + or "application/json" in request.headers.get("accept", "") + ): + return JsonResponse({"status": "ok", "group_id": group_id}) + + messages.success(request, "Groupe supprimé") + return HttpResponseRedirect( + reverse("competitions:edit", kwargs={"pk": competition.pk}) + ) + + +def _group_stored_name(competition_pk, user_name): + return f"comp{competition_pk}__{user_name}" + + +def _group_display_name(stored_name, competition_pk): + prefix = f"comp{competition_pk}__" + if stored_name.startswith(prefix): + return stored_name[len(prefix):] + return stored_name diff --git a/src/apps/profiles/models.py b/src/apps/profiles/models.py index 042c7d0a1..dee26644d 100644 --- a/src/apps/profiles/models.py +++ b/src/apps/profiles/models.py @@ -1,6 +1,7 @@ +from queues.models import Queue import uuid -from django.contrib.auth.models import PermissionsMixin, AbstractBaseUser, UserManager +from django.contrib.auth.models import Group, PermissionsMixin, AbstractBaseUser, UserManager from django.db import models from django.utils.timezone import now from django.utils.text import slugify @@ -343,3 +344,16 @@ class Membership(models.Model): class Meta: ordering = ["date_joined"] + + +class CustomGroup(Group): + + queue = models.ForeignKey(Queue, null=True, blank=True, on_delete=models.SET_NULL, related_name='custom_groups', verbose_name="Groups_for_queue", + help_text="Queue Foreign Key on group model for multiple submissions routing.") + + class Meta: + verbose_name = "Group" + verbose_name_plural = "Groups" + + def __str__(self): + return self.name diff --git a/src/static/riot/competitions/detail/leaderboards.tag b/src/static/riot/competitions/detail/leaderboards.tag index aabc566f5..8f03c4c27 100644 --- a/src/static/riot/competitions/detail/leaderboards.tag +++ b/src/static/riot/competitions/detail/leaderboards.tag @@ -28,7 +28,7 @@ Task: - + { task.name } @@ -36,6 +36,7 @@ Participant Date ID + Groups {column.title} @@ -46,7 +47,6 @@ No submissions have been added to this leaderboard yet! - @@ -63,35 +63,44 @@ { pretty_date(submission.created_when) } {submission.id} + + { submission.queue_name } + + + - + + - {get_score(column, submission)} + + + {get_score(column, submission)} + - - - @@ -378,7 +370,6 @@ height: 100% .celled.table.selectable margin 1em 0 - table tbody .center.aligned td color #8c8c8c .index-column @@ -400,4 +391,4 @@ .text-bold font-weight: bold - + \ No newline at end of file diff --git a/src/static/riot/competitions/detail/submission_upload.tag b/src/static/riot/competitions/detail/submission_upload.tag index 289b83a33..830617c23 100644 --- a/src/static/riot/competitions/detail/submission_upload.tag +++ b/src/static/riot/competitions/detail/submission_upload.tag @@ -533,33 +533,39 @@ }) CODALAB.events.on('submissions_loaded', submissions => { - let latest_submission = _.head(_.filter(submissions, {parent: null})) + let latest_submission = _.head(_.filter(submissions, { parent: null })) + if (latest_submission && !_.includes(['Finished', 'Cancelled', 'Failed', 'Unknown'], latest_submission.status)) { self.selected_submission = latest_submission self.children = _.sortBy(latest_submission.children) + if (self.children) { self.update() $('.menu .item', self.root).tab() } - // Commented this out as it seems to hit a race condition some times with websocket - // not being connected yet. moved running after websocket open - // self.pull_logs() } }) CODALAB.events.on('submission_selected', function (selected_submission) { self.selected_submission = selected_submission - self.autoscroll_output() + self.update() + + setTimeout(function () { + self.autoscroll_output() + }, 0) }) self.autoscroll_output = function () { - if (!self.refs.autoscroll_checkbox) { + if (!self.refs.autoscroll_checkbox || !self.autoscroll_selected) { return } - if (self.autoscroll_selected) { - var output = self.refs.submission_output - output.scrollTop = output.scrollHeight + + var output = self.refs.submission_output + if (!output) { + return } + + output.scrollTop = output.scrollHeight } diff --git a/src/static/riot/competitions/editor/_participation.tag b/src/static/riot/competitions/editor/_participation.tag index 2a5463527..610ff7716 100644 --- a/src/static/riot/competitions/editor/_participation.tag +++ b/src/static/riot/competitions/editor/_participation.tag @@ -1,150 +1,555 @@ -
-
- - -
-
-
- - + +
+ + +
+
+
+ + +
+
+
+
+ + +
+
+ +
+ +

A list of emails (one per line) of users who do not require competition organizer's approval to enter this competition.

+
+ Note:
+ Only valid emails are allowed
+ Empty lines are not allowed +
+ +
+
+ +
+ +
+ +
+
+
+
+
+
-
-
-
- - +
+
+ Queue: { group.queue || "Aucune" } + Membres: { group.members && group.members.length > 0 ? group.members.length : 0 } +
+
+ + { m } + +
+
+ + +
+
+
+
+ - - + +