diff --git a/docker-compose.yml b/docker-compose.yml index bc445f64d..957b9fab7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: command: bash -c "python manage.py collectstatic --noinput && cd /app/src && watchmedo auto-restart -p '*.py' --recursive -- python3 ./gunicorn_run.py" environment: - DATABASE_URL=postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} + - PYTHONUNBUFFERED=1 env_file: .env volumes: - .:/app:delegated diff --git a/src/apps/competitions/admin.py b/src/apps/competitions/admin.py index 769888db8..d69698b4a 100644 --- a/src/apps/competitions/admin.py +++ b/src/apps/competitions/admin.py @@ -1,10 +1,13 @@ +from django import forms from django.contrib import admin 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 +from django.contrib.auth.models import Group +from django.contrib.admin.widgets import FilteredSelectMultiple # General class used to make custom filter @@ -348,6 +351,46 @@ class PhaseExpansion(admin.ModelAdmin): ] +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.site.unregister(Group) +@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.register(models.Competition, CompetitionExpansion) admin.site.register( models.CompetitionCreationTaskStatus, CompetitionCreationTaskStatusExpansion diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index 60fc0ac0b..ea87123b3 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -14,7 +14,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 @@ -56,6 +56,15 @@ 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 f3f577139..23451393d 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -120,8 +120,9 @@ ] MAX_EXECUTION_TIME_LIMIT = int(os.environ.get('MAX_EXECUTION_TIME_LIMIT', 600)) # time limit of the default queue - def _send_to_compute_worker(submission, is_scoring): + logger.info("Site Worker ==> STARTING") + run_args = { "user_pk": submission.owner.pk, "submissions_api_url": settings.SUBMISSIONS_API_URL, @@ -205,6 +206,47 @@ def _send_to_compute_worker(submission, is_scoring): time_padding = 60 * 20 # 20 minutes time_limit = submission.phase.execution_time_limit + time_padding + try: + competition = submission.phase.competition + + user_group_ids = list(submission.owner.groups.values_list("id", flat=True)) + logger.debug("User %s groups ids: %s", submission.owner.pk, user_group_ids) + + comp_user_groups_qs = ( + competition.participant_groups + .select_related("queue") + .filter(id__in=user_group_ids) + ) + + group = comp_user_groups_qs.filter(queue__isnull=False).first() or comp_user_groups_qs.first() + + if group: + logger.info( + "Submission %s candidate group(s) in competition %s: chosen group=%s queue=%s", + submission.pk, + competition.pk, + group.pk, + getattr(group.queue, "name", None), + ) + + if group.queue: + run_args["queue"] = group.queue.name + competition.queue = group.queue + logger.info(f"Group Found = {group.name}") + + else: + logger.debug( + "Submission %s owner %s: no intersection between user's groups %s and competition %s participant_groups", + submission.pk, + submission.owner.pk, + user_group_ids, + competition.pk, + ) + + except Exception: + logger.exception("Error while resolving competition/group for submission %s", submission.pk) + + if submission.phase.competition.queue: # if the competition is running on a custom queue, not the default queue submission.queue_name = submission.phase.competition.queue.name or '' run_args['execution_time_limit'] = submission.phase.execution_time_limit # use the competition time limit diff --git a/src/apps/competitions/urls.py b/src/apps/competitions/urls.py index 705c1d77c..afaa59892 100644 --- a/src/apps/competitions/urls.py +++ b/src/apps/competitions/urls.py @@ -13,4 +13,10 @@ 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 6d2aa3c3e..ad468ca1e 100644 --- a/src/apps/competitions/views.py +++ b/src/apps/competitions/views.py @@ -3,6 +3,22 @@ from django.views.generic import TemplateView, DetailView from .models import Competition, CompetitionParticipant +from django.core.serializers.json import DjangoJSONEncoder + +from django.db.models import Q +import json +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 django.http import JsonResponse, HttpResponseForbidden, HttpResponseBadRequest, HttpResponseRedirect +from django.urls import reverse +from django.db import transaction +from django.contrib import messages + + +from profiles.models import CustomGroup, User +from queues.models import Queue + class CompetitionManagement(LoginRequiredMixin, TemplateView): @@ -21,6 +37,47 @@ 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 = CustomGroup.objects.filter( + Q(id__in=comp.participant_groups.values_list('id', flat=True)) + ).select_related('queue').prefetch_related('user_set') + + 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()], + } + 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(is_active=True) + .values('id', 'username', 'email') + ), + cls=DjangoJSONEncoder + ) + + return ctx + + def get_object(self, *args, **kwargs): competition = super().get_object(*args, **kwargs) @@ -76,7 +133,7 @@ def get_object(self, *args, **kwargs): # get participants from CompetitionParticipant where user=user and competition=competition is_participant = CompetitionParticipant.objects.filter(user=self.request.user, competition=competition).count() > 0 - # check if secret key provided is valid + # check if secret key provided is valid, valid_secret_key = self.request.GET.get('secret_key') == str(competition.secret_key) if ( @@ -104,3 +161,157 @@ def get_context_data(self, **kwargs): class CompetitionDetailedResults(LoginRequiredMixin, TemplateView): template_name = 'competitions/detailed_results.html' + + +@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") + + 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() + + if user_ids: + # normalize to ints + try: + user_ids_int = [int(u) for u in user_ids] + except Exception: + user_ids_int = [] + if user_ids_int: + 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, + 'members': members, + } + except Exception as e: + return HttpResponseBadRequest("Error creating group: %s" % str(e)) + + if request.is_ajax() or request.content_type == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + 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 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") + + 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() + + # normalize user ids and set membership + try: + user_ids_int = [int(u) for u in user_ids] + except Exception: + user_ids_int = [] + group.user_set.set(User.objects.filter(pk__in=user_ids_int)) + 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, + 'members': list(group.user_set.values_list('username', flat=True)), + } + } + + if request.is_ajax() or request.content_type == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + 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) + 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") + + try: + with transaction.atomic(): + competition.participant_groups.remove(group) + group.delete() + except Exception as e: + return HttpResponseBadRequest("Error deleting group: %s" % str(e)) + + if request.is_ajax() or request.content_type == 'application/json' or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + 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..006adcc2a 100644 --- a/src/apps/profiles/models.py +++ b/src/apps/profiles/models.py @@ -1,7 +1,9 @@ +from queues.models import Queue import uuid from django.contrib.auth.models import PermissionsMixin, AbstractBaseUser, UserManager from django.db import models +from django.contrib.auth.models import Group from django.utils.timezone import now from django.utils.text import slugify from utils.data import PathWrapper @@ -343,3 +345,23 @@ 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="Queue assignée au groupe", + help_text="Queue à utiliser pour les utilisateurs membres de ce groupe (si définie)." + ) + + class Meta: + verbose_name = "Group" + verbose_name_plural = "Groups" + + def __str__(self): + return self.name + \ No newline at end of file diff --git a/src/settings/base.py b/src/settings/base.py index 505329d73..dea0058f8 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -301,6 +301,48 @@ # ============================================================================= LOGGING_CONFIG = None +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + + 'formatters': { + 'verbose': { + 'format': '[{asctime}] {levelname} {processName} {name}:{lineno} {message}', + 'style': '{', + }, + }, + + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'stream': 'ext://sys.stdout', + 'formatter': 'verbose', + }, + }, + + 'root': { + 'handlers': ['console'], + 'level': 'DEBUG', + }, + + 'loggers': { + 'celery': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + }, + 'django': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + }, + '__main__': { + 'handlers': ['console'], + 'level': 'DEBUG', + }, + }, +} + # This makes Celery not override the default logger that is configured for the project @signals.setup_logging.connect diff --git a/src/static/riot/competitions/editor/_participation.tag b/src/static/riot/competitions/editor/_participation.tag index 2a5463527..b26055820 100644 --- a/src/static/riot/competitions/editor/_participation.tag +++ b/src/static/riot/competitions/editor/_participation.tag @@ -1,150 +1,662 @@ -
-
- - -
-
-
- - + +
+ + +
+
+
+ + +
+
+
+
+ + +
+
+ + + +
+ +

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