Teams must send their motivation letter

This commit is contained in:
Yohann D'ANELLO 2021-01-22 09:40:28 +01:00
parent 628f69e772
commit ce206998f0
Signed by: ynerant
GPG Key ID: 3A75C55819C8CF85
12 changed files with 402 additions and 224 deletions

View File

@ -59,6 +59,21 @@ class ParticipationForm(forms.ModelForm):
fields = ('tournament',) fields = ('tournament',)
class MotivationLetterForm(forms.ModelForm):
def clean_file(self):
if "file" 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): class RequestValidationForm(forms.Form):
""" """
Form to ask about validation. Form to ask about validation.

View File

@ -0,0 +1,19 @@
# Generated by Django 3.0.11 on 2021-01-22 08:15
from django.db import migrations, models
import participation.models
class Migration(migrations.Migration):
dependencies = [
('participation', '0002_auto_20210121_2206'),
]
operations = [
migrations.AddField(
model_name='team',
name='motivation_letter',
field=models.FileField(blank=True, default='', upload_to=participation.models.get_motivation_letter_filename, verbose_name='motivation letter'),
),
]

View File

@ -20,6 +20,10 @@ from tfjm.lists import get_sympa_client
from tfjm.matrix import Matrix, RoomPreset, RoomVisibility from tfjm.matrix import Matrix, RoomPreset, RoomVisibility
def get_motivation_letter_filename(instance, filename):
return f"authorization/motivation_letters/motivation_letter_{instance.trigram}"
class Team(models.Model): class Team(models.Model):
""" """
The Team model represents a real team that participates to the TFJM². The Team model represents a real team that participates to the TFJM².
@ -45,6 +49,13 @@ class Team(models.Model):
help_text=_("The access code let other people to join the team."), help_text=_("The access code let other people to join the team."),
) )
motivation_letter = models.FileField(
verbose_name=_("motivation letter"),
upload_to=get_motivation_letter_filename,
blank=True,
default="",
)
@property @property
def students(self): def students(self):
return self.participants.filter(studentregistration__isnull=False) return self.participants.filter(studentregistration__isnull=False)

View File

@ -85,6 +85,18 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</dd> </dd>
<dt class="col-sm-6 text-right">{% trans "Motivation letter:" %}</dt>
<dd class="col-sm-6">
{% if team.motivation_letter %}
<a href="{{ team.motivation_letter.url }}" data-turbolinks="false">{% trans "Download" %}</a>
{% else %}
<em>{% trans "Not uploaded yet" %}</em>
{% endif %}
{% if user.registration.team == team and not user.registration.team.participation.valid or user.registration.is_admin %}
<button class="btn btn-primary" data-toggle="modal" data-target="#uploadMotivationLetterModal">{% trans "Replace" %}</button>
{% endif %}
</dd>
</dl> </dl>
</div> </div>
<div class="card-footer text-center"> <div class="card-footer text-center">
@ -146,6 +158,11 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% trans "Upload motivation letter" as modal_title %}
{% trans "Upload" as modal_button %}
{% url "participation:upload_team_motivation_letter" pk=team.pk as modal_action %}
{% include "base_modal.html" with modal_id="uploadMotivationLetter" modal_enctype="multipart/form-data" %}
{% trans "Update team" as modal_title %} {% trans "Update team" as modal_title %}
{% trans "Update" as modal_button %} {% trans "Update" as modal_button %}
{% url "participation:update_team" pk=team.pk as modal_action %} {% url "participation:update_team" pk=team.pk as modal_action %}
@ -160,6 +177,11 @@
{% block extrajavascript %} {% block extrajavascript %}
<script> <script>
$(document).ready(function() { $(document).ready(function() {
$('button[data-target="#uploadMotivationLetterModal"]').click(function() {
let modalBody = $("#uploadMotivationLetterModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:upload_team_motivation_letter" pk=team.pk %} #form-content");
});
$('button[data-target="#updateTeamModal"]').click(function() { $('button[data-target="#updateTeamModal"]').click(function() {
let modalBody = $("#updateTeamModal div.modal-body"); let modalBody = $("#updateTeamModal div.modal-body");
if (!modalBody.html().trim()) if (!modalBody.html().trim())

View File

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% load i18n static crispy_forms_filters %}
{% block content %}
<a class="btn btn-info" href="{% url "participation:team_detail" pk=object.pk %}"><i class="fas fa-arrow-left"></i> {% trans "Back to the team detail" %}</a>
<hr>
<form method="post" enctype="multipart/form-data">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-success" type="submit">{% trans "Upload" %}</button>
</form>
{% endblock %}

View File

@ -7,8 +7,8 @@ from django.views.generic import TemplateView
from .views import CreateTeamView, JoinTeamView, MyParticipationDetailView, MyTeamDetailView, NoteUpdateView, \ from .views import CreateTeamView, JoinTeamView, MyParticipationDetailView, MyTeamDetailView, NoteUpdateView, \
ParticipationDetailView, PassageCreateView, PassageDetailView, PassageUpdateView, PoolCreateView, PoolDetailView, \ ParticipationDetailView, PassageCreateView, PassageDetailView, PassageUpdateView, PoolCreateView, PoolDetailView, \
PoolUpdateTeamsView, PoolUpdateView, SolutionUploadView, SynthesisUploadView, TeamAuthorizationsView, \ PoolUpdateTeamsView, PoolUpdateView, SolutionUploadView, SynthesisUploadView, TeamAuthorizationsView, \
TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, TournamentCreateView, TournamentDetailView, \ TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, TeamUploadMotivationLetterView, TournamentCreateView, \
TournamentListView, TournamentUpdateView TournamentDetailView, TournamentListView, TournamentUpdateView
app_name = "participation" app_name = "participation"
@ -20,6 +20,8 @@ urlpatterns = [
path("team/", MyTeamDetailView.as_view(), name="my_team_detail"), path("team/", MyTeamDetailView.as_view(), name="my_team_detail"),
path("team/<int:pk>/", TeamDetailView.as_view(), name="team_detail"), path("team/<int:pk>/", TeamDetailView.as_view(), name="team_detail"),
path("team/<int:pk>/update/", TeamUpdateView.as_view(), name="update_team"), path("team/<int:pk>/update/", TeamUpdateView.as_view(), name="update_team"),
path("team/<int:pk>/upload-motivation-letter/", TeamUploadMotivationLetterView.as_view(),
name="upload_team_motivation_letter"),
path("team/<int:pk>/authorizations/", TeamAuthorizationsView.as_view(), name="team_authorizations"), path("team/<int:pk>/authorizations/", TeamAuthorizationsView.as_view(), name="team_authorizations"),
path("team/leave/", TeamLeaveView.as_view(), name="team_leave"), path("team/leave/", TeamLeaveView.as_view(), name="team_leave"),
path("detail/", MyParticipationDetailView.as_view(), name="my_participation_detail"), path("detail/", MyParticipationDetailView.as_view(), name="my_participation_detail"),

View File

@ -2,6 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from io import BytesIO from io import BytesIO
import os
from zipfile import ZipFile from zipfile import ZipFile
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@ -9,13 +10,13 @@ from django.contrib.sites.models import Site
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.mail import send_mail from django.core.mail import send_mail
from django.db import transaction from django.db import transaction
from django.http import Http404, HttpResponse from django.http import FileResponse, Http404, HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, FormView, RedirectView, TemplateView, UpdateView from django.views.generic import CreateView, DetailView, FormView, RedirectView, TemplateView, UpdateView, View
from django.views.generic.edit import FormMixin, ProcessFormView from django.views.generic.edit import FormMixin, ProcessFormView
from django_tables2 import SingleTableView from django_tables2 import SingleTableView
from magic import Magic from magic import Magic
@ -24,8 +25,9 @@ from tfjm.lists import get_sympa_client
from tfjm.matrix import Matrix from tfjm.matrix import Matrix
from tfjm.views import AdminMixin, VolunteerMixin from tfjm.views import AdminMixin, VolunteerMixin
from .forms import JoinTeamForm, NoteForm, ParticipationForm, PassageForm, PoolForm, PoolTeamsForm, \ from .forms import JoinTeamForm, MotivationLetterForm, NoteForm, ParticipationForm, PassageForm, PoolForm, \
RequestValidationForm, SolutionForm, SynthesisForm, TeamForm, TournamentForm, ValidateParticipationForm PoolTeamsForm, RequestValidationForm, SolutionForm, SynthesisForm, TeamForm, TournamentForm, \
ValidateParticipationForm
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
from .tables import NoteTable, ParticipationTable, PassageTable, PoolTable, TeamTable, TournamentTable from .tables import NoteTable, ParticipationTable, PassageTable, PoolTable, TeamTable, TournamentTable
@ -178,7 +180,8 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
all(r.email_confirmed for r in team.students.all()) and \ all(r.email_confirmed for r in team.students.all()) and \
all(r.photo_authorization for r in team.participants.all()) and \ all(r.photo_authorization for r in team.participants.all()) and \
all(r.health_sheet for r in team.students.all() if r.under_18) and \ all(r.health_sheet for r in team.students.all() if r.under_18) and \
all(r.parental_authorization for r in team.students.all() if r.under_18) all(r.parental_authorization for r in team.students.all() if r.under_18) and \
team.motivation_letter
return context return context
@ -209,7 +212,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
return self.form_invalid(form) return self.form_invalid(form)
if not self.get_context_data()["can_validate"]: if not self.get_context_data()["can_validate"]:
form.add_error(None, _("The team can't be validated: missing email address confirmations, " form.add_error(None, _("The team can't be validated: missing email address confirmations, "
"authorizations, people or the chosen problem is not set.")) "authorizations, people, motivation letter or the tournament is not set."))
return self.form_invalid(form) return self.form_invalid(form)
self.object.participation.valid = False self.object.participation.valid = False
@ -304,6 +307,55 @@ class TeamUpdateView(LoginRequiredMixin, UpdateView):
return super().form_valid(form) return super().form_valid(form)
class TeamUploadMotivationLetterView(LoginRequiredMixin, UpdateView):
"""
A team can send its motivation letter.
"""
model = Team
form_class = MotivationLetterForm
template_name = "participation/upload_motivation_letter.html"
extra_context = dict(title=_("Upload motivation letter"))
def dispatch(self, request, *args, **kwargs):
if not self.request.user.is_authenticated or \
not self.request.user.registration.is_admin \
and self.request.user.registration.team != self.get_object():
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
@transaction.atomic
def form_valid(self, form):
old_instance = Team.objects.get(pk=self.object.pk)
if old_instance.motivation_letter:
old_instance.motivation_letter.delete()
old_instance.save()
return super().form_valid(form)
class MotivationLetterView(LoginRequiredMixin, View):
"""
Display the sent motivation letter.
"""
def get(self, request, *args, **kwargs):
filename = kwargs["filename"]
path = f"media/authorization/motivation_letters/{filename}"
if not os.path.exists(path):
raise Http404
team = Team.objects.get(motivation_letter__endswith=filename)
user = request.user
if not (user.registration in team.participants.all() or user.registration.is_admin
or user.registration.is_volunteer
and team.participation.tournament in user.registration.organized_tournaments.all()):
raise PermissionDenied
# Guess mime type of the file
mime = Magic(mime=True)
mime_type = mime.from_file(path)
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
# Replace file name
true_file_name = _("Motivation letter of {team}.{ext}").format(team=str(team), ext=ext)
return FileResponse(open(path, "rb"), content_type=mime_type, filename=true_file_name)
class TeamAuthorizationsView(LoginRequiredMixin, DetailView): class TeamAuthorizationsView(LoginRequiredMixin, DetailView):
""" """
Get as a ZIP archive all the authorizations that are sent Get as a ZIP archive all the authorizations that are sent
@ -322,10 +374,10 @@ class TeamAuthorizationsView(LoginRequiredMixin, DetailView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
team = self.get_object() team = self.get_object()
magic = Magic(mime=True)
output = BytesIO() output = BytesIO()
zf = ZipFile(output, "w") zf = ZipFile(output, "w")
for participant in team.participants.all(): for participant in team.participants.all():
magic = Magic(mime=True)
if participant.photo_authorization: if participant.photo_authorization:
mime_type = magic.from_file("media/" + participant.photo_authorization.name) mime_type = magic.from_file("media/" + participant.photo_authorization.name)
ext = mime_type.split("/")[1].replace("jpeg", "jpg") ext = mime_type.split("/")[1].replace("jpeg", "jpg")
@ -344,6 +396,12 @@ class TeamAuthorizationsView(LoginRequiredMixin, DetailView):
ext = mime_type.split("/")[1].replace("jpeg", "jpg") ext = mime_type.split("/")[1].replace("jpeg", "jpg")
zf.write("media/" + participant.health_sheet.name, zf.write("media/" + participant.health_sheet.name,
_("Health sheet of {participant}.{ext}").format(participant=str(participant), ext=ext)) _("Health sheet of {participant}.{ext}").format(participant=str(participant), ext=ext))
if team.motivation_letter:
mime_type = magic.from_file("media/" + team.motivation_letter.name)
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
zf.write("media/" + team.motivation_letter.name,
_("Motivation letter of {team}.{ext}").format(team=str(team), ext=ext))
zf.close() zf.close()
response = HttpResponse(content_type="application/zip") response = HttpResponse(content_type="application/zip")
response["Content-Disposition"] = "attachment; filename=\"{filename}\"" \ response["Content-Disposition"] = "attachment; filename=\"{filename}\"" \
@ -518,6 +576,7 @@ class SolutionUploadView(LoginRequiredMixin, FormView):
# Drop previous solution if existing # Drop previous solution if existing
for sol in sol_qs.all(): for sol in sol_qs.all():
sol.file.delete() sol.file.delete()
sol.save()
sol.delete() sol.delete()
form_sol.participation = self.participation form_sol.participation = self.participation
form_sol.final = self.participation.final form_sol.final = self.participation.final
@ -698,6 +757,7 @@ class SynthesisUploadView(LoginRequiredMixin, FormView):
# Drop previous solution if existing # Drop previous solution if existing
for syn in syn_qs.all(): for syn in syn_qs.all():
syn.file.delete() syn.file.delete()
syn.save()
syn.delete() syn.delete()
form_syn.participation = self.participation form_syn.participation = self.participation
form_syn.passage = self.passage form_syn.passage = self.passage

View File

@ -343,6 +343,7 @@ class TestRegistration(TestCase):
self.student.registration.refresh_from_db() self.student.registration.refresh_from_db()
self.student.registration.photo_authorization.delete() self.student.registration.photo_authorization.delete()
self.student.registration.save()
def test_user_detail_forbidden(self): def test_user_detail_forbidden(self):
""" """

View File

@ -329,6 +329,7 @@ class UserUploadPhotoAuthorizationView(UserMixin, UpdateView):
old_instance = StudentRegistration.objects.get(pk=self.object.pk) old_instance = StudentRegistration.objects.get(pk=self.object.pk)
if old_instance.photo_authorization: if old_instance.photo_authorization:
old_instance.photo_authorization.delete() old_instance.photo_authorization.delete()
old_instance.save()
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self): def get_success_url(self):
@ -355,6 +356,7 @@ class UserUploadHealthSheetView(UserMixin, UpdateView):
old_instance = StudentRegistration.objects.get(pk=self.object.pk) old_instance = StudentRegistration.objects.get(pk=self.object.pk)
if old_instance.health_sheet: if old_instance.health_sheet:
old_instance.health_sheet.delete() old_instance.health_sheet.delete()
old_instance.save()
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self): def get_success_url(self):
@ -381,6 +383,7 @@ class UserUploadParentalAuthorizationView(UserMixin, UpdateView):
old_instance = StudentRegistration.objects.get(pk=self.object.pk) old_instance = StudentRegistration.objects.get(pk=self.object.pk)
if old_instance.parental_authorization: if old_instance.parental_authorization:
old_instance.parental_authorization.delete() old_instance.parental_authorization.delete()
old_instance.save()
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self): def get_success_url(self):

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@ _client = None
def get_sympa_client(): def get_sympa_client():
global _client global _client
if _client is None: if _client is None:
if os.getenv("SYMPA_PASSWORD", None) is not None: # pragma: no cover if os.getenv("SYMPA_PASSWORD", None): # pragma: no cover
from sympasoap import Client from sympasoap import Client
_client = Client("https://" + os.getenv("SYMPA_URL")) _client = Client("https://" + os.getenv("SYMPA_URL"))
_client.login(os.getenv("SYMPA_EMAIL"), os.getenv("SYMPA_PASSWORD")) _client.login(os.getenv("SYMPA_EMAIL"), os.getenv("SYMPA_PASSWORD"))

View File

@ -21,6 +21,7 @@ from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from django.views.defaults import bad_request, page_not_found, permission_denied, server_error from django.views.defaults import bad_request, page_not_found, permission_denied, server_error
from django.views.generic import TemplateView from django.views.generic import TemplateView
from participation.views import MotivationLetterView
from registration.views import HealthSheetView, ParentalAuthorizationView, PhotoAuthorizationView, \ from registration.views import HealthSheetView, ParentalAuthorizationView, PhotoAuthorizationView, \
ScholarshipView, SolutionView, SynthesisView ScholarshipView, SolutionView, SynthesisView
@ -47,6 +48,8 @@ urlpatterns = [
name='parental_authorization'), name='parental_authorization'),
path('media/authorization/scholarship/<str:filename>/', ScholarshipView.as_view(), path('media/authorization/scholarship/<str:filename>/', ScholarshipView.as_view(),
name='scholarship'), name='scholarship'),
path('media/authorization/motivation_letters/<str:filename>/', MotivationLetterView.as_view(),
name='scholarship'),
path('media/solutions/<str:filename>/', SolutionView.as_view(), path('media/solutions/<str:filename>/', SolutionView.as_view(),
name='solution'), name='solution'),