From c353b5d77844c3ff19e7b399e9a0471477974c39 Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Wed, 13 May 2026 11:50:34 +0200 Subject: [PATCH 01/19] backend & frontend OK / TODO: site worker and leaderboad --- src/apps/competitions/admin.py | 48 +- src/apps/competitions/models.py | 5 +- src/apps/competitions/urls.py | 5 + src/apps/competitions/views.py | 268 +++++- src/apps/profiles/models.py | 16 +- .../competitions/editor/_participation.tag | 783 +++++++++++++++--- src/templates/competitions/form.html | 17 + 7 files changed, 1013 insertions(+), 129 deletions(-) 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/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..1b43bf074 100644 --- a/src/apps/competitions/views.py +++ b/src/apps/competitions/views.py @@ -1,8 +1,24 @@ +import json +from django.db.models import Q + 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 +37,49 @@ 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': g.name, + 'queue': g.queue.name 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 +163,210 @@ 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") + + allowed_user_ids = set( + CompetitionParticipant.objects.filter(competition=competition) + .values_list('user_id', flat=True) + ) + + try: + with transaction.atomic(): + group = CustomGroup(name=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': group.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") + + allowed_user_ids = set( + CompetitionParticipant.objects.filter(competition=competition) + .values_list('user_id', flat=True) + ) + + try: + with transaction.atomic(): + group.name = 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': group.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}) + ) 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/editor/_participation.tag b/src/static/riot/competitions/editor/_participation.tag index 2a5463527..afde7e1bb 100644 --- a/src/static/riot/competitions/editor/_participation.tag +++ b/src/static/riot/competitions/editor/_participation.tag @@ -1,150 +1,683 @@ -
-
- - -
-
-
- - + +
+ + +
+
+
+ + +
+
+
+
+ + +
+
+ + + +
+ +

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 } + +
+ +
+ + +
+ +
+
+
+ + + + +