# Copyright (C) 2020 by Animath # SPDX-License-Identifier: GPL-3.0-or-later import csv import math from io import StringIO import re from typing import Iterable from crispy_forms.helper import FormHelper from crispy_forms.layout import Div, Field, Submit 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.translation import gettext_lazy as _ import pandas from pypdf import PdfReader from registration.models import VolunteerRegistration from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament class TeamForm(forms.ModelForm): """ Form to create a team, with the name and the trigram,... """ def clean_name(self): if "name" in self.cleaned_data: name = self.cleaned_data["name"] if Team.objects.filter(name=name).exclude(pk=self.instance.pk).exists(): raise ValidationError(_("This name is already used.")) return name def clean_trigram(self): if "trigram" in self.cleaned_data: trigram = self.cleaned_data["trigram"].upper() if not re.match("[A-Z]{3}", trigram): raise ValidationError(_("The trigram must be composed of three uppercase letters.")) if Team.objects.filter(trigram=trigram).exclude(pk=self.instance.pk).exists(): raise ValidationError(_("This trigram is already used.")) return trigram class Meta: model = Team fields = ('name', 'trigram',) class JoinTeamForm(forms.ModelForm): """ Form to join a team by the access code. """ def clean_access_code(self): access_code = self.cleaned_data["access_code"] if not Team.objects.filter(access_code=access_code).exists(): raise ValidationError(_("No team was found with this access code.")) else: team = Team.objects.get(access_code=access_code) if team.participation.valid is not None: raise ValidationError(_("The team is already validated or the validation is pending.")) return access_code def clean(self): cleaned_data = super().clean() if "access_code" in cleaned_data: team = Team.objects.get(access_code=cleaned_data["access_code"]) self.instance = team return cleaned_data class Meta: model = Team fields = ('access_code',) class ParticipationForm(forms.ModelForm): """ Form to update the problem of a team participation. """ class Meta: model = Participation fields = ('tournament', 'final',) class MotivationLetterForm(forms.ModelForm): def clean_motivation_letter(self): if "motivation_letter" in self.files: file = self.files["motivation_letter"] if file.size > 2e6: raise ValidationError(_("The uploaded file size must be under 2 Mo.")) if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]: raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file.")) return self.cleaned_data["motivation_letter"] class Meta: model = Team fields = ('motivation_letter',) class RequestValidationForm(forms.Form): """ Form to ask about validation. """ _form_type = forms.CharField( initial="RequestValidationForm", widget=forms.HiddenInput(), ) engagement = forms.BooleanField( label=_("I engage myself to participate to the whole TFJM²."), required=True, ) class ValidateParticipationForm(forms.Form): """ Form to let administrators to accept or refuse a team. """ _form_type = forms.CharField( initial="ValidateParticipationForm", widget=forms.HiddenInput(), ) message = forms.CharField( label=_("Message to address to the team:"), widget=forms.Textarea(), ) class TournamentForm(forms.ModelForm): class Meta: model = Tournament exclude = ('notes_sheet_id', ) widgets = { 'date_start': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'), 'date_end': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'), 'inscription_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%d %H:%M'), 'solution_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%d %H:%M'), 'solutions_draw': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%d %H:%M'), 'syntheses_first_phase_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%d %H:%M'), 'solutions_available_second_phase': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%d %H:%M'), 'syntheses_second_phase_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%d %H:%M'), 'organizers': forms.SelectMultiple(attrs={ 'class': 'selectpicker', 'data-live-search': 'true', 'data-live-search-normalize': 'true', 'data-width': 'fit', }) } class SolutionForm(forms.ModelForm): def clean_file(self): if "file" in self.files: file = self.files["file"] if file.size > 5e6: raise ValidationError(_("The uploaded file size must be under 5 Mo.")) if file.content_type != "application/pdf": raise ValidationError(_("The uploaded file must be a PDF file.")) pdf_reader = PdfReader(file) pages = len(pdf_reader.pages) if pages > 30: raise ValidationError(_("The PDF file must not have more than 30 pages.")) return self.cleaned_data["file"] def save(self, commit=True): """ Don't save a solution with this way. Use a view instead """ class Meta: model = Solution fields = ('problem', 'file',) class PoolForm(forms.ModelForm): class Meta: model = Pool fields = ('tournament', 'round', 'letter', 'bbb_url', 'results_available', 'jury_president', 'juries',) widgets = { "jury_president": forms.Select(attrs={ 'class': 'selectpicker', 'data-live-search': 'true', 'data-live-search-normalize': 'true', }), "juries": forms.SelectMultiple(attrs={ 'class': 'selectpicker', 'data-live-search': 'true', 'data-live-search-normalize': 'true', }), } class AddJuryForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['first_name'].required = True self.fields['last_name'].required = True self.fields['email'].required = True self.helper = FormHelper() self.helper.form_class = 'form-inline' self.helper.layout = Div( Div( Div( Field('email', autofocus="autofocus", list="juries-email"), css_class='col-md-5 px-1', ), Div( Field('first_name', list="juries-first-name"), css_class='col-md-3 px-1', ), Div( Field('last_name', list="juries-last-name"), css_class='col-md-3 px-1', ), Div( Submit('submit', _("Add")), css_class='col-md-1 py-md-4 px-1', ), css_class='row', ) ) def clean_email(self): """ Ensure that the email address is unique. """ email = self.data["email"] if User.objects.filter(email=email).exists(): self.instance = User.objects.get(email=email) if self.instance.registration.participates: self.add_error(None, _("This user already exists, but is a participant.")) return return email class Meta: model = User fields = ('first_name', 'last_name', 'email',) class UploadNotesForm(forms.Form): file = forms.FileField( label=_("Spreadsheet file:"), validators=[FileExtensionValidator(allowed_extensions=["csv", "ods"])], ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['file'].widget.attrs['accept'] = 'text/csv,application/vnd.oasis.opendocument.spreadsheet' def clean(self): cleaned_data = super().clean() if 'file' in cleaned_data: file = cleaned_data['file'] if file.name.endswith('.csv'): with file: try: data: bytes = file.read() try: content = data.decode() except UnicodeDecodeError: # This is not UTF-8, grrrr content = data.decode('latin1') table = pandas.read_csv(StringIO(content), sep=None, header=None) self.process(table, cleaned_data) except UnicodeDecodeError: self.add_error('file', _("This file contains non-UTF-8 and non-ISO-8859-1 content. " "Please send your sheet as a CSV file.")) elif file.name.endswith('.ods'): table = pandas.read_excel(file, header=None, engine='odf') self.process(table, cleaned_data) return cleaned_data def process(self, df: pandas.DataFrame, cleaned_data: dict): parsed_notes = {} valid_lengths = [2 + 6 * 3, 2 + 7 * 4, 2 + 6 * 5] # Per pool sizes pool_size = 0 line_length = 0 for line in df.values.tolist(): # Remove NaN line = [s for s in line if s == s] # Strip cases line = [str(s).strip() for s in line if str(s)] if line and line[0] == 'Problème': pool_size = len(line) - 1 if pool_size < 3 or pool_size > 5: self.add_error('file', _("Can't determine the pool size. Are you sure your file is correct?")) return line_length = valid_lengths[pool_size - 3] continue if pool_size == 0 or len(line) < line_length: continue name = line[0] if name.lower() in ["rôle", "juré⋅e", "juré?e", "moyenne", "coefficient", "sous-total", "équipe", "equipe"]: continue notes = line[2:line_length] if not all(s.isnumeric() or s[0] == '-' and s[1:].isnumeric() for s in notes): continue notes = list(map(lambda x: int(float(x)), notes)) max_notes = pool_size * ([20, 20, 10, 10, 10, 10] + ([4] if pool_size == 4 else [])) 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)) # Search by volunteer id jury = VolunteerRegistration.objects.filter(pk=int(float(line[1]))) if jury.count() != 1: raise ValidationError({'file': _("The following user was not found:") + " " + name}) jury = jury.get() parsed_notes[jury] = notes cleaned_data['parsed_notes'] = parsed_notes return cleaned_data class PassageForm(forms.ModelForm): def clean(self): cleaned_data = super().clean() if "defender" in cleaned_data and "opponent" in cleaned_data and "reporter" in cleaned_data \ and len({cleaned_data["defender"], cleaned_data["opponent"], cleaned_data["reporter"]}) < 3: self.add_error(None, _("The defender, the opponent and the reporter must be different.")) if "defender" in self.cleaned_data and "solution_number" in self.cleaned_data \ and not Solution.objects.filter(participation=cleaned_data["defender"], problem=cleaned_data["solution_number"]).exists(): self.add_error("solution_number", _("This defender did not work on this problem.")) return cleaned_data class Meta: model = Passage fields = ('position', 'solution_number', 'defender', 'opponent', 'reporter', 'observer', 'defender_penalties',) class SynthesisForm(forms.ModelForm): def clean_file(self): if "file" in self.files: file = self.files["file"] if file.size > 2e6: raise ValidationError(_("The uploaded file size must be under 2 Mo.")) if file.content_type != "application/pdf": raise ValidationError(_("The uploaded file must be a PDF file.")) pdf_reader = PdfReader(file) pages = len(pdf_reader.pages) if pages > 2: raise ValidationError(_("The PDF file must not have more than 2 pages.")) return self.cleaned_data["file"] def save(self, commit=True): """ Don't save a synthesis with this way. Use a view instead """ class Meta: model = Synthesis fields = ('file',) class NoteForm(forms.ModelForm): class Meta: model = Note fields = ('defender_writing', 'defender_oral', 'opponent_writing', 'opponent_oral', 'reporter_writing', 'reporter_oral', 'observer_oral', )