plateforme-tfjm2/apps/tournament/views.py

631 lines
24 KiB
Python

import random
import zipfile
from datetime import timedelta
from io import BytesIO
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.core.mail import send_mail
from django.db.models import Q
from django.http import HttpResponse
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, CreateView, UpdateView
from django.views.generic.edit import BaseFormView
from django_tables2.views import SingleTableView
from member.models import TFJMUser, Solution, Synthesis
from .forms import TournamentForm, OrganizerForm, SolutionForm, SynthesisForm, TeamForm, PoolForm
from .models import Tournament, Team, Pool
from .tables import TournamentTable, TeamTable, SolutionTable, SynthesisTable, PoolTable
class AdminMixin(LoginRequiredMixin):
"""
If a view extends this mixin, then the view will be only accessible to administrators.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated or not request.user.admin:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class OrgaMixin(LoginRequiredMixin):
"""
If a view extends this mixin, then the view will be only accessible to administrators or organizers.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated or not request.user.organizes:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class TeamMixin(LoginRequiredMixin):
"""
If a view extends this mixin, then the view will be only accessible to users that are registered in a team.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated or not request.user.team:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class TournamentListView(SingleTableView):
"""
Display the list of all tournaments, ordered by start date then name.
"""
model = Tournament
table_class = TournamentTable
extra_context = dict(title=_("Tournaments list"),)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
team_users = TFJMUser.objects.filter(Q(team__isnull=False) | Q(role="admin") | Q(role="organizer"))\
.order_by('-role')
valid_team_users = team_users.filter(
Q(team__validation_status="2valid") | Q(role="admin") | Q(role="organizer"))
context["team_users_emails"] = [user.email for user in team_users]
context["valid_team_users_emails"] = [user.email for user in valid_team_users]
return context
class TournamentCreateView(AdminMixin, CreateView):
"""
Create a tournament. Only accessible to admins.
"""
model = Tournament
form_class = TournamentForm
extra_context = dict(title=_("Add tournament"),)
def get_success_url(self):
return reverse_lazy('tournament:detail', args=(self.object.pk,))
class TournamentDetailView(DetailView):
"""
Display the detail of a tournament.
Accessible to all, including not authenticated users.
"""
model = Tournament
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = _("Tournament of {name}").format(name=self.object.name)
team_users = TFJMUser.objects.filter(
Q(team__tournament=self.object)
| Q(organized_tournaments=self.object)).order_by('role')
valid_team_users = team_users.filter(
Q(team__validation_status="2valid")
| Q(role="admin")
| Q(organized_tournaments=self.object))
context["team_users_emails"] = [user.email for user in team_users]
context["valid_team_users_emails"] = [user.email for user in valid_team_users]
context["teams"] = TeamTable(self.object.teams.all())
return context
class TournamentUpdateView(OrgaMixin, UpdateView):
"""
Update the data of a tournament.
Reserved to admins and organizers of the tournament.
"""
def dispatch(self, request, *args, **kwargs):
"""
Restrict the view to organizers of tournaments, then process the request.
"""
if self.request.user.role == "1volunteer" and self.request.user not in self.get_object().organizers.all():
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
model = Tournament
form_class = TournamentForm
extra_context = dict(title=_("Update tournament"),)
def get_success_url(self):
return reverse_lazy('tournament:detail', args=(self.object.pk,))
class TeamDetailView(LoginRequiredMixin, DetailView):
"""
View the detail of a team.
Restricted to this team, admins and organizers of its tournament.
"""
model = Team
def dispatch(self, request, *args, **kwargs):
"""
Protect the page and process the request.
"""
if not request.user.is_authenticated or \
(not request.user.admin and self.request.user not in self.get_object().tournament.organizers.all()
and self.get_object() != request.user.team):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""
Process POST requests. Supported requests:
- get the solutions of the team as a ZIP archive
- a user leaves its team (if the composition is not validated yet)
- the team requests the validation
- Organizers can validate or invalidate the request
- Admins can delete teams
- Admins can select teams for the final tournament
"""
team = self.get_object()
if "zip" in request.POST:
solutions = team.solutions.all()
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for solution in solutions:
zf.write(solution.file.path, str(solution) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}'\
.format(_("Solutions for team {team}.zip")
.format(team=str(team)).replace(" ", "%20"))
return resp
elif "leave" in request.POST and request.user.participates:
request.user.team = None
request.user.save()
if not team.users.exists():
team.delete()
return redirect('tournament:detail', pk=team.tournament.pk)
elif "request_validation" in request.POST and request.user.participates and team.can_validate:
team.validation_status = "1waiting"
team.save()
team.tournament.send_mail_to_organizers("request_validation", "Demande de validation TFJM²", team=team)
return redirect('tournament:team_detail', pk=team.pk)
elif "validate" in request.POST and request.user.organizes:
team.validation_status = "2valid"
team.save()
team.send_mail("validate_team", "Équipe validée TFJM²")
return redirect('tournament:team_detail', pk=team.pk)
elif "invalidate" in request.POST and request.user.organizes:
team.validation_status = "0invalid"
team.save()
team.send_mail("unvalidate_team", "Équipe non validée TFJM²")
return redirect('tournament:team_detail', pk=team.pk)
elif "delete" in request.POST and request.user.organizes:
team.delete()
return redirect('tournament:detail', pk=team.tournament.pk)
elif "select_final" in request.POST and request.user.admin and not team.selected_for_final and team.pools:
# We copy all solutions for solutions for the final
for solution in team.solutions.all():
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789"
id = ""
for i in range(64):
id += random.choice(alphabet)
with solution.file.open("rb") as source:
with open("/code/media/" + id, "wb") as dest:
for chunk in source.chunks():
dest.write(chunk)
new_sol = Solution(
file=id,
team=team,
problem=solution.problem,
final=True,
)
new_sol.save()
team.selected_for_final = True
team.save()
team.send_mail("select_for_final", "Sélection pour la finale, félicitations ! - TFJM²",
final=Tournament.get_final())
return redirect('tournament:team_detail', pk=team.pk)
return self.get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = _("Information about team")
context["ordered_solutions"] = self.object.solutions.order_by('problem').all()
context["team_users_emails"] = [user.email for user in self.object.users.all()]
return context
class TeamUpdateView(LoginRequiredMixin, UpdateView):
"""
Update the information about a team.
Team members, admins and organizers are allowed to do this.
"""
model = Team
form_class = TeamForm
extra_context = dict(title=_("Update team"),)
def dispatch(self, request, *args, **kwargs):
if not request.user.admin and self.request.user not in self.get_object().tournament.organizers.all() \
and self.get_object() != self.request.user.team:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class AddOrganizerView(AdminMixin, CreateView):
"""
Add a new organizer account. No password is created, the user should reset its password using the link
sent by mail. Only name and email are requested.
Only admins are granted to do this.
"""
model = TFJMUser
form_class = OrganizerForm
extra_context = dict(title=_("Add organizer"),)
template_name = "tournament/add_organizer.html"
def form_valid(self, form):
user = form.instance
msg = render_to_string("mail_templates/add_organizer.txt", context=dict(user=user))
msg_html = render_to_string("mail_templates/add_organizer.html", context=dict(user=user))
send_mail('Organisateur du TFJM² 2020', msg, 'contact@tfjm.org', [user.email], html_message=msg_html)
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy('index')
class SolutionsView(TeamMixin, BaseFormView, SingleTableView):
"""
Upload and view solutions for a team.
"""
model = Solution
table_class = SolutionTable
form_class = SolutionForm
template_name = "tournament/solutions_list.html"
extra_context = dict(title=_("Solutions"))
def post(self, request, *args, **kwargs):
if "zip" in request.POST:
solutions = request.user.team.solutions
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for solution in solutions:
zf.write(solution.file.path, str(solution) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}'\
.format(_("Solutions for team {team}.zip")
.format(team=str(request.user.team)).replace(" ", "%20"))
return resp
return super().post(request, *args, **kwargs)
def get_context_data(self, **kwargs):
self.object_list = self.get_queryset()
context = super().get_context_data(**kwargs)
context["now"] = timezone.now()
context["real_deadline"] = self.request.user.team.tournament.date_solutions + timedelta(minutes=30)
return context
def get_queryset(self):
qs = super().get_queryset().filter(team=self.request.user.team)
return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram',
'problem',)
def form_valid(self, form):
solution = form.instance
solution.team = self.request.user.team
solution.final = solution.team.selected_for_final
if timezone.now() > solution.tournament.date_solutions + timedelta(minutes=30):
form.add_error('file', _("You can't publish your solution anymore. Deadline: {date:%m-%d-%Y %H:%M}.")
.format(date=timezone.localtime(solution.tournament.date_solutions)))
return super().form_invalid(form)
prev_sol = Solution.objects.filter(problem=solution.problem, team=solution.team, final=solution.final)
for sol in prev_sol.all():
sol.delete()
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789"
id = ""
for i in range(64):
id += random.choice(alphabet)
solution.file.name = id
solution.save()
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("tournament:solutions")
class SolutionsOrgaListView(OrgaMixin, SingleTableView):
"""
View all solutions sent by teams for the organized tournaments. Juries can view solutions of their pools.
Organizers can download a ZIP archive for each organized tournament.
"""
model = Solution
table_class = SolutionTable
template_name = "tournament/solutions_orga_list.html"
extra_context = dict(title=_("All solutions"))
def post(self, request, *args, **kwargs):
if "tournament_zip" in request.POST:
tournament = Tournament.objects.get(pk=int(request.POST["tournament_zip"]))
solutions = tournament.solutions
if not request.user.admin and request.user not in tournament.organizers.all():
raise PermissionDenied
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for solution in solutions:
zf.write(solution.file.path, str(solution) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}'\
.format(_("Solutions for tournament {tournament}.zip")
.format(tournament=str(tournament)).replace(" ", "%20"))
return resp
return self.get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["tournaments"] = \
Tournament.objects if self.request.user.admin else self.request.user.organized_tournaments
return context
def get_queryset(self):
qs = super().get_queryset()
if not self.request.user.admin:
qs = qs.filter(Q(team__tournament__organizers=self.request.user) | Q(pools__juries=self.request.user))
return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram',
'problem',).distinct()
class SynthesesView(TeamMixin, BaseFormView, SingleTableView):
"""
Upload and view syntheses for a team.
"""
model = Synthesis
table_class = SynthesisTable
form_class = SynthesisForm
template_name = "tournament/syntheses_list.html"
extra_context = dict(title=_("Syntheses"))
def post(self, request, *args, **kwargs):
if "zip" in request.POST:
syntheses = request.user.team.syntheses
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for synthesis in syntheses:
zf.write(synthesis.file.path, str(synthesis) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}'\
.format(_("Syntheses for team {team}.zip")
.format(team=str(request.user.team)).replace(" ", "%20"))
return resp
return super().post(request, *args, **kwargs)
def get_queryset(self):
qs = super().get_queryset().filter(team=self.request.user.team)
return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram',
'round', 'source',)
def get_context_data(self, **kwargs):
self.object_list = self.get_queryset()
context = super().get_context_data(**kwargs)
context["now"] = timezone.now()
context["real_deadline_1"] = self.request.user.team.future_tournament.date_syntheses + timedelta(minutes=30)
context["real_deadline_2"] = self.request.user.team.future_tournament.date_syntheses_2 + timedelta(minutes=30)
return context
def form_valid(self, form):
synthesis = form.instance
synthesis.team = self.request.user.team
synthesis.final = synthesis.team.selected_for_final
if synthesis.round == '1' and timezone.now() > (synthesis.tournament.date_syntheses + timedelta(minutes=30)):
form.add_error('file', _("You can't publish your synthesis anymore for the first round."
" Deadline: {date:%m-%d-%Y %H:%M}.")
.format(date=timezone.localtime(synthesis.tournament.date_syntheses)))
return super().form_invalid(form)
if synthesis.round == '2' and timezone.now() > synthesis.tournament.date_syntheses_2 + timedelta(minutes=30):
form.add_error('file', _("You can't publish your synthesis anymore for the second round."
" Deadline: {date:%m-%d-%Y %H:%M}.")
.format(date=timezone.localtime(synthesis.tournament.date_syntheses_2)))
return super().form_invalid(form)
prev_syn = Synthesis.objects.filter(team=synthesis.team, round=synthesis.round, source=synthesis.source,
final=synthesis.final)
for syn in prev_syn.all():
syn.delete()
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789"
id = ""
for i in range(64):
id += random.choice(alphabet)
synthesis.file.name = id
synthesis.save()
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("tournament:syntheses")
class SynthesesOrgaListView(OrgaMixin, SingleTableView):
"""
View all syntheses sent by teams for the organized tournaments. Juries can view syntheses of their pools.
Organizers can download a ZIP archive for each organized tournament.
"""
model = Synthesis
table_class = SynthesisTable
template_name = "tournament/syntheses_orga_list.html"
extra_context = dict(title=_("All syntheses"))
def post(self, request, *args, **kwargs):
if "tournament_zip" in request.POST:
tournament = Tournament.objects.get(pk=request.POST["tournament_zip"])
syntheses = tournament.syntheses
if not request.user.admin and request.user not in tournament.organizers.all():
raise PermissionDenied
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for synthesis in syntheses:
zf.write(synthesis.file.path, str(synthesis) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}'\
.format(_("Syntheses for tournament {tournament}.zip")
.format(tournament=str(tournament)).replace(" ", "%20"))
return resp
return self.get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["tournaments"] = \
Tournament.objects if self.request.user.admin else self.request.user.organized_tournaments
return context
def get_queryset(self):
qs = super().get_queryset()
if not self.request.user.admin:
qs = qs.filter(Q(team__tournament__organizers=self.request.user) | Q(team__pools__juries=self.request.user))
return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram',
'round', 'source',)
class PoolListView(LoginRequiredMixin, SingleTableView):
"""
View the list of visible pools.
Admins see all, juries see their own pools, organizers see the pools of their tournaments.
"""
model = Pool
table_class = PoolTable
extra_context = dict(title=_("Pools"))
def get_queryset(self):
qs = super().get_queryset()
user = self.request.user
if not user.admin and user.organizes:
qs = qs.filter(Q(juries=user) | Q(teams__tournament__organizers=user))
elif user.participates:
qs = qs.filter(teams=user.team)
qs = qs.distinct().order_by('solutions__final', 'teams__tournament__date_start', 'teams__tournament__name',
'round',)
return qs
class PoolCreateView(AdminMixin, CreateView):
"""
Create a pool manually.
This page should not be used: prefer send automatically data from the drawing bot.
"""
model = Pool
form_class = PoolForm
extra_context = dict(title=_("Create pool"))
def get_success_url(self):
return reverse_lazy("tournament:pools")
class PoolDetailView(LoginRequiredMixin, DetailView):
"""
See the detail of a pool.
Teams and juries can download here defended solutions of the pool.
If this is the second round, teams can't download solutions of the other teams before the date when they
should be available.
Juries see also syntheses. They see of course solutions immediately.
This is also true for organizers and admins.
All can be downloaded as a ZIP archive.
"""
model = Pool
extra_context = dict(title=_("Pool detail"))
def get_queryset(self):
qs = super().get_queryset()
user = self.request.user
if not user.admin and user.organizes:
qs = qs.filter(Q(juries=user) | Q(teams__tournament__organizers=user))
elif user.participates:
qs = qs.filter(teams=user.team)
return qs.distinct()
def post(self, request, *args, **kwargs):
user = request.user
pool = self.get_object()
if "solutions_zip" in request.POST:
if user.participates and pool.round == 2 and pool.tournament.date_solutions_2 > timezone.now():
raise PermissionDenied
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for solution in pool.solutions.all():
zf.write(solution.file.path, str(solution) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}' \
.format(_("Solutions of a pool for the round {round} of the tournament {tournament}.zip")
.format(round=pool.round, tournament=str(pool.tournament)).replace(" ", "%20"))
return resp
elif "syntheses_zip" in request.POST and user.organizes:
if user.participates and pool.round == 2 and pool.tournament.date_solutions_2 > timezone.now():
raise PermissionDenied
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for synthesis in pool.syntheses.all():
zf.write(synthesis.file.path, str(synthesis) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}' \
.format(_("Syntheses of a pool for the round {round} of the tournament {tournament}.zip")
.format(round=pool.round, tournament=str(pool.tournament)).replace(" ", "%20"))
return resp
return self.get(request, *args, **kwargs)