mirror of
				https://gitlab.com/animath/si/plateforme.git
				synced 2025-11-04 09:02:11 +01:00 
			
		
		
		
	Upload notes from a CSV sheet
This commit is contained in:
		@@ -1,11 +1,16 @@
 | 
			
		||||
# Copyright (C) 2020 by Animath
 | 
			
		||||
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
			
		||||
 | 
			
		||||
import csv
 | 
			
		||||
import re
 | 
			
		||||
from io import StringIO
 | 
			
		||||
from typing import Iterable
 | 
			
		||||
 | 
			
		||||
from bootstrap_datepicker_plus.widgets import DatePickerInput, DateTimePickerInput
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.core.validators import FileExtensionValidator
 | 
			
		||||
from django.utils import formats
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from PyPDF3 import PdfFileReader
 | 
			
		||||
@@ -190,6 +195,69 @@ class PoolTeamsForm(forms.ModelForm):
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UploadNotesForm(forms.Form):
 | 
			
		||||
    file = forms.FileField(
 | 
			
		||||
        label=_("CSV file:"),
 | 
			
		||||
        validators=[FileExtensionValidator(allowed_extensions=["csv"])],
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        super().__init__(*args, **kwargs)
 | 
			
		||||
        self.fields['file'].widget.attrs['accept'] = 'text/csv'
 | 
			
		||||
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        cleaned_data = super().clean()
 | 
			
		||||
 | 
			
		||||
        if 'file' in cleaned_data:
 | 
			
		||||
            file = cleaned_data['file']
 | 
			
		||||
            with file:
 | 
			
		||||
                try:
 | 
			
		||||
                    csvfile = csv.reader(StringIO(file.read().decode()))
 | 
			
		||||
                except UnicodeDecodeError:
 | 
			
		||||
                    self.add_error('file', _("This file contains non-UTF-8 content. "
 | 
			
		||||
                                             "Please send your sheet as a CSV file."))
 | 
			
		||||
 | 
			
		||||
            self.process(csvfile, cleaned_data)
 | 
			
		||||
 | 
			
		||||
        return cleaned_data
 | 
			
		||||
 | 
			
		||||
    def process(self, csvfile: Iterable[str], cleaned_data: dict):
 | 
			
		||||
        parsed_notes = {}
 | 
			
		||||
        for line in csvfile:
 | 
			
		||||
            line = [s for s in line if s]
 | 
			
		||||
            if len(line) < 19:
 | 
			
		||||
                continue
 | 
			
		||||
            name = line[0]
 | 
			
		||||
            notes = line[1:19]
 | 
			
		||||
            if not all(s.isnumeric() for s in notes):
 | 
			
		||||
                continue
 | 
			
		||||
            notes = list(map(int, notes))
 | 
			
		||||
            if max(notes) < 3 or min(notes) < 0:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            max_notes = 3 * [20, 16, 9, 10, 9, 10]
 | 
			
		||||
            for n, max_n in zip(notes, max_notes):
 | 
			
		||||
                if n > max_n:
 | 
			
		||||
                    self.add_error('file',
 | 
			
		||||
                                   _("The following note is higher of the maximum expected value:")
 | 
			
		||||
                                   + str(n) + " > " + str(max_n))
 | 
			
		||||
 | 
			
		||||
            first_name, last_name = tuple(name.split(' ', 1))
 | 
			
		||||
 | 
			
		||||
            jury = User.objects.filter(first_name=first_name, last_name=last_name)
 | 
			
		||||
            if jury.count() != 1:
 | 
			
		||||
                self.form.add_error('file', _("The following user was not found:") + " " + name)
 | 
			
		||||
                continue
 | 
			
		||||
            jury = jury.get()
 | 
			
		||||
 | 
			
		||||
            vr = jury.registration
 | 
			
		||||
            parsed_notes[vr] = notes
 | 
			
		||||
 | 
			
		||||
        cleaned_data['parsed_notes'] = parsed_notes
 | 
			
		||||
 | 
			
		||||
        return cleaned_data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PassageForm(forms.ModelForm):
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        cleaned_data = super().clean()
 | 
			
		||||
 
 | 
			
		||||
@@ -654,6 +654,15 @@ class Note(models.Model):
 | 
			
		||||
        default=0,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def set_all(self, defender_writing: int, defender_oral: int, opponent_writing: int, opponent_oral: int,
 | 
			
		||||
                reporter_writing: int, reporter_oral: int):
 | 
			
		||||
        self.defender_writing = defender_writing
 | 
			
		||||
        self.defender_oral = defender_oral
 | 
			
		||||
        self.opponent_writing = opponent_writing
 | 
			
		||||
        self.opponent_oral = opponent_oral
 | 
			
		||||
        self.reporter_writing = reporter_writing
 | 
			
		||||
        self.reporter_oral = reporter_oral
 | 
			
		||||
 | 
			
		||||
    def get_absolute_url(self):
 | 
			
		||||
        return reverse_lazy("participation:passage_detail", args=(self.passage.pk,))
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -54,6 +54,7 @@
 | 
			
		||||
                <button class="btn btn-success" data-toggle="modal" data-target="#addPassageModal">{% trans "Add passage" %}</button>
 | 
			
		||||
                <button class="btn btn-primary" data-toggle="modal" data-target="#updatePoolModal">{% trans "Update" %}</button>
 | 
			
		||||
                <button class="btn btn-primary" data-toggle="modal" data-target="#updateTeamsModal">{% trans "Update teams" %}</button>
 | 
			
		||||
                <button class="btn btn-primary" data-toggle="modal" data-target="#uploadNotesModal">{% trans "Upload notes from a CSV file" %}</button>
 | 
			
		||||
            </div>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
@@ -78,6 +79,11 @@
 | 
			
		||||
    {% trans "Update" as modal_button %}
 | 
			
		||||
    {% url "participation:pool_update_teams" pk=pool.pk as modal_action %}
 | 
			
		||||
    {% include "base_modal.html" with modal_id="updateTeams" %}
 | 
			
		||||
 | 
			
		||||
    {% trans "Upload notes" as modal_title %}
 | 
			
		||||
    {% trans "Upload" as modal_button %}
 | 
			
		||||
    {% url "participation:pool_upload_notes" pk=pool.pk as modal_action %}
 | 
			
		||||
    {% include "base_modal.html" with modal_id="uploadNotes" modal_button_type="success" modal_enctype="multipart/form-data" %}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block extrajavascript %}
 | 
			
		||||
@@ -100,6 +106,12 @@
 | 
			
		||||
                if (!modalBody.html().trim())
 | 
			
		||||
                    modalBody.load("{% url "participation:passage_create" pk=pool.pk %} #form-content")
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            $('button[data-target="#uploadNotesModal"]').click(function() {
 | 
			
		||||
                let modalBody = $("#uploadNotesModal div.modal-body");
 | 
			
		||||
                if (!modalBody.html().trim())
 | 
			
		||||
                    modalBody.load("{% url "participation:pool_upload_notes" pk=pool.pk %} #form-content")
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    </script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										14
									
								
								apps/participation/templates/participation/upload_notes.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								apps/participation/templates/participation/upload_notes.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% load crispy_forms_tags %}
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <form method="post" enctype="multipart/form-data">
 | 
			
		||||
        <div id="form-content">
 | 
			
		||||
            {% csrf_token %}
 | 
			
		||||
            {{ form|crispy }}
 | 
			
		||||
        </div>
 | 
			
		||||
        <button class="btn btn-primary" type="submit">{% trans "Upload" %}</button>
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -6,9 +6,10 @@ from django.views.generic import TemplateView
 | 
			
		||||
 | 
			
		||||
from .views import CreateTeamView, JoinTeamView, MyParticipationDetailView, MyTeamDetailView, NoteUpdateView, \
 | 
			
		||||
    ParticipationDetailView, PassageCreateView, PassageDetailView, PassageUpdateView, PoolCreateView, PoolDetailView, \
 | 
			
		||||
    PoolUpdateTeamsView, PoolUpdateView, SolutionUploadView, SynthesisUploadView, TeamAuthorizationsView, \
 | 
			
		||||
    TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, TeamUploadMotivationLetterView, TournamentCreateView, \
 | 
			
		||||
    TournamentDetailView, TournamentExportCSVView, TournamentListView, TournamentUpdateView
 | 
			
		||||
    PoolUpdateTeamsView, PoolUpdateView, PoolUploadNotesView, SolutionUploadView, SynthesisUploadView,\
 | 
			
		||||
    TeamAuthorizationsView, TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, \
 | 
			
		||||
    TeamUploadMotivationLetterView, TournamentCreateView, TournamentDetailView, TournamentExportCSVView, \
 | 
			
		||||
    TournamentListView, TournamentUpdateView
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
app_name = "participation"
 | 
			
		||||
@@ -36,6 +37,7 @@ urlpatterns = [
 | 
			
		||||
    path("pools/<int:pk>/", PoolDetailView.as_view(), name="pool_detail"),
 | 
			
		||||
    path("pools/<int:pk>/update/", PoolUpdateView.as_view(), name="pool_update"),
 | 
			
		||||
    path("pools/<int:pk>/update-teams/", PoolUpdateTeamsView.as_view(), name="pool_update_teams"),
 | 
			
		||||
    path("pools/<int:pk>/upload-notes/", PoolUploadNotesView.as_view(), name="pool_upload_notes"),
 | 
			
		||||
    path("pools/passages/add/<int:pk>/", PassageCreateView.as_view(), name="passage_create"),
 | 
			
		||||
    path("pools/passages/<int:pk>/", PassageDetailView.as_view(), name="passage_detail"),
 | 
			
		||||
    path("pools/passages/<int:pk>/update/", PassageUpdateView.as_view(), name="passage_update"),
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ import os
 | 
			
		||||
from zipfile import ZipFile
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib import messages
 | 
			
		||||
from django.contrib.auth.mixins import LoginRequiredMixin
 | 
			
		||||
from django.contrib.sites.models import Site
 | 
			
		||||
from django.core.exceptions import PermissionDenied
 | 
			
		||||
@@ -18,6 +19,7 @@ from django.urls import reverse_lazy
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django.views.generic import CreateView, DetailView, FormView, RedirectView, TemplateView, UpdateView, View
 | 
			
		||||
from django.views.generic.detail import SingleObjectMixin
 | 
			
		||||
from django.views.generic.edit import FormMixin, ProcessFormView
 | 
			
		||||
from django_tables2 import SingleTableView
 | 
			
		||||
from magic import Magic
 | 
			
		||||
@@ -28,7 +30,7 @@ from tfjm.views import AdminMixin, VolunteerMixin
 | 
			
		||||
 | 
			
		||||
from .forms import JoinTeamForm, MotivationLetterForm, NoteForm, ParticipationForm, PassageForm, PoolForm, \
 | 
			
		||||
    PoolTeamsForm, RequestValidationForm, SolutionForm, SynthesisForm, TeamForm, TournamentForm, \
 | 
			
		||||
    ValidateParticipationForm
 | 
			
		||||
    UploadNotesForm, ValidateParticipationForm
 | 
			
		||||
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
 | 
			
		||||
from .tables import NoteTable, ParticipationTable, PassageTable, PoolTable, TeamTable, TournamentTable
 | 
			
		||||
 | 
			
		||||
@@ -703,6 +705,43 @@ class PoolUpdateTeamsView(VolunteerMixin, UpdateView):
 | 
			
		||||
        return self.handle_no_permission()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PoolUploadNotesView(VolunteerMixin, FormView, DetailView):
 | 
			
		||||
    model = Pool
 | 
			
		||||
    form_class = UploadNotesForm
 | 
			
		||||
    template_name = 'participation/upload_notes.html'
 | 
			
		||||
 | 
			
		||||
    def dispatch(self, request, *args, **kwargs):
 | 
			
		||||
        self.object = self.get_object()
 | 
			
		||||
 | 
			
		||||
        if request.user.registration.is_admin or request.user.registration.is_volunteer \
 | 
			
		||||
                and (self.object.tournament in request.user.registration.organized_tournaments.all()
 | 
			
		||||
                     or request.user.registration in self.object.juries.all()):
 | 
			
		||||
            return super().dispatch(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
        return self.handle_no_permission()
 | 
			
		||||
 | 
			
		||||
    @transaction.atomic
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        pool = self.get_object()
 | 
			
		||||
        parsed_notes = form.cleaned_data['parsed_notes']
 | 
			
		||||
 | 
			
		||||
        for vr, notes in parsed_notes.items():
 | 
			
		||||
            if vr not in pool.juries.all():
 | 
			
		||||
                form.add_error('file', _("The following user is not registered as a jury:") + " " + str(vr))
 | 
			
		||||
 | 
			
		||||
            for i, passage in enumerate(pool.passages.all()):
 | 
			
		||||
                note = Note.objects.get(jury=vr, passage=passage)
 | 
			
		||||
                passage_notes = notes[6 * i:6 * (i + 1)]
 | 
			
		||||
                note.set_all(*passage_notes)
 | 
			
		||||
                note.save()
 | 
			
		||||
 | 
			
		||||
        messages.success(self.request, _("Notes were successfully uploaded."))
 | 
			
		||||
        return super().form_valid(form)
 | 
			
		||||
 | 
			
		||||
    def get_success_url(self):
 | 
			
		||||
        return reverse_lazy('participation:pool_detail', args=(self.kwargs['pk'],))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PassageCreateView(VolunteerMixin, CreateView):
 | 
			
		||||
    model = Passage
 | 
			
		||||
    form_class = PassageForm
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user