1
0
mirror of https://gitlab.com/animath/si/plateforme-corres2math.git synced 2025-07-04 19:04:03 +02:00

Compare commits

..

65 Commits

Author SHA1 Message Date
4c9e63be3d Merge branch 'improvements' into 'master'
Questions can be seen during the third phase

See merge request animath/si/plateforme-corres2math!11
2021-01-08 12:42:44 +00:00
35a197be07 Questions can be seen during the third phase 2021-01-08 13:37:14 +01:00
01f6d74cae Merge branch 'improvements' into 'master'
Improvements

See merge request animath/si/plateforme-corres2math!10
2021-01-01 15:00:33 +00:00
d55671efd1 Use warning background for teams that didn't sent their video 2021-01-01 15:54:50 +01:00
bcdbc445c6 Fix the fix sympa lists script 2021-01-01 15:41:23 +01:00
95a69f35d2 A user can have no team 2020-12-28 11:52:34 +01:00
eec941c816 Admins can change email validation status 2020-12-27 21:19:09 +01:00
64d471936f Add copyright information 2020-12-26 21:26:26 +01:00
02c977264d Pending teams can change their composition 2020-12-26 19:43:46 +01:00
8f86ea15c8 Pending teams can change their composition 2020-12-26 19:24:37 +01:00
7628387158 Fix the fix of the sympa lists 2020-12-24 00:43:07 +01:00
a82c6b4673 Merge branch 'improvements' into 'master'
django-cas-server does not support Django 3.1 and Python 3.9 yet

See merge request animath/si/plateforme-corres2math!9
2020-12-22 22:29:51 +00:00
bf2feb9c35 django-cas-server does not support Django 3.1 and Python 3.9 yet 2020-12-22 23:24:15 +01:00
4ee5ac309d Merge branch 'improvements' into 'master'
Improvements, bug fixes

See merge request animath/si/plateforme-corres2math!8
2020-12-22 20:36:57 +00:00
969e53b712 Linting 2020-12-22 21:31:22 +01:00
84e149e8c9 Add about page 2020-12-22 21:30:34 +01:00
9e6a3eb1ca Add Gitlab logo 2020-12-22 21:06:11 +01:00
1f9f60d880 Unit test for large files uploading 2020-12-22 20:57:03 +01:00
205760f2e9 More protection on pages that require authentication 2020-12-22 20:48:25 +01:00
8f742b8e14 Increase nginx upload limit to have a better message in the Django form 2020-12-22 20:40:01 +01:00
996d00c7f0 Don't send too large files 2020-12-22 20:20:35 +01:00
8f09ca5553 URLs in mails were missing domains 2020-12-21 18:02:37 +01:00
b761670133 Rebuild search index each hour
Signed-off-by: Yohann D'ANELLO <yohann.danello@animath.fr>
2020-12-21 18:01:11 +01:00
f2ca4b7446 Update email validation page 2020-12-11 16:10:05 +01:00
3faf1294d8 Update import order 2020-12-11 16:09:51 +01:00
24e1cf3845 Merge branch 'improvements' into 'master'
Improvements

See merge request animath/si/plateforme-corres2math!7
2020-12-11 13:11:23 +00:00
dbcf15c4f3 Display user list 2020-12-11 14:06:08 +01:00
10115a0419 Administrators can update the team of the email validation status of a team. 2020-12-11 12:31:00 +01:00
833f9147ce Prevent errors when a not authenticated user tries to see a user detail page 2020-12-11 11:33:12 +01:00
91640f8fb1 Merge branch 'improvements' into 'master'
Fix signup

See merge request animath/si/plateforme-corres2math!6
2020-12-04 00:49:30 +00:00
55a727612c More explanations on how to join the Matrix server 2020-12-04 01:44:20 +01:00
de7f51d8da There was an error when an email was already used 2020-12-04 01:44:20 +01:00
c658a9767d Alert for spam folder 2020-12-04 01:44:20 +01:00
ce048a30d6 Invite people in Matrix rooms at registration 2020-11-16 19:07:18 +01:00
d104c2ff1f Merge branch 'improvements' into 'master'
Improvements

See merge request animath/si/plateforme-corres2math!5
2020-11-16 11:06:10 +00:00
982b61fe03 Merge branch 'master' into 'improvements'
# Conflicts:
#   locale/fr/LC_MESSAGES/django.po
2020-11-16 11:01:19 +00:00
ace1dbdc75 Add team table 2020-11-16 11:58:05 +01:00
731f8711ff We can only register during the first phase 2020-11-15 01:50:12 +01:00
d3e18a8fbb We can only register during the first phase 2020-11-15 01:40:20 +01:00
ece1e800ab Add #flood 2020-11-15 01:29:53 +01:00
2e0028c063 Send display name to Matrix 2020-11-15 01:14:31 +01:00
d304c3565c resolve_room_alias was broken 2020-11-15 00:55:25 +01:00
0fe1f9c348 Don't migrate in Dockerfile 2020-11-14 23:50:02 +01:00
9867e5e2a8 Linting 2020-11-14 23:44:53 +01:00
48b34e4362 Ensure that teams have a participation 2020-11-14 23:43:42 +01:00
10932d1cc5 Create more mailing lists 2020-11-14 22:18:35 +01:00
02ca1d1efe Fix matrix bot avatar 2020-11-14 21:28:01 +01:00
b0a3a22f83 Migrate in the entrypoint, not in the docker build 2020-11-04 13:51:41 +01:00
4238e49c11 Merge branch 'squash-migrations' into 'master'
Squash migrations

See merge request animath/si/plateforme-corres2math!4
2020-11-04 12:18:40 +00:00
c3c2c55aca Squash migrations 2020-11-04 13:13:34 +01:00
cba3e56fb8 Merge branch 'tests' into 'master'
Tests

See merge request animath/si/plateforme-corres2math!3
2020-11-03 20:15:23 +00:00
f146ae2dd2 Remove django cas server in CI dependencies 2020-11-03 21:10:21 +01:00
04dd02b88a Tests should not depend on Matrix-nio, that uses lxml that needs a lot of dependencies and a lot of time to build 2020-11-03 20:52:55 +01:00
1ddf39f296 Cover also settings files, keep 100% coverage by ignoring production files 2020-11-03 19:13:41 +01:00
fa368a399a More linting 2020-11-03 18:16:36 +01:00
c35fb4e996 Test questions, 100% coverage 2020-11-03 17:58:05 +01:00
b6cefc1519 Test setting the received participation and the sent participation 2020-11-03 17:21:50 +01:00
e73bb2d18b Test calendar 2020-11-03 16:43:51 +01:00
f422212aea Test calendar 2020-11-03 16:26:43 +01:00
52763cb75a Fix video table 2020-11-03 15:33:25 +01:00
aed9f457c3 Test custom CAS user authentication 2020-11-03 15:25:47 +01:00
6afa1ea40b Test to change mailing list subscription when an email address got updated 2020-11-03 15:16:08 +01:00
e98540a2a8 Test search participation objects 2020-11-03 15:12:33 +01:00
7353ecfd5f Admins don't have any participation 2020-11-03 14:46:00 +01:00
0a3fffe21e Test leave team 2020-11-03 14:43:51 +01:00
90 changed files with 1489 additions and 612 deletions

View File

@ -6,7 +6,7 @@ py38:
stage: test stage: test
image: python:3.8-alpine image: python:3.8-alpine
before_script: before_script:
- apk add --no-cache gcc libc-dev libffi-dev libmagic libxml2-dev libxslt-dev libxml2-dev libxslt-dev - apk add --no-cache libmagic
- pip install tox --no-cache-dir - pip install tox --no-cache-dir
script: tox -e py38 script: tox -e py38
@ -14,7 +14,7 @@ py39:
stage: test stage: test
image: python:3.9-alpine image: python:3.9-alpine
before_script: before_script:
- apk add --no-cache gcc libc-dev libffi-dev libmagic libxml2-dev libxslt-dev libxml2-dev libxslt-dev - apk add --no-cache gcc libmagic
- pip install tox --no-cache-dir - pip install tox --no-cache-dir
script: tox -e py39 script: tox -e py39

View File

@ -1,4 +1,4 @@
FROM python:3-alpine FROM python:3.8-alpine
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
ENV DJANGO_ALLOW_ASYNC_UNSAFE 1 ENV DJANGO_ALLOW_ASYNC_UNSAFE 1
@ -11,14 +11,12 @@ RUN apk add --no-cache bash
RUN mkdir /code RUN mkdir /code
WORKDIR /code WORKDIR /code
COPY requirements.txt /code/requirements.txt COPY requirements.txt /code/requirements.txt
RUN pip install -r requirements.txt psycopg2-binary sympasoap --no-cache-dir RUN pip install -r requirements.txt --no-cache-dir
COPY . /code/ COPY . /code/
RUN python manage.py collectstatic --noinput && \ RUN python manage.py collectstatic --noinput && \
python manage.py compilemessages && \ python manage.py compilemessages
python manage.py migrate && \
python manage.py loaddata initial
# Configure nginx # Configure nginx
RUN mkdir /run/nginx RUN mkdir /run/nginx

View File

@ -632,7 +632,7 @@ state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found. the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.> <one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author> Copyright (C) 2020 Animath
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode: notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author> <program> Copyright (C) 2020 Animath
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details. under certain conditions; type `show c' for details.

View File

@ -1 +1,4 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'api.apps.APIConfig' default_app_config = 'api.apps.APIConfig'

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import serializers from rest_framework import serializers

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from unittest.case import skipIf from unittest.case import skipIf
from django.conf import settings from django.conf import settings

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings from django.conf import settings
from django.conf.urls import include, url from django.conf.urls import include, url
from rest_framework import routers from rest_framework import routers

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter from rest_framework.filters import SearchFilter

View File

@ -1 +1,4 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'eastereggs.apps.EastereggsConfig' default_app_config = 'eastereggs.apps.EastereggsConfig'

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig from django.apps import AppConfig

View File

@ -0,0 +1,2 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path from django.urls import path
from django.views.generic import TemplateView from django.views.generic import TemplateView

View File

@ -0,0 +1,2 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@ -1,4 +1,4 @@
# Generated by Django 3.1.1 on 2020-09-21 15:33 # Generated by Django 3.1.3 on 2020-11-04 12:05
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -11,8 +11,8 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
] ]
operations = [ operations = [

View File

@ -0,0 +1,2 @@
# Copyright (C) 2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@ -1 +1,4 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'participation.apps.ParticipationConfig' default_app_config = 'participation.apps.ParticipationConfig'

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import post_save, pre_delete, pre_save from django.db.models.signals import post_save, pre_delete, pre_save

View File

@ -1,8 +1,11 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import re import re
from bootstrap_datepicker_plus import DateTimePickerInput from bootstrap_datepicker_plus import DateTimePickerInput
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -126,13 +129,17 @@ class SendParticipationForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
try:
self.fields["sent_participation"].initial = self.instance.sent_participation self.fields["sent_participation"].initial = self.instance.sent_participation
except ObjectDoesNotExist: # No sent participation
pass
self.fields["sent_participation"].queryset = Participation.objects.filter( self.fields["sent_participation"].queryset = Participation.objects.filter(
~Q(pk=self.instance.pk) & Q(problem=self.instance.problem, valid=True) ~Q(pk=self.instance.pk) & Q(problem=self.instance.problem, valid=True)
) )
def clean(self, commit=True): def clean(self, commit=True):
cleaned_data = super().clean() cleaned_data = super().clean()
if "sent_participation" in cleaned_data:
participation = cleaned_data["sent_participation"] participation = cleaned_data["sent_participation"]
participation.received_participation = self.instance participation.received_participation = self.instance
self.instance = participation self.instance = participation
@ -151,6 +158,11 @@ class QuestionForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["question"].widget.attrs.update({"placeholder": _("How did you get the idea to ...?")}) self.fields["question"].widget.attrs.update({"placeholder": _("How did you get the idea to ...?")})
def clean(self):
if Phase.current_phase().phase_number != 2:
self.add_error(None, _("You can only create or update a question during the second phase."))
return super().clean()
class Meta: class Meta:
model = Question model = Question
fields = ('question',) fields = ('question',)
@ -171,10 +183,12 @@ class PhaseForm(forms.ModelForm):
def clean(self): def clean(self):
# Ensure that dates are in a right order # Ensure that dates are in a right order
cleaned_data = super().clean() cleaned_data = super().clean()
if cleaned_data["end"] <= cleaned_data["start"]: start = cleaned_data["start"]
end = cleaned_data["end"]
if end <= start:
self.add_error("end", _("Start date must be before the end date.")) self.add_error("end", _("Start date must be before the end date."))
if Phase.objects.filter(phase_number__lt=self.instance.phase_number, end__gt=cleaned_data["start"]).exists(): if Phase.objects.filter(phase_number__lt=self.instance.phase_number, end__gt=start).exists():
self.add_error("start", _("This phase must start after the previous phases.")) self.add_error("start", _("This phase must start after the previous phases."))
if Phase.objects.filter(phase_number__gt=self.instance.phase_number, start__lt=cleaned_data["end"]).exists(): if Phase.objects.filter(phase_number__gt=self.instance.phase_number, start__lt=end).exists():
self.add_error("end", _("This phase must end after the next phases.")) self.add_error("end", _("This phase must end after the next phases."))
return cleaned_data return cleaned_data

View File

@ -0,0 +1,2 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@ -1,9 +1,11 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import os import os
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from corres2math.matrix import Matrix, RoomVisibility, UploadError from corres2math.matrix import Matrix, RoomPreset, RoomVisibility
from django.core.management import BaseCommand from django.core.management import BaseCommand
from nio import RoomPreset
from registration.models import AdminRegistration, Registration from registration.models import AdminRegistration, Registration
@ -11,17 +13,19 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
Matrix.set_display_name("Bot des Correspondances") Matrix.set_display_name("Bot des Correspondances")
if not os.getenv("SYNAPSE_PASSWORD"):
avatar_uri = "plop"
else: # pragma: no cover
if not os.path.isfile(".matrix_avatar"): if not os.path.isfile(".matrix_avatar"):
stat_file = os.stat("corres2math/static/logo.png") stat_file = os.stat("corres2math/static/logo.png")
with open("corres2math/static/logo.png", "rb") as f: with open("corres2math/static/logo.png", "rb") as f:
resp, _ = Matrix.upload(f, filename="logo.png", content_type="image/png", filesize=stat_file.st_size) resp = Matrix.upload(f, filename="logo.png", content_type="image/png",
if isinstance(resp, UploadError): filesize=stat_file.st_size)[0][0]
raise Exception(resp)
avatar_uri = resp.content_uri avatar_uri = resp.content_uri
with open(".matrix_avatar", "w") as f: with open(".matrix_avatar", "w") as f:
f.write(avatar_uri) f.write(avatar_uri)
Matrix.set_avatar(avatar_uri) Matrix.set_avatar(avatar_uri)
else:
with open(".matrix_avatar", "r") as f: with open(".matrix_avatar", "r") as f:
avatar_uri = f.read().rstrip(" \t\r\n") avatar_uri = f.read().rstrip(" \t\r\n")
@ -55,9 +59,20 @@ class Command(BaseCommand):
preset=RoomPreset.public_chat, preset=RoomPreset.public_chat,
) )
if not async_to_sync(Matrix.resolve_room_alias)("#flood:correspondances-maths.fr"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias="flood",
name="Flood",
topic="Discutez de tout et de rien !",
federate=False,
preset=RoomPreset.public_chat,
)
Matrix.set_room_avatar("#annonces:correspondances-maths.fr", avatar_uri) Matrix.set_room_avatar("#annonces:correspondances-maths.fr", avatar_uri)
Matrix.set_room_avatar("#faq:correspondances-maths.fr", avatar_uri) Matrix.set_room_avatar("#faq:correspondances-maths.fr", avatar_uri)
Matrix.set_room_avatar("#je-cherche-une-equipe:correspondances-maths.fr", avatar_uri) Matrix.set_room_avatar("#je-cherche-une-equipe:correspondances-maths.fr", avatar_uri)
Matrix.set_room_avatar("#flood:correspondances-maths.fr", avatar_uri)
Matrix.set_room_power_level_event("#annonces:correspondances-maths.fr", "events_default", 50) Matrix.set_room_power_level_event("#annonces:correspondances-maths.fr", "events_default", 50)
@ -66,9 +81,14 @@ class Command(BaseCommand):
Matrix.invite("#faq:correspondances-maths.fr", f"@{r.matrix_username}:correspondances-maths.fr") Matrix.invite("#faq:correspondances-maths.fr", f"@{r.matrix_username}:correspondances-maths.fr")
Matrix.invite("#je-cherche-une-equipe:correspondances-maths.fr", Matrix.invite("#je-cherche-une-equipe:correspondances-maths.fr",
f"@{r.matrix_username}:correspondances-maths.fr") f"@{r.matrix_username}:correspondances-maths.fr")
Matrix.invite("#flood:correspondances-maths.fr", f"@{r.matrix_username}:correspondances-maths.fr")
for admin in AdminRegistration.objects.all(): for admin in AdminRegistration.objects.all():
Matrix.set_room_power_level("#annonces:correspondances-maths.fr", Matrix.set_room_power_level("#annonces:correspondances-maths.fr",
f"@{admin.matrix_username}:correspondances-maths.fr", 95) f"@{admin.matrix_username}:correspondances-maths.fr", 95)
Matrix.set_room_power_level("#faq:correspondances-maths.fr", Matrix.set_room_power_level("#faq:correspondances-maths.fr",
f"@{admin.matrix_username}:correspondances-maths.fr", 95) f"@{admin.matrix_username}:correspondances-maths.fr", 95)
Matrix.set_room_power_level("#je-cherche-une-equipe:correspondances-maths.fr",
f"@{admin.matrix_username}:correspondances-maths.fr", 95)
Matrix.set_room_power_level("#flood:correspondances-maths.fr",
f"@{admin.matrix_username}:correspondances-maths.fr", 95)

View File

@ -0,0 +1,43 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from corres2math.lists import get_sympa_client
from django.core.management import BaseCommand
from django.db.models import Q
from participation.models import Team
from registration.models import CoachRegistration, StudentRegistration
class Command(BaseCommand):
def handle(self, *args, **options):
"""
Create Sympa mailing lists and register teams.
"""
sympa = get_sympa_client()
sympa.create_list("equipes", "Équipes des Correspondances", "hotline",
"Liste de diffusion pour contacter toutes les équipes validées des Correspondances.",
"education", raise_error=False)
sympa.create_list("equipes-non-valides", "Équipes des Correspondances", "hotline",
"Liste de diffusion pour contacter toutes les équipes non-validées des Correspondances.",
"education", raise_error=False)
for problem in range(1, 4):
sympa.create_list(f"probleme-{problem}",
f"Équipes des Correspondances participant au problème {problem}", "hotline",
f"Liste de diffusion pour contacter les équipes participant au problème {problem}"
f" des Correspondances.", "education", raise_error=False)
for team in Team.objects.filter(participation__valid=True).all():
team.create_mailing_list()
sympa.subscribe(team.email, "equipes", f"Equipe {team.name}", True)
sympa.subscribe(team.email, f"probleme-{team.participation.problem}", f"Equipe {team.name}", True)
for team in Team.objects.filter(Q(participation__valid=False) | Q(participation__valid__isnull=True)).all():
team.create_mailing_list()
sympa.subscribe(team.email, "equipes-non-valides", f"Equipe {team.name}", True)
for student in StudentRegistration.objects.filter(team__isnull=False).all():
sympa.subscribe(student.user.email, f"equipe-{student.team.trigram.lower()}", True, f"{student}")
for coach in CoachRegistration.objects.filter(team__isnull=False).all():
sympa.subscribe(coach.user.email, f"equipe-{coach.team.trigram.lower()}", True, f"{coach}")

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from corres2math.matrix import Matrix, RoomVisibility from corres2math.matrix import Matrix, RoomVisibility
from django.core.management import BaseCommand from django.core.management import BaseCommand
from participation.models import Participation from participation.models import Participation

View File

@ -1,8 +1,39 @@
# Generated by Django 3.1.1 on 2020-09-21 15:51 # Generated by Django 3.1.3 on 2020-11-04 12:05
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.utils.timezone
def register_phases(apps, _):
"""
Import the different phases of the action
"""
Phase = apps.get_model("participation", "phase")
Phase.objects.get_or_create(
phase_number=1,
description="Soumission des vidéos",
)
Phase.objects.get_or_create(
phase_number=2,
description="Phase de questions",
)
Phase.objects.get_or_create(
phase_number=3,
description="Phase d'échanges entre les équipes",
)
Phase.objects.get_or_create(
phase_number=4,
description="Synthèse de l'échange",
)
def reverse_phase_registering(apps, _): # pragma: no cover
"""
Drop all phases in order to unapply this migration.
"""
Phase = apps.get_model("participation", "phase")
Phase.objects.all().delete()
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -18,12 +49,33 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('problem', models.IntegerField(choices=[(1, 'Problem #1'), (2, 'Problem #2'), (3, 'Problem #3')], default=None, null=True, verbose_name='problem number')), ('problem', models.IntegerField(choices=[(1, 'Problem #1'), (2, 'Problem #2'), (3, 'Problem #3')], default=None, null=True, verbose_name='problem number')),
('valid', models.BooleanField(default=None, help_text='The video got the validation of the administrators.', null=True, verbose_name='valid')),
], ],
options={ options={
'verbose_name': 'participation', 'verbose_name': 'participation',
'verbose_name_plural': 'participations', 'verbose_name_plural': 'participations',
}, },
), ),
migrations.CreateModel(
name='Phase',
fields=[
('phase_number', models.AutoField(primary_key=True, serialize=False, unique=True, verbose_name='phase number')),
('description', models.CharField(max_length=255, verbose_name='phase description')),
('start', models.DateTimeField(default=django.utils.timezone.now, verbose_name='start date of the given phase')),
('end', models.DateTimeField(default=django.utils.timezone.now, verbose_name='end date of the given phase')),
],
options={
'verbose_name': 'phase',
'verbose_name_plural': 'phases',
},
),
migrations.CreateModel(
name='Question',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question', models.TextField(verbose_name='question')),
],
),
migrations.CreateModel( migrations.CreateModel(
name='Team', name='Team',
fields=[ fields=[
@ -31,6 +83,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=255, unique=True, verbose_name='name')), ('name', models.CharField(max_length=255, unique=True, verbose_name='name')),
('trigram', models.CharField(help_text='The trigram must be composed of three uppercase letters.', max_length=3, unique=True, validators=[django.core.validators.RegexValidator('[A-Z]{3}')], verbose_name='trigram')), ('trigram', models.CharField(help_text='The trigram must be composed of three uppercase letters.', max_length=3, unique=True, validators=[django.core.validators.RegexValidator('[A-Z]{3}')], verbose_name='trigram')),
('access_code', models.CharField(help_text='The access code let other people to join the team.', max_length=6, verbose_name='access code')), ('access_code', models.CharField(help_text='The access code let other people to join the team.', max_length=6, verbose_name='access code')),
('grant_animath_access_videos', models.BooleanField(default=False, help_text='Give the authorisation to publish the video on the main website to promote the action.', verbose_name='Grant Animath to publish my video')),
], ],
options={ options={
'verbose_name': 'team', 'verbose_name': 'team',
@ -43,7 +96,6 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('link', models.URLField(help_text='The full video link.', verbose_name='link')), ('link', models.URLField(help_text='The full video link.', verbose_name='link')),
('valid', models.BooleanField(default=None, help_text='The video got the validation of the administrators.', null=True, verbose_name='valid')), ('valid', models.BooleanField(default=None, help_text='The video got the validation of the administrators.', null=True, verbose_name='valid')),
('participation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='participation.participation', verbose_name='participation')),
], ],
options={ options={
'verbose_name': 'video', 'verbose_name': 'video',
@ -54,6 +106,11 @@ class Migration(migrations.Migration):
model_name='team', model_name='team',
index=models.Index(fields=['trigram'], name='participati_trigram_239255_idx'), index=models.Index(fields=['trigram'], name='participati_trigram_239255_idx'),
), ),
migrations.AddField(
model_name='question',
name='participation',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='participation.participation', verbose_name='participation'),
),
migrations.AddField( migrations.AddField(
model_name='participation', model_name='participation',
name='received_participation', name='received_participation',
@ -62,16 +119,20 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='participation', model_name='participation',
name='solution', name='solution',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='participation.video', verbose_name='solution video'), field=models.OneToOneField(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='participation_solution', to='participation.video', verbose_name='solution video'),
), ),
migrations.AddField( migrations.AddField(
model_name='participation', model_name='participation',
name='synthesis', name='synthesis',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='participation.video', verbose_name='synthesis video'), field=models.OneToOneField(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='participation_synthesis', to='participation.video', verbose_name='synthesis video'),
), ),
migrations.AddField( migrations.AddField(
model_name='participation', model_name='participation',
name='team', name='team',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='participation.team', verbose_name='team'), field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='participation.team', verbose_name='team'),
), ),
migrations.RunPython(
register_phases,
reverse_code=reverse_phase_registering,
)
] ]

View File

@ -1,18 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-22 16:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('participation', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='team',
name='grant_animath_access_videos',
field=models.BooleanField(default=False, help_text='Give the authorisation to publish the video on the main website to promote the action.', verbose_name='Grant Animath to publish my video'),
),
]

View File

@ -1,28 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-27 11:09
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('participation', '0002_team_grant_animath_access_videos'),
]
operations = [
migrations.RemoveField(
model_name='video',
name='participation',
),
migrations.AlterField(
model_name='participation',
name='solution',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='participation', to='participation.video', verbose_name='solution video'),
),
migrations.AlterField(
model_name='participation',
name='synthesis',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='participation_synthesis', to='participation.video', verbose_name='synthesis video'),
),
]

View File

@ -1,24 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-27 11:22
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('participation', '0003_auto_20200927_1309'),
]
operations = [
migrations.AlterField(
model_name='participation',
name='solution',
field=models.OneToOneField(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='participation_solution', to='participation.video', verbose_name='solution video'),
),
migrations.AlterField(
model_name='participation',
name='synthesis',
field=models.OneToOneField(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='participation_synthesis', to='participation.video', verbose_name='synthesis video'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 3.1.1 on 2020-10-11 13:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('participation', '0004_auto_20200927_1322'),
]
operations = [
migrations.AddField(
model_name='participation',
name='valid',
field=models.BooleanField(default=None, help_text='The video got the validation of the administrators.', null=True, verbose_name='valid'),
),
]

View File

@ -1,61 +0,0 @@
# Generated by Django 3.1.1 on 2020-10-20 10:39
from django.db import migrations, models
import django.utils.timezone
def register_phases(apps, schema_editor):
"""
Import the different phases of the action
"""
Phase = apps.get_model("participation", "phase")
Phase.objects.get_or_create(
phase_number=1,
description="Soumission des vidéos",
)
Phase.objects.get_or_create(
phase_number=2,
description="Phase de questions",
)
Phase.objects.get_or_create(
phase_number=3,
description="Phase d'échanges entre les équipes",
)
Phase.objects.get_or_create(
phase_number=4,
description="Synthèse de l'échange",
)
def reverse_phase_registering(apps, schema_editor):
"""
Drop all phases in order to unapply this migration.
"""
Phase = apps.get_model("participation", "phase")
Phase.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('participation', '0005_participation_valid'),
]
operations = [
migrations.CreateModel(
name='Phase',
fields=[
('phase_number', models.AutoField(primary_key=True, serialize=False, unique=True, verbose_name='phase number')),
('description', models.CharField(max_length=255, verbose_name="phase description")),
('start', models.DateTimeField(default=django.utils.timezone.now, verbose_name='start date of the given phase')),
('end', models.DateTimeField(default=django.utils.timezone.now, verbose_name='end date of the given phase')),
],
options={
'verbose_name': 'phase',
'verbose_name_plural': 'phases',
},
),
migrations.RunPython(
register_phases,
reverse_phase_registering,
),
]

View File

@ -1,22 +0,0 @@
# Generated by Django 3.0.10 on 2020-10-31 13:36
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('participation', '0006_phase'),
]
operations = [
migrations.CreateModel(
name='Question',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question', models.TextField(verbose_name='question')),
('participation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='participation.Participation', verbose_name='participation')),
],
),
]

View File

@ -0,0 +1,2 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@ -1,8 +1,11 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import os import os
import re import re
from corres2math.lists import get_sympa_client from corres2math.lists import get_sympa_client
from corres2math.matrix import Matrix from corres2math.matrix import Matrix, RoomPreset, RoomVisibility
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
@ -13,7 +16,6 @@ from django.utils import timezone
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.text import format_lazy from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from nio import RoomPreset, RoomVisibility
class Team(models.Model): class Team(models.Model):
@ -66,11 +68,22 @@ class Team(models.Model):
"education", "education",
raise_error=False, raise_error=False,
) )
if self.pk and self.participation.valid: # pragma: no cover
get_sympa_client().subscribe(self.email, "equipes", False, f"Equipe {self.name}")
get_sympa_client().subscribe(self.email, f"probleme-{self.participation.problem}", False,
f"Equipe {self.name}")
else:
get_sympa_client().subscribe(self.email, "equipes-non-valides", False)
def delete_mailing_list(self): def delete_mailing_list(self):
""" """
Drop the Sympa mailing list, if the team is empty or if the trigram changed. Drop the Sympa mailing list, if the team is empty or if the trigram changed.
""" """
if self.participation.valid: # pragma: no cover
get_sympa_client().unsubscribe(self.email, "equipes", False)
get_sympa_client().unsubscribe(self.email, f"probleme-{self.participation.problem}", False)
else:
get_sympa_client().unsubscribe(self.email, "equipes-non-valides", False)
get_sympa_client().delete_list(f"equipe-{self.trigram}") get_sympa_client().delete_list(f"equipe-{self.trigram}")
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@ -275,17 +288,15 @@ class Phase(models.Model):
) )
@classmethod @classmethod
def current_phase(cls): def current_phase(cls) -> "Phase":
""" """
Retrieve the current phase of this day Retrieve the current phase of this day
""" """
qs = Phase.objects.filter(start__lte=timezone.now(), end__gte=timezone.now()) qs = Phase.objects.filter(start__lte=timezone.now(), end__gte=timezone.now())
if qs.exists(): if qs.exists():
return qs.get() return qs.get()
qs = Phase.objects.order_by("phase_number").all() qs = Phase.objects.filter(start__lte=timezone.now()).order_by("phase_number").all()
if timezone.now() < qs.first().start: return qs.last() if qs.exists() else None
return qs.first()
return qs.last()
def __str__(self): def __str__(self):
return _("Phase {phase_number:d} starts on {start:%Y-%m-%d %H:%M} and ends on {end:%Y-%m-%d %H:%M}")\ return _("Phase {phase_number:d} starts on {start:%Y-%m-%d %H:%M} and ends on {end:%Y-%m-%d %H:%M}")\

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from haystack import indexes from haystack import indexes
from .models import Participation, Team, Video from .models import Participation, Team, Video

View File

@ -1,8 +1,11 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from corres2math.lists import get_sympa_client from corres2math.lists import get_sympa_client
from participation.models import Participation, Team, Video from participation.models import Participation, Team, Video
def create_team_participation(instance, **_): def create_team_participation(instance, created, **_):
""" """
When a team got created, create an associated team and create Video objects. When a team got created, create an associated team and create Video objects.
""" """
@ -12,6 +15,8 @@ def create_team_participation(instance, **_):
if not participation.synthesis: if not participation.synthesis:
participation.synthesis = Video.objects.create() participation.synthesis = Video.objects.create()
participation.save() participation.save()
if not created:
participation.team.create_mailing_list()
def update_mailing_list(instance: Team, **_): def update_mailing_list(instance: Team, **_):

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
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 _
import django_tables2 as tables import django_tables2 as tables
@ -39,6 +42,9 @@ class TeamTable(tables.Table):
attrs = { attrs = {
'class': 'table table condensed table-striped', 'class': 'table table condensed table-striped',
} }
row_attrs = {
'class': lambda record: '' if record.participation.solution.link else 'bg-warning',
}
model = Team model = Team
fields = ('name', 'trigram', 'problem',) fields = ('name', 'trigram', 'problem',)
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'
@ -72,19 +78,17 @@ class ParticipationTable(tables.Table):
class VideoTable(tables.Table): class VideoTable(tables.Table):
participationname = tables.LinkColumn( participation_name = tables.LinkColumn(
'participation:participation_detail', 'participation:participation_detail',
args=[tables.A("participation__pk")], args=[tables.A("participation__pk")],
verbose_name=lambda: _("name").capitalize(), verbose_name=lambda: _("name").capitalize(),
accessor=tables.A("participation__team__name"),
) )
def render_participationname(self, record):
return record.participation.team.name
class Meta: class Meta:
attrs = { attrs = {
'class': 'table table condensed table-striped', 'class': 'table table condensed table-striped',
} }
model = Team model = Team
fields = ('participationname', 'link',) fields = ('participation_name', 'link',)
template_name = 'django_tables2/bootstrap4.html' template_name = 'django_tables2/bootstrap4.html'

View File

@ -1,16 +1,39 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block extracss %} {% load i18n %}
<style>
iframe {
width: 100%;
height: 100vh;
margin: 0;
padding: 0;
}
</style>
{% endblock %}
{% block fullcontent %} {% block content %}
<iframe frameborder="0" src="https://element.correspondances-maths.fr/#/room/#faq:correspondances-maths.fr"></iframe> <div class="alert alert-warning">
{% blocktrans trimmed %}
The chat is located on the dedicated Matrix server:
{% endblocktrans %}
</div>
<div class="alert text-center">
<a class="btn btn-success" href="https://element.correspondances-maths.fr/#/room/#faq:correspondances-maths.fr" target="_blank">
<i class="fas fa-server"></i> {% trans "Access to the Matrix server" %}
</a>
</div>
<div class="alert alert-info">
<p>
{% blocktrans trimmed %}
To connect to the server, you can select "Log in", then use your credentials of this platform to connect
with the central authentication server, then you must trust the connection between the Matrix account and the
platform. Finally, you will be able to access to the chat platform.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
You will be invited in some basic rooms. You must confirm the invitations to join channels.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
If you have any trouble, don't hesitate to contact us :)
{% endblocktrans %}
</p>
</div>
{% endblock %} {% endblock %}

View File

@ -13,7 +13,9 @@ Bonjour {{ user.registration }},
L'équipe « {{ team.name }} » ({{ team.trigram }}) vient de demander à valider son équipe pour participer L'équipe « {{ team.name }} » ({{ team.trigram }}) vient de demander à valider son équipe pour participer
au {{ team.participation.get_problem_display }} des Correspondances des Jeunes Mathématicien·ne·s. au {{ team.participation.get_problem_display }} des Correspondances des Jeunes Mathématicien·ne·s.
Vous pouvez décider d'accepter ou de refuser l'équipe en vous rendant sur la page de l'équipe : Vous pouvez décider d'accepter ou de refuser l'équipe en vous rendant sur la page de l'équipe :
<a href="{% url "participation:team_detail" pk=team.pk %}">{% url "participation:team_detail" pk=team.pk %}</a> <a href="https://{{ domain }}{% url "participation:team_detail" pk=team.pk %}">
https://{{ domain }}{% url "participation:team_detail" pk=team.pk %}
</a>
</p> </p>
<p> <p>

View File

@ -3,7 +3,7 @@ Bonjour {{ user.registration }},
L'équipe « {{ team.name }} » ({{ team.trigram }}) vient de demander à valider son équipe pour participer L'équipe « {{ team.name }} » ({{ team.trigram }}) vient de demander à valider son équipe pour participer
au {{ team.participation.get_problem_display }} des Correspondances des Jeunes Mathématicien·ne·s. au {{ team.participation.get_problem_display }} des Correspondances des Jeunes Mathématicien·ne·s.
Vous pouvez décider d'accepter ou de refuser l'équipe en vous rendant sur la page de l'équipe : Vous pouvez décider d'accepter ou de refuser l'équipe en vous rendant sur la page de l'équipe :
{% url "participation:team_detail" pk=team.pk %} https://{{ domain }}{% url "participation:team_detail" pk=team.pk %}
Cordialement, Cordialement,

View File

@ -194,13 +194,14 @@
{% trans "This video platform is not supported yet." as unsupported_platform %} {% trans "This video platform is not supported yet." as unsupported_platform %}
{% include "base_modal.html" with modal_id="displayOtherSolution" modal_action="" modal_button="" modal_additional_class="modal-lg" modal_content=participation.received_participation.solution.as_iframe|default:unsupported_platform %} {% include "base_modal.html" with modal_id="displayOtherSolution" modal_action="" modal_button="" modal_additional_class="modal-lg" modal_content=participation.received_participation.solution.as_iframe|default:unsupported_platform %}
{% endif %} {% endif %}
{% endif %}
{% if user.registration.participates and current_phase.phase_number == 2 %} {% if current_phase.phase_number == 2 %}
{% trans "Add question" as modal_title %} {% trans "Add question" as modal_title %}
{% trans "Add" as modal_button %} {% trans "Add" as modal_button %}
{% url "participation:add_question" pk=participation.pk as modal_action %} {% url "participation:add_question" pk=participation.pk as modal_action %}
{% include "base_modal.html" with modal_id="addQuestion" modal_button_type="success" %} {% include "base_modal.html" with modal_id="addQuestion" modal_button_type="success" %}
{% endif %}
{% for question in participation.questions.all %} {% for question in participation.questions.all %}
{% with number_str=forloop.counter|stringformat:"d"%} {% with number_str=forloop.counter|stringformat:"d"%}
{% with modal_id="updateQuestion"|add:number_str %} {% with modal_id="updateQuestion"|add:number_str %}
@ -260,12 +261,13 @@
}); });
{% endif %} {% endif %}
{% if user.registration.participates and current_phase.phase_number == 2 %} {% if current_phase.phase_number == 2 %}
$('button[data-target="#addQuestionModal"]').click(function() { $('button[data-target="#addQuestionModal"]').click(function() {
let modalBody = $("#addQuestionModal div.modal-body"); let modalBody = $("#addQuestionModal div.modal-body");
if (!modalBody.html().trim()) if (!modalBody.html().trim())
modalBody.load("{% url "participation:add_question" pk=participation.pk %} #form-content"); modalBody.load("{% url "participation:add_question" pk=participation.pk %} #form-content");
}); });
{% endif %}
{% for question in participation.questions.all %} {% for question in participation.questions.all %}
$('button[data-target="#updateQuestion{{ forloop.counter }}Modal"]').click(function() { $('button[data-target="#updateQuestion{{ forloop.counter }}Modal"]').click(function() {
@ -274,13 +276,14 @@
modalBody.load("{% url "participation:update_question" pk=question.pk %} #form-content"); modalBody.load("{% url "participation:update_question" pk=question.pk %} #form-content");
}); });
{% if current_phase.phase_number == 2 %}
$('button[data-target="#deleteQuestion{{ forloop.counter }}Modal"]').click(function() { $('button[data-target="#deleteQuestion{{ forloop.counter }}Modal"]').click(function() {
let modalBody = $("#deleteQuestion{{ forloop.counter }}Modal div.modal-body"); let modalBody = $("#deleteQuestion{{ forloop.counter }}Modal div.modal-body");
if (!modalBody.html().trim()) if (!modalBody.html().trim())
modalBody.load("{% url "participation:delete_question" pk=question.pk %} #form-content"); modalBody.load("{% url "participation:delete_question" pk=question.pk %} #form-content");
}); });
{% endfor %}
{% endif %} {% endif %}
{% endfor %}
$('button[data-target="#uploadSolutionModal"]').click(function() { $('button[data-target="#uploadSolutionModal"]').click(function() {
let modalBody = $("#uploadSolutionModal div.modal-body"); let modalBody = $("#uploadSolutionModal div.modal-body");

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% load django_tables2 i18n %}
{% block contenttitle %}
<h1>{% trans "All teams" %}</h1>
{% endblock %}
{% block content %}
<div id="form-content">
{% render_table table %}
</div>
{% endblock %}

View File

@ -0,0 +1,2 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@ -0,0 +1,15 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django import template
from ..models import Phase
def current_phase(nb):
phase = Phase.current_phase()
return phase is not None and phase.phase_number == nb
register = template.Library()
register.filter("current_phase", current_phase)

View File

@ -1,11 +1,18 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.management import call_command
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from registration.models import StudentRegistration from django.utils import timezone
from registration.models import CoachRegistration, StudentRegistration
from .models import Participation, Question, Team from .models import Participation, Phase, Question, Team
class TestStudentParticipation(TestCase): class TestStudentParticipation(TestCase):
@ -59,6 +66,14 @@ class TestStudentParticipation(TestCase):
grant_animath_access_videos=True, grant_animath_access_videos=True,
) )
self.coach = User.objects.create(
first_name="Coach",
last_name="Coach",
email="coach@example.com",
password="coach",
)
CoachRegistration.objects.create(user=self.coach)
def test_admin_pages(self): def test_admin_pages(self):
""" """
Load Django-admin pages. Load Django-admin pages.
@ -173,6 +188,13 @@ class TestStudentParticipation(TestCase):
)) ))
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_team_list(self):
"""
Test to display the list of teams.
"""
response = self.client.get(reverse("participation:team_list"))
self.assertTrue(response.status_code, 200)
def test_no_myteam_redirect_noteam(self): def test_no_myteam_redirect_noteam(self):
""" """
Test redirection. Test redirection.
@ -355,6 +377,9 @@ class TestStudentParticipation(TestCase):
self.user.registration.team = self.team self.user.registration.team = self.team
self.user.registration.save() self.user.registration.save()
self.coach.registration.team = self.team
self.coach.registration.save()
response = self.client.get(reverse("participation:update_team", args=(self.team.pk,))) response = self.client.get(reverse("participation:update_team", args=(self.team.pk,)))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -376,6 +401,39 @@ class TestStudentParticipation(TestCase):
self.assertRedirects(response, reverse("participation:team_detail", args=(self.team.pk,)), 302, 200) self.assertRedirects(response, reverse("participation:team_detail", args=(self.team.pk,)), 302, 200)
self.assertTrue(Team.objects.filter(trigram="BBB", participation__problem=3).exists()) self.assertTrue(Team.objects.filter(trigram="BBB", participation__problem=3).exists())
def test_leave_team(self):
"""
A user is in a team, and leaves it.
"""
# User is not in a team
response = self.client.post(reverse("participation:team_leave"))
self.assertEqual(response.status_code, 403)
self.user.registration.team = self.team
self.user.registration.save()
# Team is valid
self.team.participation.valid = True
self.team.participation.save()
response = self.client.post(reverse("participation:team_leave"))
self.assertEqual(response.status_code, 403)
# Unauthenticated users are redirected to login page
self.client.logout()
response = self.client.get(reverse("participation:team_leave"))
self.assertRedirects(response, reverse("login") + "?next=" + reverse("participation:team_leave"), 302, 200)
self.client.force_login(self.user)
self.team.participation.valid = None
self.team.participation.save()
response = self.client.post(reverse("participation:team_leave"))
self.assertRedirects(response, reverse("index"), 302, 200)
self.user.registration.refresh_from_db()
self.assertIsNone(self.user.registration.team)
self.assertFalse(Team.objects.filter(pk=self.team.pk).exists())
def test_no_myparticipation_redirect_nomyparticipation(self): def test_no_myparticipation_redirect_nomyparticipation(self):
""" """
Ensure a permission denied when we search my team participation when we are in no team. Ensure a permission denied when we search my team participation when we are in no team.
@ -438,9 +496,177 @@ class TestStudentParticipation(TestCase):
response = self.client.get(reverse("participation:participation_detail", args=(self.team.participation.pk,))) response = self.client.get(reverse("participation:participation_detail", args=(self.team.participation.pk,)))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Set the second phase
for i in range(1, 5):
Phase.objects.filter(phase_number=i).update(start=timezone.now() + timedelta(days=i - 2),
end=timezone.now() + timedelta(days=i - 1))
self.assertEqual(Phase.current_phase().phase_number, 2)
# Can't update the link during the second phase
response = self.client.post(reverse("participation:upload_video", args=(self.team.participation.solution.pk,)),
data=dict(link="https://youtube.com/watch?v=73nsrixx7eI"))
self.assertEqual(response.status_code, 200)
def test_questions(self):
"""
Ensure that creating/updating/deleting a question is working.
"""
self.user.registration.team = self.team
self.user.registration.save()
self.team.participation.valid = True
self.team.participation.save()
response = self.client.get(reverse("participation:add_question", args=(self.team.participation.pk,)))
self.assertEqual(response.status_code, 200)
# We are not in second phase
response = self.client.post(reverse("participation:add_question", args=(self.team.participation.pk,)),
data=dict(question="I got censored!"))
self.assertEqual(response.status_code, 200)
# Set the second phase
for i in range(1, 5):
Phase.objects.filter(phase_number=i).update(start=timezone.now() + timedelta(days=i - 2),
end=timezone.now() + timedelta(days=i - 1))
self.assertEqual(Phase.current_phase().phase_number, 2)
# Create a question
response = self.client.post(reverse("participation:add_question", args=(self.team.participation.pk,)),
data=dict(question="I asked a question!"))
self.assertRedirects(response, reverse("participation:participation_detail",
args=(self.team.participation.pk,)), 302, 200)
qs = Question.objects.filter(participation=self.team.participation, question="I asked a question!")
self.assertTrue(qs.exists())
question = qs.get()
# Update a question
response = self.client.get(reverse("participation:update_question", args=(question.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:update_question", args=(question.pk,)), data=dict(
question="The question changed!",
))
self.assertRedirects(response, reverse("participation:participation_detail",
args=(self.team.participation.pk,)), 302, 200)
question.refresh_from_db()
self.assertEqual(question.question, "The question changed!")
# Delete the question
response = self.client.get(reverse("participation:delete_question", args=(question.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:delete_question", args=(question.pk,)))
self.assertRedirects(response, reverse("participation:participation_detail",
args=(self.team.participation.pk,)), 302, 200)
self.assertFalse(Question.objects.filter(pk=question.pk).exists())
# Non-authenticated users are redirected to login page
self.client.logout()
response = self.client.get(reverse("participation:add_question", args=(self.team.participation.pk,)))
self.assertRedirects(response, reverse("login") + "?next=" +
reverse("participation:add_question", args=(self.team.participation.pk,)), 302, 200)
response = self.client.get(reverse("participation:update_question", args=(self.question.pk,)))
self.assertRedirects(response, reverse("login") + "?next=" +
reverse("participation:update_question", args=(self.question.pk,)), 302, 200)
response = self.client.get(reverse("participation:delete_question", args=(self.question.pk,)))
self.assertRedirects(response, reverse("login") + "?next=" +
reverse("participation:delete_question", args=(self.question.pk,)), 302, 200)
def test_current_phase(self):
"""
Ensure that the current phase is the good one.
"""
# We are before the beginning
for i in range(1, 5):
Phase.objects.filter(phase_number=i).update(start=timezone.now() + timedelta(days=2 * i),
end=timezone.now() + timedelta(days=2 * i + 1))
self.assertEqual(Phase.current_phase(), None)
# We are after the end
for i in range(1, 5):
Phase.objects.filter(phase_number=i).update(start=timezone.now() - timedelta(days=2 * i),
end=timezone.now() - timedelta(days=2 * i + 1))
self.assertEqual(Phase.current_phase().phase_number, Phase.objects.count())
# First phase
for i in range(1, 5):
Phase.objects.filter(phase_number=i).update(start=timezone.now() + timedelta(days=i - 1),
end=timezone.now() + timedelta(days=i))
self.assertEqual(Phase.current_phase().phase_number, 1)
# Second phase
for i in range(1, 5):
Phase.objects.filter(phase_number=i).update(start=timezone.now() + timedelta(days=i - 2),
end=timezone.now() + timedelta(days=i - 1))
self.assertEqual(Phase.current_phase().phase_number, 2)
# Third phase
for i in range(1, 5):
Phase.objects.filter(phase_number=i).update(start=timezone.now() + timedelta(days=i - 3),
end=timezone.now() + timedelta(days=i - 2))
self.assertEqual(Phase.current_phase().phase_number, 3)
# Fourth phase
for i in range(1, 5):
Phase.objects.filter(phase_number=i).update(start=timezone.now() + timedelta(days=i - 4),
end=timezone.now() + timedelta(days=i - 3))
self.assertEqual(Phase.current_phase().phase_number, 4)
response = self.client.get(reverse("participation:calendar"))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("participation:update_phase", args=(4,)))
self.assertEqual(response.status_code, 403)
response = self.client.post(reverse("participation:update_phase", args=(4,)), data=dict(
start=timezone.now(),
end=timezone.now() + timedelta(days=3),
))
self.assertEqual(response.status_code, 403)
self.client.force_login(self.superuser)
response = self.client.get(reverse("participation:update_phase", args=(4,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:update_phase", args=(4,)), data=dict(
start=timezone.now(),
end=timezone.now() + timedelta(days=3),
))
self.assertRedirects(response, reverse("participation:calendar"), 302, 200)
fourth_phase = Phase.objects.get(phase_number=4)
self.assertEqual((fourth_phase.end - fourth_phase.start).days, 3)
# First phase must be before the other phases
response = self.client.post(reverse("participation:update_phase", args=(1,)), data=dict(
start=timezone.now() + timedelta(days=8),
end=timezone.now() + timedelta(days=9),
))
self.assertEqual(response.status_code, 200)
# Fourth phase must be after the other phases
response = self.client.post(reverse("participation:update_phase", args=(4,)), data=dict(
start=timezone.now() - timedelta(days=9),
end=timezone.now() - timedelta(days=8),
))
self.assertEqual(response.status_code, 200)
# End must be after start
response = self.client.post(reverse("participation:update_phase", args=(4,)), data=dict(
start=timezone.now() + timedelta(days=3),
end=timezone.now(),
))
self.assertEqual(response.status_code, 200)
# Unauthenticated user can't update the calendar
self.client.logout()
response = self.client.get(reverse("participation:calendar"))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("participation:update_phase", args=(2,)))
self.assertRedirects(response, reverse("login") + "?next=" +
reverse("participation:update_phase", args=(2,)), 302, 200)
def test_forbidden_access(self): def test_forbidden_access(self):
""" """
Load personnal pages and ensure that these are protected. Load personal pages and ensure that these are protected.
""" """
self.user.registration.team = self.team self.user.registration.team = self.team
self.user.registration.save() self.user.registration.save()
@ -468,8 +694,23 @@ class TestStudentParticipation(TestCase):
resp = self.client.get(reverse("participation:delete_question", args=(question.pk,))) resp = self.client.get(reverse("participation:delete_question", args=(question.pk,)))
self.assertEqual(resp.status_code, 403) self.assertEqual(resp.status_code, 403)
def test_cover_matrix(self):
"""
Load matrix scripts, to cover them and ensure that they can run.
"""
self.user.registration.team = self.team
self.user.registration.save()
self.second_user.registration.team = self.second_team
self.second_user.registration.save()
self.team.participation.valid = True
self.team.participation.received_participation = self.second_team.participation
self.team.participation.save()
class TestAdminForbidden(TestCase): call_command('fix_matrix_channels')
call_command('setup_third_phase')
class TestAdmin(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.user = User.objects.create_superuser( self.user = User.objects.create_superuser(
username="admin@example.com", username="admin@example.com",
@ -478,6 +719,96 @@ class TestAdminForbidden(TestCase):
) )
self.client.force_login(self.user) self.client.force_login(self.user)
self.team1 = Team.objects.create(
name="Toto",
trigram="TOT",
)
self.team1.participation.valid = True
self.team1.participation.problem = 1
self.team1.participation.save()
self.team2 = Team.objects.create(
name="Bliblu",
trigram="BIU",
)
self.team2.participation.valid = True
self.team2.participation.problem = 1
self.team2.participation.save()
self.team3 = Team.objects.create(
name="Zouplop",
trigram="ZPL",
)
self.team3.participation.valid = True
self.team3.participation.problem = 1
self.team3.participation.save()
self.other_team = Team.objects.create(
name="I am different",
trigram="IAD",
)
self.other_team.participation.valid = True
self.other_team.participation.problem = 2
self.other_team.participation.save()
def test_research(self):
"""
Try to search some things.
"""
call_command("rebuild_index", "--noinput", "--verbosity", 0)
response = self.client.get(reverse("haystack_search") + "?q=" + self.team1.name)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["object_list"])
response = self.client.get(reverse("haystack_search") + "?q=" + self.team2.trigram)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["object_list"])
def test_set_received_video(self):
"""
Try to define the received video of a participation.
"""
response = self.client.get(reverse("participation:participation_receive_participation",
args=(self.team1.participation.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:participation_receive_participation",
args=(self.team1.participation.pk,)),
data=dict(received_participation=self.team2.participation.pk))
self.assertRedirects(response, reverse("participation:participation_detail",
args=(self.team1.participation.pk,)), 302, 200)
response = self.client.get(reverse("participation:participation_receive_participation",
args=(self.team1.participation.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:participation_send_participation",
args=(self.team1.participation.pk,)),
data=dict(sent_participation=self.team3.participation.pk))
self.assertRedirects(response, reverse("participation:participation_detail",
args=(self.team1.participation.pk,)), 302, 200)
self.team1.participation.refresh_from_db()
self.team2.participation.refresh_from_db()
self.team3.participation.refresh_from_db()
self.assertEqual(self.team1.participation.received_participation.pk, self.team2.participation.pk)
self.assertEqual(self.team1.participation.sent_participation.pk, self.team3.participation.pk)
self.assertEqual(self.team2.participation.sent_participation.pk, self.team1.participation.pk)
self.assertEqual(self.team3.participation.received_participation.pk, self.team1.participation.pk)
# The other team didn't work on the same problem
response = self.client.post(reverse("participation:participation_receive_participation",
args=(self.team1.participation.pk,)),
data=dict(received_participation=self.other_team.participation.pk))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:participation_send_participation",
args=(self.team1.participation.pk,)),
data=dict(sent_participation=self.other_team.participation.pk))
self.assertEqual(response.status_code, 200)
def test_create_team_forbidden(self): def test_create_team_forbidden(self):
""" """
Ensure that an admin can't create a team. Ensure that an admin can't create a team.
@ -500,9 +831,23 @@ class TestAdminForbidden(TestCase):
)) ))
self.assertTrue(response.status_code, 403) self.assertTrue(response.status_code, 403)
def test_leave_team_forbidden(self):
"""
Ensure that an admin can't leave a team.
"""
response = self.client.get(reverse("participation:team_leave"))
self.assertTrue(response.status_code, 403)
def test_my_team_forbidden(self): def test_my_team_forbidden(self):
""" """
Ensure that an admin can't access to "My team". Ensure that an admin can't access to "My team".
""" """
response = self.client.get(reverse("participation:my_team_detail")) response = self.client.get(reverse("participation:my_team_detail"))
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_my_participation_forbidden(self):
"""
Ensure that an admin can't access to "My participation".
"""
response = self.client.get(reverse("participation:my_participation_detail"))
self.assertEqual(response.status_code, 403)

View File

@ -1,10 +1,13 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path from django.urls import path
from django.views.generic import TemplateView from django.views.generic import TemplateView
from .views import CalendarView, CreateQuestionView, CreateTeamView, DeleteQuestionView, JoinTeamView, \ from .views import CalendarView, CreateQuestionView, CreateTeamView, DeleteQuestionView, JoinTeamView, \
MyParticipationDetailView, MyTeamDetailView, ParticipationDetailView, PhaseUpdateView, \ MyParticipationDetailView, MyTeamDetailView, ParticipationDetailView, PhaseUpdateView, \
SetParticipationReceiveParticipationView, SetParticipationSendParticipationView, TeamAuthorizationsView, \ SetParticipationReceiveParticipationView, SetParticipationSendParticipationView, TeamAuthorizationsView, \
TeamDetailView, TeamLeaveView, TeamUpdateView, UpdateQuestionView, UploadVideoView TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, UpdateQuestionView, UploadVideoView
app_name = "participation" app_name = "participation"
@ -12,6 +15,7 @@ app_name = "participation"
urlpatterns = [ urlpatterns = [
path("create_team/", CreateTeamView.as_view(), name="create_team"), path("create_team/", CreateTeamView.as_view(), name="create_team"),
path("join_team/", JoinTeamView.as_view(), name="join_team"), path("join_team/", JoinTeamView.as_view(), name="join_team"),
path("teams/", TeamListView.as_view(), name="team_list"),
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"),

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from io import BytesIO from io import BytesIO
from zipfile import ZipFile from zipfile import ZipFile
@ -5,6 +8,7 @@ from corres2math.lists import get_sympa_client
from corres2math.matrix import Matrix from corres2math.matrix import Matrix
from corres2math.views import AdminMixin from corres2math.views import AdminMixin
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
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
@ -23,7 +27,7 @@ from .forms import JoinTeamForm, ParticipationForm, PhaseForm, QuestionForm, \
ReceiveParticipationForm, RequestValidationForm, SendParticipationForm, TeamForm, \ ReceiveParticipationForm, RequestValidationForm, SendParticipationForm, TeamForm, \
UploadVideoForm, ValidateParticipationForm UploadVideoForm, ValidateParticipationForm
from .models import Participation, Phase, Question, Team, Video from .models import Participation, Phase, Question, Team, Video
from .tables import CalendarTable from .tables import CalendarTable, TeamTable
class CreateTeamView(LoginRequiredMixin, CreateView): class CreateTeamView(LoginRequiredMixin, CreateView):
@ -38,6 +42,8 @@ class CreateTeamView(LoginRequiredMixin, CreateView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
user = request.user user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
registration = user.registration registration = user.registration
if not registration.participates: if not registration.participates:
raise PermissionDenied(_("You don't participate, so you can't create a team.")) raise PermissionDenied(_("You don't participate, so you can't create a team."))
@ -84,6 +90,8 @@ class JoinTeamView(LoginRequiredMixin, FormView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
user = request.user user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
registration = user.registration registration = user.registration
if not registration.participates: if not registration.participates:
raise PermissionDenied(_("You don't participate, so you can't create a team.")) raise PermissionDenied(_("You don't participate, so you can't create a team."))
@ -119,6 +127,15 @@ class JoinTeamView(LoginRequiredMixin, FormView):
return reverse_lazy("participation:team_detail", args=(self.object.pk,)) return reverse_lazy("participation:team_detail", args=(self.object.pk,))
class TeamListView(AdminMixin, SingleTableView):
"""
Display the whole list of teams
"""
model = Team
table_class = TeamTable
ordering = ('participation__problem', 'trigram',)
class MyTeamDetailView(LoginRequiredMixin, RedirectView): class MyTeamDetailView(LoginRequiredMixin, RedirectView):
""" """
Redirect to the detail of the team in which the user is. Redirect to the detail of the team in which the user is.
@ -199,7 +216,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
self.object.participation.save() self.object.participation.save()
for admin in AdminRegistration.objects.all(): for admin in AdminRegistration.objects.all():
mail_context = dict(user=admin.user, team=self.object) mail_context = dict(user=admin.user, team=self.object, domain=Site.objects.first().domain)
mail_plain = render_to_string("participation/mails/request_validation.txt", mail_context) mail_plain = render_to_string("participation/mails/request_validation.txt", mail_context)
mail_html = render_to_string("participation/mails/request_validation.html", mail_context) mail_html = render_to_string("participation/mails/request_validation.html", mail_context)
admin.user.email_user("[Corres2math] Validation d'équipe", mail_plain, html_message=mail_html) admin.user.email_user("[Corres2math] Validation d'équipe", mail_plain, html_message=mail_html)
@ -223,6 +240,11 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
mail_plain = render_to_string("participation/mails/team_validated.txt", mail_context) mail_plain = render_to_string("participation/mails/team_validated.txt", mail_context)
mail_html = render_to_string("participation/mails/team_validated.html", mail_context) mail_html = render_to_string("participation/mails/team_validated.html", mail_context)
send_mail("[Corres2math] Équipe validée", mail_plain, None, [self.object.email], html_message=mail_html) send_mail("[Corres2math] Équipe validée", mail_plain, None, [self.object.email], html_message=mail_html)
get_sympa_client().subscribe(self.object.email, "equipes", False, f"Equipe {self.object.name}")
get_sympa_client().unsubscribe(self.object.email, "equipes-non-valides", False)
get_sympa_client().subscribe(self.object.email, f"probleme-{self.object.participation.problem}", False,
f"Equipe {self.object.name}")
elif "invalidate" in self.request.POST: elif "invalidate" in self.request.POST:
self.object.participation.valid = None self.object.participation.valid = None
self.object.participation.save() self.object.participation.save()
@ -250,6 +272,8 @@ class TeamUpdateView(LoginRequiredMixin, UpdateView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
user = request.user user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
if user.registration.is_admin or user.registration.participates and \ if user.registration.is_admin or user.registration.participates and \
user.registration.team and \ user.registration.team and \
user.registration.team.pk == kwargs["pk"]: user.registration.team.pk == kwargs["pk"]:
@ -284,6 +308,8 @@ class TeamAuthorizationsView(LoginRequiredMixin, DetailView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
user = request.user user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
if user.registration.is_admin or user.registration.participates and user.registration.team.pk == kwargs["pk"]: if user.registration.is_admin or user.registration.participates and user.registration.team.pk == kwargs["pk"]:
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
raise PermissionDenied raise PermissionDenied
@ -316,9 +342,9 @@ class TeamLeaveView(LoginRequiredMixin, TemplateView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return self.handle_no_permission() return self.handle_no_permission()
if not request.user.registration.team: if not request.user.registration.participates or not request.user.registration.team:
raise PermissionDenied(_("You are not in a team.")) raise PermissionDenied(_("You are not in a team."))
if request.user.registration.team.participation.valid is not None: if request.user.registration.team.participation.valid:
raise PermissionDenied(_("The team is already validated or the validation is pending.")) raise PermissionDenied(_("The team is already validated or the validation is pending."))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@ -362,6 +388,8 @@ class ParticipationDetailView(LoginRequiredMixin, DetailView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
user = request.user user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
if not self.get_object().valid: if not self.get_object().valid:
raise PermissionDenied(_("The team is not validated yet.")) raise PermissionDenied(_("The team is not validated yet."))
if user.registration.is_admin or user.registration.participates \ if user.registration.is_admin or user.registration.participates \
@ -388,7 +416,7 @@ class SetParticipationReceiveParticipationView(AdminMixin, UpdateView):
template_name = "participation/receive_participation_form.html" template_name = "participation/receive_participation_form.html"
def get_success_url(self): def get_success_url(self):
return reverse_lazy("participation:participation_detail", args=(self.object.pk,)) return reverse_lazy("participation:participation_detail", args=(self.kwargs["pk"],))
class SetParticipationSendParticipationView(AdminMixin, UpdateView): class SetParticipationSendParticipationView(AdminMixin, UpdateView):
@ -400,7 +428,7 @@ class SetParticipationSendParticipationView(AdminMixin, UpdateView):
template_name = "participation/send_participation_form.html" template_name = "participation/send_participation_form.html"
def get_success_url(self): def get_success_url(self):
return reverse_lazy("participation:participation_detail", args=(self.object.pk,)) return reverse_lazy("participation:participation_detail", args=(self.kwargs["pk"],))
class CreateQuestionView(LoginRequiredMixin, CreateView): class CreateQuestionView(LoginRequiredMixin, CreateView):
@ -418,6 +446,7 @@ class CreateQuestionView(LoginRequiredMixin, CreateView):
self.participation = Participation.objects.get(pk=kwargs["pk"]) self.participation = Participation.objects.get(pk=kwargs["pk"])
if request.user.registration.is_admin or \ if request.user.registration.is_admin or \
request.user.registration.participates and \ request.user.registration.participates and \
self.participation.valid and \
request.user.registration.team.pk == self.participation.team_id: request.user.registration.team.pk == self.participation.team_id:
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
raise PermissionDenied raise PermissionDenied
@ -443,10 +472,17 @@ class UpdateQuestionView(LoginRequiredMixin, UpdateView):
return self.handle_no_permission() return self.handle_no_permission()
if request.user.registration.is_admin or \ if request.user.registration.is_admin or \
request.user.registration.participates and \ request.user.registration.participates and \
self.object.participation.valid and \
request.user.registration.team.pk == self.object.participation.team_id: request.user.registration.team.pk == self.object.participation.team_id:
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
raise PermissionDenied raise PermissionDenied
def form_valid(self, form):
if not self.request.user.registration.is_admin and Phase.current_phase().phase_number != 2:
form.add_error(None, _("You can update your questions now."))
return self.form_invalid(form)
return super().form_valid(form)
def get_success_url(self): def get_success_url(self):
return reverse_lazy("participation:participation_detail", args=(self.object.participation.pk,)) return reverse_lazy("participation:participation_detail", args=(self.object.participation.pk,))
@ -464,10 +500,16 @@ class DeleteQuestionView(LoginRequiredMixin, DeleteView):
return self.handle_no_permission() return self.handle_no_permission()
if request.user.registration.is_admin or \ if request.user.registration.is_admin or \
request.user.registration.participates and \ request.user.registration.participates and \
self.object.participation.valid and \
request.user.registration.team.pk == self.object.participation.team_id: request.user.registration.team.pk == self.object.participation.team_id:
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
raise PermissionDenied raise PermissionDenied
def delete(self, request, *args, **kwargs):
if not request.user.registration.is_admin and Phase.current_phase().phase_number != 2:
raise PermissionDenied(_("You can update your questions now."))
return super().delete(request, *args, **kwargs)
def get_success_url(self): def get_success_url(self):
return reverse_lazy("participation:participation_detail", args=(self.object.participation.pk,)) return reverse_lazy("participation:participation_detail", args=(self.object.participation.pk,))
@ -483,6 +525,8 @@ class UploadVideoView(LoginRequiredMixin, UpdateView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
user = request.user user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
if user.registration.is_admin or user.registration.participates \ if user.registration.is_admin or user.registration.participates \
and user.registration.team.participation.pk == self.get_object().participation.pk: and user.registration.team.participation.pk == self.get_object().participation.pk:
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)

View File

@ -1 +1,4 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'registration.apps.RegistrationConfig' default_app_config = 'registration.apps.RegistrationConfig'

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin from django.contrib import admin
from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicParentModelAdmin from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicParentModelAdmin

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import post_save, pre_save from django.db.models.signals import post_save, pre_save

View File

@ -1,7 +1,10 @@
from cas_server.auth import DjangoAuthUser # Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from cas_server.auth import DjangoAuthUser # pragma: no cover
class CustomAuthUser(DjangoAuthUser): class CustomAuthUser(DjangoAuthUser): # pragma: no cover
""" """
Override Django Auth User model to define a custom Matrix username. Override Django Auth User model to define a custom Matrix username.
""" """

View File

@ -13,5 +13,14 @@
"single_log_out": true, "single_log_out": true,
"single_log_out_callback": "" "single_log_out_callback": ""
} }
},
{
"model": "cas_server.replaceattributname",
"pk": 1,
"fields": {
"name": "display_name",
"replace": "",
"service_pattern": 1
}
} }
] ]

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django import forms from django import forms
from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -22,6 +25,15 @@ class SignupForm(UserCreationForm):
], ],
) )
def clean_email(self):
"""
Ensure that the email address is unique.
"""
email = self.data["email"]
if User.objects.filter(email=email).exists():
self.add_error("email", _("This email address is already used."))
return email
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["first_name"].required = True self.fields["first_name"].required = True
@ -55,7 +67,7 @@ class StudentRegistrationForm(forms.ModelForm):
""" """
class Meta: class Meta:
model = StudentRegistration model = StudentRegistration
fields = ('student_class', 'school', 'give_contact_to_animath',) fields = ('team', 'student_class', 'school', 'give_contact_to_animath', 'email_confirmed',)
class PhotoAuthorizationForm(forms.ModelForm): class PhotoAuthorizationForm(forms.ModelForm):
@ -63,7 +75,10 @@ class PhotoAuthorizationForm(forms.ModelForm):
Form to send a photo authorization. Form to send a photo authorization.
""" """
def clean_photo_authorization(self): def clean_photo_authorization(self):
if "photo_authorization" in self.files:
file = self.files["photo_authorization"] file = self.files["photo_authorization"]
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"]: 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.")) raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file."))
return self.cleaned_data["photo_authorization"] return self.cleaned_data["photo_authorization"]
@ -83,7 +98,7 @@ class CoachRegistrationForm(forms.ModelForm):
""" """
class Meta: class Meta:
model = CoachRegistration model = CoachRegistration
fields = ('professional_activity', 'give_contact_to_animath',) fields = ('team', 'professional_activity', 'give_contact_to_animath', 'email_confirmed',)
class AdminRegistrationForm(forms.ModelForm): class AdminRegistrationForm(forms.ModelForm):
@ -92,4 +107,4 @@ class AdminRegistrationForm(forms.ModelForm):
""" """
class Meta: class Meta:
model = AdminRegistration model = AdminRegistration
fields = ('role', 'give_contact_to_animath',) fields = ('role', 'give_contact_to_animath', 'email_confirmed',)

View File

@ -1,8 +1,9 @@
# Generated by Django 3.1.1 on 2020-09-21 15:51 # Generated by Django 3.1.3 on 2020-11-04 12:05
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import registration.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -11,8 +12,8 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
('participation', '0001_initial'), ('participation', '0001_initial'),
('contenttypes', '0002_remove_content_type_name'),
] ]
operations = [ operations = [
@ -21,8 +22,8 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('give_contact_to_animath', models.BooleanField(default=False, verbose_name='Grant Animath to contact me in the future about other actions')), ('give_contact_to_animath', models.BooleanField(default=False, verbose_name='Grant Animath to contact me in the future about other actions')),
('email_confirmed', models.BooleanField(default=False, verbose_name='email confirmed')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_registration.registration_set+', to='contenttypes.contenttype')), ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_registration.registration_set+', to='contenttypes.contenttype')),
('team', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, to='participation.team', verbose_name='team')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')), ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')),
], ],
options={ options={
@ -42,24 +43,14 @@ class Migration(migrations.Migration):
}, },
bases=('registration.registration',), bases=('registration.registration',),
), ),
migrations.CreateModel(
name='CoachRegistration',
fields=[
('registration_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registration.registration')),
('professional_activity', models.TextField(verbose_name='professional activity')),
],
options={
'verbose_name': 'coach registration',
'verbose_name_plural': 'coach registrations',
},
bases=('registration.registration',),
),
migrations.CreateModel( migrations.CreateModel(
name='StudentRegistration', name='StudentRegistration',
fields=[ fields=[
('registration_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registration.registration')), ('registration_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registration.registration')),
('student_class', models.IntegerField(choices=[(12, '12th grade'), (11, '11th grade'), (10, '10th grade or lower')], verbose_name='student class')), ('student_class', models.IntegerField(choices=[(12, '12th grade'), (11, '11th grade'), (10, '10th grade or lower')], verbose_name='student class')),
('school', models.CharField(max_length=255, verbose_name='school')), ('school', models.CharField(max_length=255, verbose_name='school')),
('photo_authorization', models.FileField(blank=True, default='', upload_to=registration.models.get_random_filename, verbose_name='photo authorization')),
('team', models.ForeignKey(default=None, blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='students', to='participation.team', verbose_name='team')),
], ],
options={ options={
'verbose_name': 'student registration', 'verbose_name': 'student registration',
@ -67,4 +58,17 @@ class Migration(migrations.Migration):
}, },
bases=('registration.registration',), bases=('registration.registration',),
), ),
migrations.CreateModel(
name='CoachRegistration',
fields=[
('registration_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='registration.registration')),
('professional_activity', models.TextField(verbose_name='professional activity')),
('team', models.ForeignKey(default=None, blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='coachs', to='participation.team', verbose_name='team')),
],
options={
'verbose_name': 'coach registration',
'verbose_name_plural': 'coach registrations',
},
bases=('registration.registration',),
),
] ]

View File

@ -1,29 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-21 17:48
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('participation', '0001_initial'),
('registration', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='registration',
name='team',
),
migrations.AddField(
model_name='coachregistration',
name='team',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='coachs', to='participation.team', verbose_name='team'),
),
migrations.AddField(
model_name='studentregistration',
name='team',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='students', to='participation.team', verbose_name='team'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-22 16:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('registration', '0002_auto_20200921_1948'),
]
operations = [
migrations.AddField(
model_name='registration',
name='email_confirmed',
field=models.BooleanField(default=False, verbose_name='email confirmed'),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-24 20:36
from django.db import migrations, models
import registration.models
class Migration(migrations.Migration):
dependencies = [
('registration', '0003_registration_email_confirmed'),
]
operations = [
migrations.AddField(
model_name='studentregistration',
name='photo_authorization',
field=models.FileField(blank=True, default='', upload_to=registration.models.get_random_filename, verbose_name='photo authorization'),
),
]

View File

@ -0,0 +1,2 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from corres2math.tokens import email_validation_token from corres2math.tokens import email_validation_token
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.db import models from django.db import models
@ -58,11 +61,11 @@ class Registration(PolymorphicModel):
self.user.email_user(subject, message, html_message=html) self.user.email_user(subject, message, html_message=html)
@property @property
def type(self): def type(self): # pragma: no cover
raise NotImplementedError raise NotImplementedError
@property @property
def form_class(self): def form_class(self): # pragma: no cover
raise NotImplementedError raise NotImplementedError
@property @property
@ -102,6 +105,7 @@ class StudentRegistration(Registration):
related_name="students", related_name="students",
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=True,
default=None, default=None,
verbose_name=_("team"), verbose_name=_("team"),
) )
@ -151,6 +155,7 @@ class CoachRegistration(Registration):
related_name="coachs", related_name="coachs",
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=True,
default=None, default=None,
verbose_name=_("team"), verbose_name=_("team"),
) )

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from haystack import indexes from haystack import indexes
from .models import Registration from .models import Registration

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from corres2math.lists import get_sympa_client from corres2math.lists import get_sympa_client
from corres2math.matrix import Matrix from corres2math.matrix import Matrix
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -45,8 +48,9 @@ def invite_to_public_rooms(instance: Registration, created: bool, **_):
""" """
When a user got registered, automatically invite the Matrix user into public rooms. When a user got registered, automatically invite the Matrix user into public rooms.
""" """
if not created: if created:
Matrix.invite("#annonces:correspondances-maths.fr", f"@{instance.matrix_username}:correspondances-maths.fr") Matrix.invite("#annonces:correspondances-maths.fr", f"@{instance.matrix_username}:correspondances-maths.fr")
Matrix.invite("#faq:correspondances-maths.fr", f"@{instance.matrix_username}:correspondances-maths.fr") Matrix.invite("#faq:correspondances-maths.fr", f"@{instance.matrix_username}:correspondances-maths.fr")
Matrix.invite("#je-cherche-une-equip:correspondances-maths.fr", Matrix.invite("#je-cherche-une-equipe:correspondances-maths.fr",
f"@{instance.matrix_username}:correspondances-maths.fr") f"@{instance.matrix_username}:correspondances-maths.fr")
Matrix.invite("#flood:correspondances-maths.fr", f"@{instance.matrix_username}:correspondances-maths.fr")

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import django_tables2 as tables import django_tables2 as tables

View File

@ -19,7 +19,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
</p> </p>
{% else %} {% else %}
<p> <p>
{% trans "The link was invalid. The token may have expired. Please send us an email to activate your account." %} {% if user.is_authenticated and user.registration.email_confirmed %}
{% trans "The link was invalid. The token may have expired, or your account is already activated. However, your account seems to be already valid." %}
{% else %}
{% trans "The link was invalid. The token may have expired, or your account is already activated. Please send us an email to activate your account." %}
{% endif %}
</p> </p>
{% endif %} {% endif %}
</div> </div>

View File

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% load django_tables2 %}
{% block content %}
{% render_table table %}
{% endblock %}

View File

@ -0,0 +1,2 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django import template from django import template
from django_tables2 import Table from django_tables2 import Table
from participation.models import Participation, Team, Video from participation.models import Participation, Team, Video
@ -9,6 +12,7 @@ from ..tables import RegistrationTable
def search_table(results): def search_table(results):
model_class = results[0].object.__class__ model_class = results[0].object.__class__
table_class = Table
if issubclass(model_class, Registration): if issubclass(model_class, Registration):
table_class = RegistrationTable table_class = RegistrationTable
elif issubclass(model_class, Team): elif issubclass(model_class, Team):
@ -17,8 +21,6 @@ def search_table(results):
table_class = ParticipationTable table_class = ParticipationTable
elif issubclass(model_class, Video): elif issubclass(model_class, Video):
table_class = VideoTable table_class = VideoTable
else:
table_class = Table
return table_class([result.object for result in results], prefix=model_class._meta.model_name) return table_class([result.object for result in results], prefix=model_class._meta.model_name)

View File

@ -1,16 +1,23 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta
import os import os
from corres2math.tokens import email_validation_token from corres2math.tokens import email_validation_token
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management import call_command from django.core.management import call_command
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.encoding import force_bytes from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode from django.utils.http import urlsafe_base64_encode
from participation.models import Phase, Team
from .models import AdminRegistration, CoachRegistration, Registration, StudentRegistration from .models import AdminRegistration, CoachRegistration, StudentRegistration
class TestIndexPage(TestCase): class TestIndexPage(TestCase):
@ -28,6 +35,28 @@ class TestIndexPage(TestCase):
response = self.client.get(reverse("registration:reset_admin")) response = self.client.get(reverse("registration:reset_admin"))
self.assertRedirects(response, reverse("login") + "?next=" + reverse("registration:reset_admin"), 302, 200) self.assertRedirects(response, reverse("login") + "?next=" + reverse("registration:reset_admin"), 302, 200)
User.objects.create()
response = self.client.get(reverse("registration:user_detail", args=(1,)))
self.assertRedirects(response, reverse("login") + "?next=" + reverse("registration:user_detail", args=(1,)))
Team.objects.create()
response = self.client.get(reverse("participation:team_detail", args=(1,)))
self.assertRedirects(response, reverse("login") + "?next=" + reverse("participation:team_detail", args=(1,)))
response = self.client.get(reverse("participation:update_team", args=(1,)))
self.assertRedirects(response, reverse("login") + "?next=" + reverse("participation:update_team", args=(1,)))
response = self.client.get(reverse("participation:create_team"))
self.assertRedirects(response, reverse("login") + "?next=" + reverse("participation:create_team"))
response = self.client.get(reverse("participation:join_team"))
self.assertRedirects(response, reverse("login") + "?next=" + reverse("participation:join_team"))
response = self.client.get(reverse("participation:team_authorizations", args=(1,)))
self.assertRedirects(response, reverse("login") + "?next="
+ reverse("participation:team_authorizations", args=(1,)))
response = self.client.get(reverse("participation:participation_detail", args=(1,)))
self.assertRedirects(response, reverse("login") + "?next="
+ reverse("participation:participation_detail", args=(1,)))
response = self.client.get(reverse("participation:upload_video", args=(1,)))
self.assertRedirects(response, reverse("login") + "?next=" + reverse("participation:upload_video", args=(1,)))
class TestRegistration(TestCase): class TestRegistration(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
@ -81,6 +110,13 @@ class TestRegistration(TestCase):
""" """
Ensure that the signup form is working successfully. Ensure that the signup form is working successfully.
""" """
# After first phase
response = self.client.get(reverse("registration:signup"))
self.assertEqual(response.status_code, 403)
Phase.objects.filter(phase_number__gte=2).update(start=timezone.now() + timedelta(days=1),
end=timezone.now() + timedelta(days=2))
response = self.client.get(reverse("registration:signup")) response = self.client.get(reverse("registration:signup"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -109,6 +145,20 @@ class TestRegistration(TestCase):
self.assertRedirects(response, reverse("registration:email_validation_sent"), 302, 200) self.assertRedirects(response, reverse("registration:email_validation_sent"), 302, 200)
self.assertTrue(User.objects.filter(email="toto@example.com").exists()) self.assertTrue(User.objects.filter(email="toto@example.com").exists())
# Email is already used
response = self.client.post(reverse("registration:signup"), data=dict(
last_name="Toto",
first_name="Toto",
email="toto@example.com",
password1="azertyuiopazertyuiop",
password2="azertyuiopazertyuiop",
role="participant",
student_class=12,
school="God",
give_contact_to_animath=False,
))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("registration:email_validation_sent")) response = self.client.get(reverse("registration:email_validation_sent"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -175,10 +225,25 @@ class TestRegistration(TestCase):
response = self.client.get(reverse("registration:user_detail", args=(self.user.pk,))) response = self.client.get(reverse("registration:user_detail", args=(self.user.pk,)))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_user_list(self):
"""
Display the list of all users.
"""
response = self.client.get(reverse("registration:user_list"))
self.assertEqual(response.status_code, 200)
def test_update_user(self): def test_update_user(self):
""" """
Update the user information, for each type of user. Update the user information, for each type of user.
""" """
# To test the modification of mailing lists
from participation.models import Team
self.student.registration.team = Team.objects.create(
name="toto",
trigram="TOT",
)
self.student.registration.save()
for user, data in [(self.user, dict(role="Bot")), for user, data in [(self.user, dict(role="Bot")),
(self.student, dict(student_class=11, school="Sky")), (self.student, dict(student_class=11, school="Sky")),
(self.coach, dict(professional_activity="God"))]: (self.coach, dict(professional_activity="God"))]:
@ -190,6 +255,8 @@ class TestRegistration(TestCase):
last_name="Name", last_name="Name",
email="new_" + user.email, email="new_" + user.email,
give_contact_to_animath=True, give_contact_to_animath=True,
email_confirmed=True,
team_id="",
)) ))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -198,6 +265,8 @@ class TestRegistration(TestCase):
last_name="Name", last_name="Name",
email="new_" + user.email, email="new_" + user.email,
give_contact_to_animath=True, give_contact_to_animath=True,
email_confirmed=True,
team_id="",
) )
response = self.client.post(reverse("registration:update_user", args=(user.pk,)), data=data) response = self.client.post(reverse("registration:update_user", args=(user.pk,)), data=data)
self.assertRedirects(response, reverse("registration:user_detail", args=(user.pk,)), 302, 200) self.assertRedirects(response, reverse("registration:user_detail", args=(user.pk,)), 302, 200)
@ -221,6 +290,14 @@ class TestRegistration(TestCase):
)) ))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Don't send too large files
response = self.client.post(reverse("registration:upload_user_photo_authorization",
args=(self.student.registration.pk,)), data=dict(
photo_authorization=SimpleUploadedFile("file.pdf", content=int(0).to_bytes(2000001, "big"),
content_type="application/pdf"),
))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("registration:upload_user_photo_authorization", response = self.client.post(reverse("registration:upload_user_photo_authorization",
args=(self.student.registration.pk,)), data=dict( args=(self.student.registration.pk,)), data=dict(
photo_authorization=open("corres2math/static/Autorisation de droit à l'image - majeur.pdf", "rb"), photo_authorization=open("corres2math/static/Autorisation de droit à l'image - majeur.pdf", "rb"),
@ -316,8 +393,3 @@ class TestRegistration(TestCase):
self.student.registration.get_student_class_display()) self.student.registration.get_student_class_display())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["object_list"]) self.assertTrue(response.context["object_list"])
def test_not_implemented_error(self):
# Only for coverage
self.assertRaises(NotImplementedError, lambda: Registration().type)
self.assertRaises(NotImplementedError, lambda: Registration().form_class)

View File

@ -1,7 +1,10 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path from django.urls import path
from .views import MyAccountDetailView, ResetAdminView, SignupView, UserDetailView, UserImpersonateView, \ from .views import MyAccountDetailView, ResetAdminView, SignupView, UserDetailView, UserImpersonateView, \
UserResendValidationEmailView, UserUpdateView, UserUploadPhotoAuthorizationView, UserValidateView, \ UserListView, UserResendValidationEmailView, UserUpdateView, UserUploadPhotoAuthorizationView, UserValidateView, \
UserValidationEmailSentView UserValidationEmailSentView
app_name = "registration" app_name = "registration"
@ -18,5 +21,6 @@ urlpatterns = [
path("user/<int:pk>/upload-photo-authorization/", UserUploadPhotoAuthorizationView.as_view(), path("user/<int:pk>/upload-photo-authorization/", UserUploadPhotoAuthorizationView.as_view(),
name="upload_user_photo_authorization"), name="upload_user_photo_authorization"),
path("user/<int:pk>/impersonate/", UserImpersonateView.as_view(), name="user_impersonate"), path("user/<int:pk>/impersonate/", UserImpersonateView.as_view(), name="user_impersonate"),
path("user/list/", UserListView.as_view(), name="user_list"),
path("reset-admin/", ResetAdminView.as_view(), name="reset_admin"), path("reset-admin/", ResetAdminView.as_view(), name="reset_admin"),
] ]

View File

@ -1,6 +1,10 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import os import os
from corres2math.tokens import email_validation_token from corres2math.tokens import email_validation_token
from corres2math.views import AdminMixin
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -12,10 +16,13 @@ from django.urls import reverse_lazy
from django.utils.http import urlsafe_base64_decode from django.utils.http import urlsafe_base64_decode
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, RedirectView, TemplateView, UpdateView, View from django.views.generic import CreateView, DetailView, RedirectView, TemplateView, UpdateView, View
from django_tables2 import SingleTableView
from magic import Magic from magic import Magic
from participation.models import Phase
from .forms import CoachRegistrationForm, PhotoAuthorizationForm, SignupForm, StudentRegistrationForm, UserForm from .forms import CoachRegistrationForm, PhotoAuthorizationForm, SignupForm, StudentRegistrationForm, UserForm
from .models import StudentRegistration from .models import Registration, StudentRegistration
from .tables import RegistrationTable
class SignupView(CreateView): class SignupView(CreateView):
@ -27,12 +34,26 @@ class SignupView(CreateView):
template_name = "registration/signup.html" template_name = "registration/signup.html"
extra_context = dict(title=_("Sign up")) extra_context = dict(title=_("Sign up"))
def dispatch(self, request, *args, **kwargs):
"""
The signup view is available only during the first phase.
"""
current_phase = Phase.current_phase()
if not current_phase or current_phase.phase_number >= 2:
raise PermissionDenied(_("You can't register now."))
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data() context = super().get_context_data()
context["student_registration_form"] = StudentRegistrationForm(self.request.POST or None) context["student_registration_form"] = StudentRegistrationForm(self.request.POST or None)
context["coach_registration_form"] = CoachRegistrationForm(self.request.POST or None) context["coach_registration_form"] = CoachRegistrationForm(self.request.POST or None)
del context["student_registration_form"].fields["team"]
del context["student_registration_form"].fields["email_confirmed"]
del context["coach_registration_form"].fields["team"]
del context["coach_registration_form"].fields["email_confirmed"]
return context return context
@transaction.atomic @transaction.atomic
@ -42,6 +63,8 @@ class SignupView(CreateView):
registration_form = StudentRegistrationForm(self.request.POST) registration_form = StudentRegistrationForm(self.request.POST)
else: else:
registration_form = CoachRegistrationForm(self.request.POST) registration_form = CoachRegistrationForm(self.request.POST)
del registration_form.fields["team"]
del registration_form.fields["email_confirmed"]
if not registration_form.is_valid(): if not registration_form.is_valid():
return self.form_invalid(form) return self.form_invalid(form)
@ -148,6 +171,8 @@ class UserDetailView(LoginRequiredMixin, DetailView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
user = request.user user = request.user
if not user.is_authenticated:
return self.handle_no_permission()
# Only an admin or the concerned user can see the information # Only an admin or the concerned user can see the information
if not user.registration.is_admin and user.pk != kwargs["pk"]: if not user.registration.is_admin and user.pk != kwargs["pk"]:
raise PermissionDenied raise PermissionDenied
@ -159,6 +184,15 @@ class UserDetailView(LoginRequiredMixin, DetailView):
return context return context
class UserListView(AdminMixin, SingleTableView):
"""
Display the list of all registered users.
"""
model = Registration
table_class = RegistrationTable
template_name = "registration/user_list.html"
class UserUpdateView(LoginRequiredMixin, UpdateView): class UserUpdateView(LoginRequiredMixin, UpdateView):
""" """
Update the detail about a user and its registration. Update the detail about a user and its registration.
@ -179,6 +213,10 @@ class UserUpdateView(LoginRequiredMixin, UpdateView):
context["title"] = _("Update user {user}").format(user=str(self.object.registration)) context["title"] = _("Update user {user}").format(user=str(self.object.registration))
context["registration_form"] = user.registration.form_class(data=self.request.POST or None, context["registration_form"] = user.registration.form_class(data=self.request.POST or None,
instance=self.object.registration) instance=self.object.registration)
if not self.request.user.registration.is_admin:
if "team" in context["registration_form"].fields:
del context["registration_form"].fields["team"]
del context["registration_form"].fields["email_confirmed"]
return context return context
@transaction.atomic @transaction.atomic
@ -186,6 +224,11 @@ class UserUpdateView(LoginRequiredMixin, UpdateView):
user = form.instance user = form.instance
registration_form = user.registration.form_class(data=self.request.POST or None, registration_form = user.registration.form_class(data=self.request.POST or None,
instance=self.object.registration) instance=self.object.registration)
if not self.request.user.registration.is_admin:
if "team" in registration_form.fields:
del registration_form.fields["team"]
del registration_form.fields["email_confirmed"]
if not registration_form.is_valid(): if not registration_form.is_valid():
return self.form_invalid(form) return self.form_invalid(form)

View File

@ -3,3 +3,6 @@
* * * * * cd /code && python manage.py send_mail -c 1 * * * * * cd /code && python manage.py send_mail -c 1
* * * * * cd /code && python manage.py retry_deferred -c 1 * * * * * cd /code && python manage.py retry_deferred -c 1
0 0 * * * cd /code && python manage.py purge_mail_log 7 -c 1 0 0 * * * cd /code && python manage.py purge_mail_log 7 -c 1
# Rebuild search index
0 * * * * cd /code && python manage.py update_index -v 0

View File

@ -0,0 +1,2 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
ASGI config for corres2math project. ASGI config for corres2math project.

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import os import os
_client = None _client = None
@ -6,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: if os.getenv("SYMPA_PASSWORD", None) is not 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

@ -1,8 +1,10 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from enum import Enum
import os import os
from typing import Tuple
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from nio import *
class Matrix: class Matrix:
@ -14,11 +16,11 @@ class Matrix:
Tasks are normally asynchronous, but for compatibility we make Tasks are normally asynchronous, but for compatibility we make
them synchronous. them synchronous.
""" """
_token: str = None _token = None
_device_id: str = None _device_id = None
@classmethod @classmethod
async def _get_client(cls) -> Union[AsyncClient, "FakeMatrixClient"]: async def _get_client(cls): # pragma: no cover
""" """
Retrieve the bot account. Retrieve the bot account.
If not logged, log in and store access token. If not logged, log in and store access token.
@ -26,6 +28,7 @@ class Matrix:
if not os.getenv("SYNAPSE_PASSWORD"): if not os.getenv("SYNAPSE_PASSWORD"):
return FakeMatrixClient() return FakeMatrixClient()
from nio import AsyncClient
client = AsyncClient("https://correspondances-maths.fr", "@corres2mathbot:correspondances-maths.fr") client = AsyncClient("https://correspondances-maths.fr", "@corres2mathbot:correspondances-maths.fr")
client.user_id = "@corres2mathbot:correspondances-maths.fr" client.user_id = "@corres2mathbot:correspondances-maths.fr"
@ -49,7 +52,7 @@ class Matrix:
@classmethod @classmethod
@async_to_sync @async_to_sync
async def set_display_name(cls, name: str) -> Union[ProfileSetDisplayNameResponse, ProfileSetDisplayNameError]: async def set_display_name(cls, name: str):
""" """
Set the display name of the bot account. Set the display name of the bot account.
""" """
@ -58,7 +61,7 @@ class Matrix:
@classmethod @classmethod
@async_to_sync @async_to_sync
async def set_avatar(cls, avatar_url: str) -> Union[ProfileSetAvatarResponse, ProfileSetAvatarError]: async def set_avatar(cls, avatar_url: str): # pragma: no cover
""" """
Set the display avatar of the bot account. Set the display avatar of the bot account.
""" """
@ -69,13 +72,13 @@ class Matrix:
@async_to_sync @async_to_sync
async def upload( async def upload(
cls, cls,
data_provider: DataProvider, data_provider,
content_type: str = "application/octet-stream", content_type: str = "application/octet-stream",
filename: Optional[str] = None, filename: str = None,
encrypt: bool = False, encrypt: bool = False,
monitor: Optional[TransferMonitor] = None, monitor=None,
filesize: Optional[int] = None, filesize: int = None,
) -> Tuple[Union[UploadResponse, UploadError], Optional[Dict[str, Any]]]: ): # pragma: no cover
""" """
Upload a file to the content repository. Upload a file to the content repository.
@ -129,24 +132,25 @@ class Matrix:
If left as ``None``, some servers might refuse the upload. If left as ``None``, some servers might refuse the upload.
""" """
client = await cls._get_client() client = await cls._get_client()
return await client.upload(data_provider, content_type, filename, encrypt, monitor, filesize) return await client.upload(data_provider, content_type, filename, encrypt, monitor, filesize) \
if not isinstance(client, FakeMatrixClient) else None, None
@classmethod @classmethod
@async_to_sync @async_to_sync
async def create_room( async def create_room(
cls, cls,
visibility: RoomVisibility = RoomVisibility.private, visibility=None,
alias: Optional[str] = None, alias=None,
name: Optional[str] = None, name=None,
topic: Optional[str] = None, topic=None,
room_version: Optional[str] = None, room_version=None,
federate: bool = True, federate=True,
is_direct: bool = False, is_direct=False,
preset: Optional[RoomPreset] = None, preset=None,
invite=(), invite=(),
initial_state=(), initial_state=(),
power_level_override: Optional[Dict[str, Any]] = None, power_level_override=None,
) -> Union[RoomCreateResponse, RoomCreateError]: ):
""" """
Create a new room. Create a new room.
@ -208,20 +212,18 @@ class Matrix:
power_level_override) power_level_override)
@classmethod @classmethod
async def resolve_room_alias(cls, room_alias: str) -> Optional[str]: async def resolve_room_alias(cls, room_alias: str):
""" """
Resolve a room alias to a room ID. Resolve a room alias to a room ID.
Return None if the alias does not exist. Return None if the alias does not exist.
""" """
client = await cls._get_client() client = await cls._get_client()
resp: RoomResolveAliasResponse = await client.room_resolve_alias(room_alias) resp = await client.room_resolve_alias(room_alias)
if isinstance(resp, RoomResolveAliasResponse): return resp.room_id if resp and hasattr(resp, "room_id") else None
return resp.room_id
return None
@classmethod @classmethod
@async_to_sync @async_to_sync
async def invite(cls, room_id: str, user_id: str) -> Union[RoomInviteResponse, RoomInviteError]: async def invite(cls, room_id: str, user_id: str):
""" """
Invite a user to a room. Invite a user to a room.
@ -263,13 +265,13 @@ class Matrix:
@classmethod @classmethod
@async_to_sync @async_to_sync
async def kick(cls, room_id: str, user_id: str, reason: str = None) -> Union[RoomKickResponse, RoomInviteError]: async def kick(cls, room_id: str, user_id: str, reason: str = None):
""" """
Kick a user from a room, or withdraw their invitation. Kick a user from a room, or withdraw their invitation.
Kicking a user adjusts their membership to "leave" with an optional Kicking a user adjusts their membership to "leave" with an optional
reason. reason.
²
Returns either a `RoomKickResponse` if the request was successful or Returns either a `RoomKickResponse` if the request was successful or
a `RoomKickError` if there was an error with the request. a `RoomKickError` if there was an error with the request.
@ -286,8 +288,7 @@ class Matrix:
@classmethod @classmethod
@async_to_sync @async_to_sync
async def set_room_power_level(cls, room_id: str, user_id: str, power_level: int)\ async def set_room_power_level(cls, room_id: str, user_id: str, power_level: int): # pragma: no cover
-> Union[RoomPutStateResponse, RoomPutStateError]:
""" """
Put a given power level to a user in a certain room. Put a given power level to a user in a certain room.
@ -302,6 +303,9 @@ class Matrix:
power_level (int): The target power level to give. power_level (int): The target power level to give.
""" """
client = await cls._get_client() client = await cls._get_client()
if isinstance(client, FakeMatrixClient):
return None
if room_id.startswith("#"): if room_id.startswith("#"):
room_id = await cls.resolve_room_alias(room_id) room_id = await cls.resolve_room_alias(room_id)
resp = await client.room_get_state_event(room_id, "m.room.power_levels") resp = await client.room_get_state_event(room_id, "m.room.power_levels")
@ -311,8 +315,7 @@ class Matrix:
@classmethod @classmethod
@async_to_sync @async_to_sync
async def set_room_power_level_event(cls, room_id: str, event: str, power_level: int)\ async def set_room_power_level_event(cls, room_id: str, event: str, power_level: int): # pragma: no cover
-> Union[RoomPutStateResponse, RoomPutStateError]:
""" """
Define the minimal power level to have to send a certain event type Define the minimal power level to have to send a certain event type
in a given room. in a given room.
@ -328,6 +331,9 @@ class Matrix:
power_level (int): The target power level to give. power_level (int): The target power level to give.
""" """
client = await cls._get_client() client = await cls._get_client()
if isinstance(client, FakeMatrixClient):
return None
if room_id.startswith("#"): if room_id.startswith("#"):
room_id = await cls.resolve_room_alias(room_id) room_id = await cls.resolve_room_alias(room_id)
resp = await client.room_get_state_event(room_id, "m.room.power_levels") resp = await client.room_get_state_event(room_id, "m.room.power_levels")
@ -340,8 +346,7 @@ class Matrix:
@classmethod @classmethod
@async_to_sync @async_to_sync
async def set_room_avatar(cls, room_id: str, avatar_uri: str)\ async def set_room_avatar(cls, room_id: str, avatar_uri: str):
-> Union[RoomPutStateResponse, RoomPutStateError]:
""" """
Define the avatar of a room. Define the avatar of a room.
@ -361,6 +366,22 @@ class Matrix:
}, state_key="") }, state_key="")
if os.getenv("SYNAPSE_PASSWORD"): # pragma: no cover
from nio import RoomVisibility, RoomPreset
RoomVisibility = RoomVisibility
RoomPreset = RoomPreset
else:
# When running tests, faking matrix-nio classes to don't include the module
class RoomVisibility(Enum):
private = 'private'
public = 'public'
class RoomPreset(Enum):
private_chat = "private_chat"
trusted_private_chat = "trusted_private_chat"
public_chat = "public_chat"
class FakeMatrixClient: class FakeMatrixClient:
""" """
Simulate a Matrix client to run tests, if no Matrix homeserver is connected. Simulate a Matrix client to run tests, if no Matrix homeserver is connected.
@ -370,4 +391,3 @@ class FakeMatrixClient:
async def func(*_, **_2): async def func(*_, **_2):
return None return None
return func return func

View File

@ -1,9 +1,10 @@
from django.conf import settings # Copyright (C) 2020 by Animath
from django.contrib.auth.models import AnonymousUser, User # SPDX-License-Identifier: GPL-3.0-or-later
from threading import local from threading import local
from django.contrib.sessions.backends.db import SessionStore from django.conf import settings
from django.contrib.auth.models import AnonymousUser, User
USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user') USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user')
SESSION_ATTR_NAME = getattr(settings, 'LOCAL_SESSION_ATTR_NAME', '_current_session') SESSION_ATTR_NAME = getattr(settings, 'LOCAL_SESSION_ATTR_NAME', '_current_session')
@ -22,19 +23,13 @@ def get_current_user() -> User:
return getattr(_thread_locals, USER_ATTR_NAME, None) return getattr(_thread_locals, USER_ATTR_NAME, None)
def get_current_session() -> SessionStore:
return getattr(_thread_locals, SESSION_ATTR_NAME, None)
def get_current_ip() -> str: def get_current_ip() -> str:
return getattr(_thread_locals, IP_ATTR_NAME, None) return getattr(_thread_locals, IP_ATTR_NAME, None)
def get_current_authenticated_user(): def get_current_authenticated_user():
current_user = get_current_user() current_user = get_current_user()
if isinstance(current_user, AnonymousUser): return None if isinstance(current_user, AnonymousUser) else current_user
return None
return current_user
class SessionMiddleware(object): class SessionMiddleware(object):
@ -50,10 +45,7 @@ class SessionMiddleware(object):
request.user = User.objects.get(pk=request.session["_fake_user_id"]) request.user = User.objects.get(pk=request.session["_fake_user_id"])
user = request.user user = request.user
if 'HTTP_X_REAL_IP' in request.META: ip = request.META.get('HTTP_X_REAL_IP' if 'HTTP_X_REAL_IP' in request.META else 'REMOTE_ADDR')
ip = request.META.get('HTTP_X_REAL_IP')
else:
ip = request.META.get('REMOTE_ADDR')
_set_current_user_and_ip(user, request.session, ip) _set_current_user_and_ip(user, request.session, ip)
response = self.get_response(request) response = self.get_response(request)
@ -62,7 +54,7 @@ class SessionMiddleware(object):
return response return response
class TurbolinksMiddleware(object): class TurbolinksMiddleware(object): # pragma: no cover
""" """
Send the `Turbolinks-Location` header in response to a visit that was redirected, Send the `Turbolinks-Location` header in response to a visit that was redirected,
and Turbolinks will replace the browser's topmost history entry. and Turbolinks will replace the browser's topmost history entry.

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
Django settings for corres2math project. Django settings for corres2math project.
@ -52,15 +55,12 @@ INSTALLED_APPS = [
'bootstrap_datepicker_plus', 'bootstrap_datepicker_plus',
'crispy_forms', 'crispy_forms',
'django_extensions',
'django_tables2', 'django_tables2',
'haystack', 'haystack',
'logs', 'logs',
'mailer',
'polymorphic', 'polymorphic',
'rest_framework', 'rest_framework',
'rest_framework.authtoken', 'rest_framework.authtoken',
'cas_server',
'api', 'api',
'eastereggs', 'eastereggs',
@ -68,6 +68,13 @@ INSTALLED_APPS = [
'participation', 'participation',
] ]
if "test" not in sys.argv: # pragma: no cover
INSTALLED_APPS += [
'cas_server',
'django_extensions',
'mailer',
]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
@ -89,8 +96,7 @@ LOGIN_REDIRECT_URL = "index"
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'corres2math/templates')] 'DIRS': [os.path.join(BASE_DIR, 'corres2math/templates')],
,
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
@ -196,7 +202,7 @@ HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
_db_type = os.getenv('DJANGO_DB_TYPE', 'sqlite').lower() _db_type = os.getenv('DJANGO_DB_TYPE', 'sqlite').lower()
if _db_type == 'mysql' or _db_type.startswith('postgres') or _db_type == 'psql': if _db_type == 'mysql' or _db_type.startswith('postgres') or _db_type == 'psql': # pragma: no cover
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.mysql' if _db_type == 'mysql' else 'django.db.backends.postgresql_psycopg2', 'ENGINE': 'django.db.backends.mysql' if _db_type == 'mysql' else 'django.db.backends.postgresql_psycopg2',
@ -215,7 +221,7 @@ else:
} }
} }
if os.getenv("CORRES2MATH_STAGE", "dev") == "prod": if os.getenv("CORRES2MATH_STAGE", "dev") == "prod": # pragma: no cover
from .settings_prod import * from .settings_prod import * # noqa: F401,F403
else: else:
from .settings_dev import * from .settings_dev import * # noqa: F401,F403

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
# Database # Database
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases # https://docs.djangoproject.com/en/3.0/ref/settings/#databases

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import os import os
# Break it, fix it! # Break it, fix it!

View File

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block contenttitle %}
<h1>À propos</h1>
{% endblock %}
{% block content %}
<p>
La plateforme d'inscription des Correspondances des Jeunes Mathématiciennes a été développée entre 2019 et 2021
par Yohann D'ANELLO, bénévole pour l'association Animath. Elle est vouée à être utilisée par les participants
pour intéragir avec les organisateurs et les autres participants.
</p>
<p>
La plateforme est développée avec le framework <a href="https://www.djangoproject.com/">Django</a> et le code
source est accessible librement sur <a href="https://gitlab.com/animath/si/plateforme-corres2math">Gitlab</a>.
Le code est distribué sous la licence <a href="https://www.gnu.org/licenses/gpl-3.0.html">GNU GPL v3</a>,
qui vous autorise à consulter le code, à le partager, à réutiliser des parties du code et à contribuer.
</p>
<p>
Le site principal présent sur <a href="https://inscription.correspondances-maths.fr/">https://inscription.correspondances-maths.fr</a>
est hébergé chez <a href="https://www.scaleway.com/fr/">Scaleway</a>.
</p>
<p>
Les données collectées par cette plateforme sont utilisées uniquement dans le cadre des Correspondances et sont
détruites dès l'action touche à sa fin, soit au plus tard 1 an après le début de l'action. Sur autorisation
explicite, des informations de contact peuvent être conservées afin d'être tenu au courant des actions futures
de l'association Animath. Aucune information personnelle n'est collectée à votre insu. Aucune information
personnelle n'est cédée à des tiers.
</p>
<p>
Pour toute demande ou réclammation, merci de nous contacter à l'adresse
<a target="_blank" href="mailto:&#99;&#111;&#110;&#116;&#97;&#99;&#116;&#64;&#99;&#111;&#114;&#114;&#101;&#115;&#112;&#111;&#110;&#100;&#97;&#110;&#99;&#101;&#115;&#45;&#109;&#97;&#116;&#104;&#115;&#46;&#102;&#114;">
&#99;&#111;&#110;&#116;&#97;&#99;&#116;&#64;&#99;&#111;&#114;&#114;&#101;&#115;&#112;&#111;&#110;&#100;&#97;&#110;&#99;&#101;&#115;&#45;&#109;&#97;&#116;&#104;&#115;&#46;&#102;&#114;
</a>.
</p>
{% endblock %}

View File

@ -1,4 +1,4 @@
{% load static i18n static %} {% load static i18n static calendar %}
<!DOCTYPE html> <!DOCTYPE html>
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} {% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
@ -70,7 +70,14 @@
<a href="#" class="nav-link" data-toggle="modal" data-target="#calendarModal"><i class="fas fa-calendar"></i> {% trans "Calendar" %}</a> <a href="#" class="nav-link" data-toggle="modal" data-target="#calendarModal"><i class="fas fa-calendar"></i> {% trans "Calendar" %}</a>
{% endif %} {% endif %}
</li> </li>
{% if user.is_authenticated and user.registration.participates %} {% if user.is_authenticated and user.registration.is_admin %}
<li class="nav-item active">
<a href="{% url "registration:user_list" %}" class="nav-link"><i class="fas fa-user"></i> {% trans "Users" %}</a>
</li>
<li class="nav-item active">
<a href="#" class="nav-link" data-toggle="modal" data-target="#teamsModal"><i class="fas fa-users"></i> {% trans "Teams" %}</a>
</li>
{% elif user.is_authenticated and user.registration.participates %}
{% if not user.registration.team %} {% if not user.registration.team %}
<li class="nav-item active"> <li class="nav-item active">
<a href="#" class="nav-link" data-toggle="modal" data-target="#createTeamModal"> <a href="#" class="nav-link" data-toggle="modal" data-target="#createTeamModal">
@ -122,9 +129,11 @@
</li> </li>
{% endif %} {% endif %}
{% if not user.is_authenticated %} {% if not user.is_authenticated %}
{% if 1|current_phase %}
<li class="nav-item active"> <li class="nav-item active">
<a class="nav-link" href="{% url "registration:signup" %}"><i class="fas fa-user-plus"></i> {% trans "Register" %}</a> <a class="nav-link" href="{% url "registration:signup" %}"><i class="fas fa-user-plus"></i> {% trans "Register" %}</a>
</li> </li>
{% endif %}
<li class="nav-item active"> <li class="nav-item active">
<a class="nav-link" href="#" data-toggle="modal" data-target="#loginModal"> <a class="nav-link" href="#" data-toggle="modal" data-target="#loginModal">
<i class="fas fa-sign-in-alt"></i> {% trans "Log in" %} <i class="fas fa-sign-in-alt"></i> {% trans "Log in" %}
@ -183,7 +192,7 @@
class="form-inline"> class="form-inline">
<span class="text-muted mr-1"> <span class="text-muted mr-1">
<a target="_blank" href="mailto:&#99;&#111;&#110;&#116;&#97;&#99;&#116;&#64;&#99;&#111;&#114;&#114;&#101;&#115;&#112;&#111;&#110;&#100;&#97;&#110;&#99;&#101;&#115;&#45;&#109;&#97;&#116;&#104;&#115;&#46;&#102;&#114;" <a target="_blank" href="mailto:&#99;&#111;&#110;&#116;&#97;&#99;&#116;&#64;&#99;&#111;&#114;&#114;&#101;&#115;&#112;&#111;&#110;&#100;&#97;&#110;&#99;&#101;&#115;&#45;&#109;&#97;&#116;&#104;&#115;&#46;&#102;&#114;"
class="text-muted">{% trans "Contact us" %}</a> &mdash; class="text-muted"><i class="fas fa-envelope"></i> {% trans "Contact us" %}</a>
</span> </span>
{% csrf_token %} {% csrf_token %}
<select title="language" name="language" <select title="language" name="language"
@ -198,10 +207,15 @@
{{ lang_name }} ({{ lang_code }}) {{ lang_name }} ({{ lang_code }})
</option> </option>
{% endfor %} {% endfor %}
</select> </select> &nbsp;
<noscript> <noscript>
<input type="submit"> <input type="submit">
</noscript> </noscript> &nbsp;
<a target="_blank" class="text-muted" href="{% url "about" %}">{% trans "About" %}</a> &nbsp; &mdash; &nbsp;
<a target="_blank" class="text-muted"
href="https://gitlab.com/animath/si/plateforme-corres2math">
<i class="fab fa-gitlab"></i>
</a>
</form> </form>
</div> </div>
<div class="col text-right"> <div class="col text-right">
@ -216,21 +230,28 @@
{% trans "Calendar" as modal_title %} {% trans "Calendar" as modal_title %}
{% include "base_modal.html" with modal_id="calendar" modal_additional_class="modal-lg" %} {% include "base_modal.html" with modal_id="calendar" modal_additional_class="modal-lg" %}
{% trans "Search results" as modal_title %}
{% include "base_modal.html" with modal_id="search" modal_form_method="get" modal_additional_class="modal-lg" %}
{% trans "Log in" as modal_title %}
{% trans "Log in" as modal_button %}
{% url "login" as modal_action %}
{% include "base_modal.html" with modal_id="login" %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
{% trans "All teams" as modal_title %}
{% include "base_modal.html" with modal_id="teams" modal_additional_class="modal-lg" %}
{% trans "Search results" as modal_title %}
{% include "base_modal.html" with modal_id="search" modal_form_method="get" modal_additional_class="modal-lg" %}
{% trans "Join team" as modal_title %} {% trans "Join team" as modal_title %}
{% trans "Join" as modal_button %} {% trans "Join" as modal_button %}
{% url "participation:join_team" as modal_action %} {% url "participation:join_team" as modal_action %}
{% include "base_modal.html" with modal_id="joinTeam" %} {% include "base_modal.html" with modal_id="joinTeam" %}
{% trans "Create team" as modal_title %} {% trans "Create team" as modal_title %}
{% trans "Create" as modal_button %} {% trans "Create" as modal_button %}
{% url "participation:create_team" as modal_action %} {% url "participation:create_team" as modal_action %}
{% include "base_modal.html" with modal_id="createTeam" modal_button_type="success" %} {% include "base_modal.html" with modal_id="createTeam" modal_button_type="success" %}
{% else %}
{% trans "Log in" as modal_title %}
{% trans "Log in" as modal_button %}
{% url "login" as modal_action %}
{% include "base_modal.html" with modal_id="login" %}
{% endif %} {% endif %}
<script> <script>
@ -243,16 +264,28 @@
if (!modalBody.html().trim()) if (!modalBody.html().trim())
modalBody.load("{% url "participation:calendar" %} #form-content") modalBody.load("{% url "participation:calendar" %} #form-content")
}); });
{% if user.is_authenticated and user.registration.is_admin %}
$('a[data-target="#teamsModal"]').click(function() {
let modalBody = $("#teamsModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:team_list" %} #form-content")
});
$('button[data-target="#searchModal"]').click(function() { $('button[data-target="#searchModal"]').click(function() {
let modalBody = $("#searchModal div.modal-body"); let modalBody = $("#searchModal div.modal-body");
let q = encodeURI($("#search-term").val()); let q = encodeURI($("#search-term").val());
modalBody.load("{% url "haystack_search" %}?q=" + q + " #search-results"); modalBody.load("{% url "haystack_search" %}?q=" + q + " #search-results");
}); });
{% endif %}
{% if not user.is_authenticated %}
$('a[data-target="#loginModal"]').click(function() { $('a[data-target="#loginModal"]').click(function() {
let modalBody = $("#loginModal div.modal-body"); let modalBody = $("#loginModal div.modal-body");
if (!modalBody.html().trim()) if (!modalBody.html().trim())
modalBody.load("{% url "login" %} #form-content") modalBody.load("{% url "login" %} #form-content")
}); });
{% endif %}
{% if user.is_authenticated and user.registration.participates and not user.registration.team %}
$('a[data-target="#createTeamModal"]').click(function() { $('a[data-target="#createTeamModal"]').click(function() {
let modalBody = $("#createTeamModal div.modal-body"); let modalBody = $("#createTeamModal div.modal-body");
if (!modalBody.html().trim()) if (!modalBody.html().trim())
@ -263,6 +296,7 @@
if (!modalBody.html().trim()) if (!modalBody.html().trim())
modalBody.load("{% url "participation:join_team" %} #form-content"); modalBody.load("{% url "participation:join_team" %} #form-content");
}); });
{% endif %}
}); });
</script> </script>

View File

@ -27,6 +27,11 @@
</div> </div>
</div> </div>
<div class="alert alert-danger">
Avertissement : certains rencontrent des difficultés à recevoir les mails automatiques. Merci de vérifier
régulièrement votre boîte spam, et à nous contacter en cas de problème.
</div>
<div class="jumbotron"> <div class="jumbotron">
<h5 class="display-4">Comment ça marche ?</h5> <h5 class="display-4">Comment ça marche ?</h5>
<p> <p>

27
corres2math/tests.py Normal file
View File

@ -0,0 +1,27 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from django.core.handlers.asgi import ASGIHandler
from django.core.handlers.wsgi import WSGIHandler
from django.test import TestCase
class TestLoadModules(TestCase):
"""
Load modules that are not used in development mode in order to increase coverage.
"""
def test_asgi(self):
from corres2math import asgi
self.assertTrue(isinstance(asgi.application, ASGIHandler))
def test_wsgi(self):
from corres2math import wsgi
self.assertTrue(isinstance(wsgi.application, WSGIHandler))
def test_load_production_settings(self):
os.putenv("CORRES2MATH_STAGE", "prod")
os.putenv("DJANGO_DB_TYPE", "postgres")
from corres2math import settings_prod
self.assertFalse(settings_prod.DEBUG)

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.contrib.auth.tokens import PasswordResetTokenGenerator

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
"""corres2math URL Configuration """corres2math URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see: The `urlpatterns` list routes URLs to views. For more information please see:
@ -13,16 +16,18 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import include, path
from django.views.defaults import bad_request, permission_denied, page_not_found, 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 registration.views import PhotoAuthorizationView from registration.views import PhotoAuthorizationView
from .views import AdminSearchView from .views import AdminSearchView
urlpatterns = [ urlpatterns = [
path('', TemplateView.as_view(template_name="index.html", extra_context=dict(title="Accueil")), name='index'), path('', TemplateView.as_view(template_name="index.html"), name='index'),
path('about/', TemplateView.as_view(template_name="about.html"), name='about'),
path('i18n/', include('django.conf.urls.i18n')), path('i18n/', include('django.conf.urls.i18n')),
path('admin/doc/', include('django.contrib.admindocs.urls')), path('admin/doc/', include('django.contrib.admindocs.urls')),
path('admin/', admin.site.urls, name="admin"), path('admin/', admin.site.urls, name="admin"),
@ -35,11 +40,14 @@ urlpatterns = [
path('media/authorization/photo/<str:filename>/', PhotoAuthorizationView.as_view(), name='photo_authorization'), path('media/authorization/photo/<str:filename>/', PhotoAuthorizationView.as_view(), name='photo_authorization'),
path('cas/', include('cas_server.urls', namespace="cas_server")),
path('', include('eastereggs.urls')), path('', include('eastereggs.urls')),
] ]
if 'cas_server' in settings.INSTALLED_APPS: # pragma: no cover
urlpatterns += [
path('cas/', include('cas_server.urls', namespace="cas_server")),
]
handler400 = bad_request handler400 = bad_request
handler403 = permission_denied handler403 = permission_denied
handler404 = page_not_found handler404 = page_not_found

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from haystack.generic_views import SearchView from haystack.generic_views import SearchView

View File

@ -1,3 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
""" """
WSGI config for corres2math project. WSGI config for corres2math project.

View File

@ -2,10 +2,13 @@
crond -l 0 crond -l 0
python manage.py migrate
python manage.py loaddata initial
nginx nginx
if [ "$CORRES2MATH_STAGE" = "prod" ]; then if [ "$CORRES2MATH_STAGE" = "prod" ]; then
gunicorn -b 0.0.0.0:8000 --workers=2 --threads=4 --worker-class=gthread corres2math.wsgi --access-logfile '-' --error-logfile '-'; gunicorn -b 0.0.0.0:8000 --workers=2 --threads=4 --worker-class=gthread corres2math.wsgi --access-logfile '-' --error-logfile '-';
else else
./manage.py runserver 0.0.0.0:8000; python manage.py runserver 0.0.0.0:8000;
fi fi

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Corres2math\n" "Project-Id-Version: Corres2math\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-11-02 10:56+0100\n" "POT-Creation-Date: 2020-12-22 21:30+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Yohann D'ANELLO <yohann.danello@animath.fr>\n" "Last-Translator: Yohann D'ANELLO <yohann.danello@animath.fr>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -30,7 +30,7 @@ msgid "This task failed successfully."
msgstr "Cette tâche a échoué avec succès." msgstr "Cette tâche a échoué avec succès."
#: apps/eastereggs/templates/eastereggs/xp_modal.html:16 #: apps/eastereggs/templates/eastereggs/xp_modal.html:16
#: templates/base_modal.html:19 #: corres2math/templates/base_modal.html:19
msgid "Close" msgid "Close"
msgstr "Fermer" msgstr "Fermer"
@ -99,7 +99,17 @@ msgstr "changelogs"
msgid "Changelog of type \"{action}\" for model {model} at {timestamp}" msgid "Changelog of type \"{action}\" for model {model} at {timestamp}"
msgstr "Changelog de type \"{action}\" pour le modèle {model} le {timestamp}" msgstr "Changelog de type \"{action}\" pour le modèle {model} le {timestamp}"
#: apps/participation/forms.py:20 apps/participation/models.py:33 #: apps/participation/admin.py:16 apps/participation/models.py:132
#: apps/participation/tables.py:35 apps/participation/tables.py:62
msgid "problem number"
msgstr "numéro de problème"
#: apps/participation/admin.py:21 apps/participation/models.py:138
#: apps/participation/models.py:192
msgid "valid"
msgstr "valide"
#: apps/participation/forms.py:20 apps/participation/models.py:32
msgid "The trigram must be composed of three uppercase letters." msgid "The trigram must be composed of three uppercase letters."
msgstr "Le trigramme doit être composé de trois lettres majuscules." msgstr "Le trigramme doit être composé de trois lettres majuscules."
@ -123,44 +133,50 @@ msgstr "Vous ne pouvez pas envoyer de vidéo après la date limite."
msgid "Send to team" msgid "Send to team"
msgstr "Envoyer à l'équipe" msgstr "Envoyer à l'équipe"
#: apps/participation/forms.py:152 #: apps/participation/forms.py:156
msgid "How did you get the idea to ...?" msgid "How did you get the idea to ...?"
msgstr "Comment avez-vous eu l'idée de ... ?" msgstr "Comment avez-vous eu l'idée de ... ?"
#: apps/participation/forms.py:175 #: apps/participation/forms.py:160
msgid "You can only create or update a question during the second phase."
msgstr ""
"Vous pouvez créer ou modifier une question seulement pendant la seconde "
"phase."
#: apps/participation/forms.py:186
msgid "Start date must be before the end date." msgid "Start date must be before the end date."
msgstr "La date de début doit être avant la date de fin." msgstr "La date de début doit être avant la date de fin."
#: apps/participation/forms.py:177 #: apps/participation/forms.py:188
msgid "This phase must start after the previous phases." msgid "This phase must start after the previous phases."
msgstr "Cette phase doit commencer après les phases précédentes." msgstr "Cette phase doit commencer après les phases précédentes."
#: apps/participation/forms.py:179 #: apps/participation/forms.py:190
msgid "This phase must end after the next phases." msgid "This phase must end after the next phases."
msgstr "Cette phase doit finir avant les phases suivantes." msgstr "Cette phase doit finir avant les phases suivantes."
#: apps/participation/models.py:26 apps/participation/tables.py:30 #: apps/participation/models.py:25 apps/participation/tables.py:30
#: apps/participation/tables.py:52 apps/participation/tables.py:78 #: apps/participation/tables.py:52 apps/participation/tables.py:78
msgid "name" msgid "name"
msgstr "nom" msgstr "nom"
#: apps/participation/models.py:32 apps/participation/tables.py:57 #: apps/participation/models.py:31 apps/participation/tables.py:57
msgid "trigram" msgid "trigram"
msgstr "trigramme" msgstr "trigramme"
#: apps/participation/models.py:40 #: apps/participation/models.py:39
msgid "access code" msgid "access code"
msgstr "code d'accès" msgstr "code d'accès"
#: apps/participation/models.py:41 #: apps/participation/models.py:40
msgid "The access code let other people to join the team." msgid "The access code let other people to join the team."
msgstr "Le code d'accès permet aux autres participants de rejoindre l'équipe." msgstr "Le code d'accès permet aux autres participants de rejoindre l'équipe."
#: apps/participation/models.py:45 #: apps/participation/models.py:44
msgid "Grant Animath to publish my video" msgid "Grant Animath to publish my video"
msgstr "Autoriser Animath à publier ma vidéo" msgstr "Autoriser Animath à publier ma vidéo"
#: apps/participation/models.py:46 #: apps/participation/models.py:45
msgid "" msgid ""
"Give the authorisation to publish the video on the main website to promote " "Give the authorisation to publish the video on the main website to promote "
"the action." "the action."
@ -168,105 +184,96 @@ msgstr ""
"Donner l'autorisation de publier la vidéo sur le site principal pour " "Donner l'autorisation de publier la vidéo sur le site principal pour "
"promouvoir les Correspondances." "promouvoir les Correspondances."
#: apps/participation/models.py:97 #: apps/participation/models.py:107
#, python-brace-format #, python-brace-format
msgid "Team {name} ({trigram})" msgid "Team {name} ({trigram})"
msgstr "Équipe {name} ({trigram})" msgstr "Équipe {name} ({trigram})"
#: apps/participation/models.py:100 apps/participation/models.py:115 #: apps/participation/models.py:110 apps/participation/models.py:125
#: apps/registration/models.py:106 apps/registration/models.py:155 #: apps/registration/models.py:106 apps/registration/models.py:155
msgid "team" msgid "team"
msgstr "équipe" msgstr "équipe"
#: apps/participation/models.py:101 #: apps/participation/models.py:111
msgid "teams" msgid "teams"
msgstr "équipes" msgstr "équipes"
#: apps/participation/models.py:119 #: apps/participation/models.py:129
#, python-brace-format #, python-brace-format
msgid "Problem #{problem:d}" msgid "Problem #{problem:d}"
msgstr "Problème n°{problem:d}" msgstr "Problème n°{problem:d}"
#: apps/participation/models.py:122 apps/participation/tables.py:35 #: apps/participation/models.py:139 apps/participation/models.py:193
#: apps/participation/tables.py:62
msgid "problem number"
msgstr "numéro de problème"
#: apps/participation/models.py:128 apps/participation/models.py:182
msgid "valid"
msgstr "valide"
#: apps/participation/models.py:129 apps/participation/models.py:183
msgid "The video got the validation of the administrators." msgid "The video got the validation of the administrators."
msgstr "La vidéo a été validée par les administrateurs." msgstr "La vidéo a été validée par les administrateurs."
#: apps/participation/models.py:138 #: apps/participation/models.py:148
msgid "solution video" msgid "solution video"
msgstr "vidéo de solution" msgstr "vidéo de solution"
#: apps/participation/models.py:147 #: apps/participation/models.py:157
msgid "received participation" msgid "received participation"
msgstr "participation reçue" msgstr "participation reçue"
#: apps/participation/models.py:156 #: apps/participation/models.py:166
msgid "synthesis video" msgid "synthesis video"
msgstr "vidéo de synthèse" msgstr "vidéo de synthèse"
#: apps/participation/models.py:163 #: apps/participation/models.py:173
#, python-brace-format #, python-brace-format
msgid "Participation of the team {name} ({trigram})" msgid "Participation of the team {name} ({trigram})"
msgstr "Participation de l'équipe {name} ({trigram})" msgstr "Participation de l'équipe {name} ({trigram})"
#: apps/participation/models.py:166 apps/participation/models.py:240 #: apps/participation/models.py:176 apps/participation/models.py:250
msgid "participation" msgid "participation"
msgstr "participation" msgstr "participation"
#: apps/participation/models.py:167 #: apps/participation/models.py:177
msgid "participations" msgid "participations"
msgstr "participations" msgstr "participations"
#: apps/participation/models.py:175 #: apps/participation/models.py:185
msgid "link" msgid "link"
msgstr "lien" msgstr "lien"
#: apps/participation/models.py:176 #: apps/participation/models.py:186
msgid "The full video link." msgid "The full video link."
msgstr "Le lien complet de la vidéo." msgstr "Le lien complet de la vidéo."
#: apps/participation/models.py:225 #: apps/participation/models.py:235
#, python-brace-format #, python-brace-format
msgid "Video of team {name} ({trigram})" msgid "Video of team {name} ({trigram})"
msgstr "Vidéo de l'équipe {name} ({trigram})" msgstr "Vidéo de l'équipe {name} ({trigram})"
#: apps/participation/models.py:229 #: apps/participation/models.py:239
msgid "video" msgid "video"
msgstr "vidéo" msgstr "vidéo"
#: apps/participation/models.py:230 #: apps/participation/models.py:240
msgid "videos" msgid "videos"
msgstr "vidéos" msgstr "vidéos"
#: apps/participation/models.py:245 #: apps/participation/models.py:255
msgid "question" msgid "question"
msgstr "question" msgstr "question"
#: apps/participation/models.py:259 #: apps/participation/models.py:269
msgid "phase number" msgid "phase number"
msgstr "phase" msgstr "phase"
#: apps/participation/models.py:264 #: apps/participation/models.py:274
msgid "phase description" msgid "phase description"
msgstr "description" msgstr "description"
#: apps/participation/models.py:268 #: apps/participation/models.py:278
msgid "start date of the given phase" msgid "start date of the given phase"
msgstr "début de la phase" msgstr "début de la phase"
#: apps/participation/models.py:273 #: apps/participation/models.py:283
msgid "end date of the given phase" msgid "end date of the given phase"
msgstr "fin de la phase" msgstr "fin de la phase"
#: apps/participation/models.py:291 #: apps/participation/models.py:299
msgid "" msgid ""
"Phase {phase_number:d} starts on {start:%Y-%m-%d %H:%M} and ends on {end:%Y-" "Phase {phase_number:d} starts on {start:%Y-%m-%d %H:%M} and ends on {end:%Y-"
"%m-%d %H:%M}" "%m-%d %H:%M}"
@ -274,21 +281,55 @@ msgstr ""
"Phase {phase_number:d} démarrant le {start:%d/%m/%Y %H:%M} et finissant le " "Phase {phase_number:d} démarrant le {start:%d/%m/%Y %H:%M} et finissant le "
"{end:%d/%m/%Y %H:%M}" "{end:%d/%m/%Y %H:%M}"
#: apps/participation/models.py:295 #: apps/participation/models.py:303
msgid "phase" msgid "phase"
msgstr "phase" msgstr "phase"
#: apps/participation/models.py:296 #: apps/participation/models.py:304
msgid "phases" msgid "phases"
msgstr "phases" msgstr "phases"
#: apps/participation/templates/participation/chat.html:7
msgid "The chat is located on the dedicated Matrix server:"
msgstr "Le chat est situé sur le serveur Matrix dédié aux Correspondances :"
#: apps/participation/templates/participation/chat.html:14
msgid "Access to the Matrix server"
msgstr "Accéder au serveur Matrix"
#: apps/participation/templates/participation/chat.html:20
msgid ""
"To connect to the server, you can select \"Log in\", then use your "
"credentials of this platform to connect with the central authentication "
"server, then you must trust the connection between the Matrix account and "
"the platform. Finally, you will be able to access to the chat platform."
msgstr ""
"Pour se connecter au serveur, vous pouvez sélectionner \"Se connecter\", "
"puis utiliser vos identifiants de cette plateforme via l'authentication "
"centralisée. Vous devrez ensuite approuver la connexion entre le compte "
"Matrix et la plateforme. Enfin, vous pourrez accéder à la plateforme de chat."
#: apps/participation/templates/participation/chat.html:28
msgid ""
"You will be invited in some basic rooms. You must confirm the invitations to "
"join channels."
msgstr ""
"Vous serez invités dans quelques salons basiques. Vous devez confirmer les "
"invitations pour rejoindre les canaux."
#: apps/participation/templates/participation/chat.html:34
msgid "If you have any trouble, don't hesitate to contact us :)"
msgstr ""
"Si vous rencontrez le moindre problème, n'hésitez surtout pas à nous "
"contacter :)"
#: apps/participation/templates/participation/create_team.html:11 #: apps/participation/templates/participation/create_team.html:11
#: templates/base.html:231 #: corres2math/templates/base.html:247
msgid "Create" msgid "Create"
msgstr "Créer" msgstr "Créer"
#: apps/participation/templates/participation/join_team.html:11 #: apps/participation/templates/participation/join_team.html:11
#: templates/base.html:227 #: corres2math/templates/base.html:242
msgid "Join" msgid "Join"
msgstr "Rejoindre" msgstr "Rejoindre"
@ -462,7 +503,7 @@ msgstr "Définir l'équipe qui recevra votre vidéo"
#: apps/participation/templates/participation/participation_detail.html:181 #: apps/participation/templates/participation/participation_detail.html:181
#: apps/participation/templates/participation/participation_detail.html:233 #: apps/participation/templates/participation/participation_detail.html:233
#: apps/participation/views.py:463 #: apps/participation/views.py:510
msgid "Upload video" msgid "Upload video"
msgstr "Envoyer la vidéo" msgstr "Envoyer la vidéo"
@ -497,7 +538,7 @@ msgid "Update question"
msgstr "Modifier la question" msgstr "Modifier la question"
#: apps/participation/templates/participation/participation_detail.html:217 #: apps/participation/templates/participation/participation_detail.html:217
#: apps/participation/views.py:440 #: apps/participation/views.py:486
msgid "Delete question" msgid "Delete question"
msgstr "Supprimer la question" msgstr "Supprimer la question"
@ -507,8 +548,8 @@ msgid "Display synthesis"
msgstr "Afficher la synthèse" msgstr "Afficher la synthèse"
#: apps/participation/templates/participation/phase_list.html:10 #: apps/participation/templates/participation/phase_list.html:10
#: apps/participation/views.py:482 templates/base.html:68 #: apps/participation/views.py:531 corres2math/templates/base.html:68
#: templates/base.html:70 templates/base.html:217 #: corres2math/templates/base.html:70 corres2math/templates/base.html:231
msgid "Calendar" msgid "Calendar"
msgstr "Calendrier" msgstr "Calendrier"
@ -620,7 +661,7 @@ msgid "Update team"
msgstr "Modifier l'équipe" msgstr "Modifier l'équipe"
#: apps/participation/templates/participation/team_detail.html:127 #: apps/participation/templates/participation/team_detail.html:127
#: apps/participation/views.py:296 #: apps/participation/views.py:337
msgid "Leave team" msgid "Leave team"
msgstr "Quitter l'équipe" msgstr "Quitter l'équipe"
@ -628,93 +669,107 @@ msgstr "Quitter l'équipe"
msgid "Are you sure that you want to leave this team?" msgid "Are you sure that you want to leave this team?"
msgstr "Êtes-vous sûr·e de vouloir quitter cette équipe ?" msgstr "Êtes-vous sûr·e de vouloir quitter cette équipe ?"
#: apps/participation/views.py:36 templates/base.html:77 #: apps/participation/templates/participation/team_list.html:6
#: templates/base.html:230 #: corres2math/templates/base.html:235
msgid "All teams"
msgstr "Toutes les équipes"
#: apps/participation/views.py:37 corres2math/templates/base.html:84
#: corres2math/templates/base.html:246
msgid "Create team" msgid "Create team"
msgstr "Créer une équipe" msgstr "Créer une équipe"
#: apps/participation/views.py:43 apps/participation/views.py:89 #: apps/participation/views.py:46 apps/participation/views.py:94
msgid "You don't participate, so you can't create a team." msgid "You don't participate, so you can't create a team."
msgstr "Vous ne participez pas, vous ne pouvez pas créer d'équipe." msgstr "Vous ne participez pas, vous ne pouvez pas créer d'équipe."
#: apps/participation/views.py:45 apps/participation/views.py:91 #: apps/participation/views.py:48 apps/participation/views.py:96
msgid "You are already in a team." msgid "You are already in a team."
msgstr "Vous êtes déjà dans une équipe." msgstr "Vous êtes déjà dans une équipe."
#: apps/participation/views.py:82 templates/base.html:82 #: apps/participation/views.py:85 corres2math/templates/base.html:89
#: templates/base.html:226 #: corres2math/templates/base.html:241
msgid "Join team" msgid "Join team"
msgstr "Rejoindre une équipe" msgstr "Rejoindre une équipe"
#: apps/participation/views.py:133 apps/participation/views.py:302 #: apps/participation/views.py:147 apps/participation/views.py:343
#: apps/participation/views.py:335 #: apps/participation/views.py:376
msgid "You are not in a team." msgid "You are not in a team."
msgstr "Vous n'êtes pas dans une équipe." msgstr "Vous n'êtes pas dans une équipe."
#: apps/participation/views.py:134 apps/participation/views.py:336 #: apps/participation/views.py:148 apps/participation/views.py:377
msgid "You don't participate, so you don't have any team." msgid "You don't participate, so you don't have any team."
msgstr "Vous ne participez pas, vous n'avez donc pas d'équipe." msgstr "Vous ne participez pas, vous n'avez donc pas d'équipe."
#: apps/participation/views.py:155 #: apps/participation/views.py:170
#, python-brace-format #, python-brace-format
msgid "Detail of team {trigram}" msgid "Detail of team {trigram}"
msgstr "Détails de l'équipe {trigram}" msgstr "Détails de l'équipe {trigram}"
#: apps/participation/views.py:180 #: apps/participation/views.py:202
msgid "You don't participate, so you can't request the validation of the team." msgid "You don't participate, so you can't request the validation of the team."
msgstr "" msgstr ""
"Vous ne participez pas, vous ne pouvez pas demander la validation de " "Vous ne participez pas, vous ne pouvez pas demander la validation de "
"l'équipe." "l'équipe."
#: apps/participation/views.py:183 #: apps/participation/views.py:205
msgid "The validation of the team is already done or pending." msgid "The validation of the team is already done or pending."
msgstr "La validation de l'équipe est déjà faite ou en cours." msgstr "La validation de l'équipe est déjà faite ou en cours."
#: apps/participation/views.py:196 #: apps/participation/views.py:208
msgid ""
"The team can't be validated: missing email address confirmations, photo "
"authorizations, people or the chosen problem is not set."
msgstr ""
"L'équipe ne peut pas être validée : il manque soit les confirmations "
"d'adresse e-mail, soit une autorisation parentale, soit des personnes soit "
"le problème n'a pas été choisi."
#: apps/participation/views.py:227
msgid "You are not an administrator." msgid "You are not an administrator."
msgstr "Vous n'êtes pas administrateur." msgstr "Vous n'êtes pas administrateur."
#: apps/participation/views.py:199 #: apps/participation/views.py:230
msgid "This team has no pending validation." msgid "This team has no pending validation."
msgstr "L'équipe n'a pas de validation en attente." msgstr "L'équipe n'a pas de validation en attente."
#: apps/participation/views.py:218 #: apps/participation/views.py:254
msgid "You must specify if you validate the registration or not." msgid "You must specify if you validate the registration or not."
msgstr "Vous devez spécifier si vous validez l'inscription ou non." msgstr "Vous devez spécifier si vous validez l'inscription ou non."
#: apps/participation/views.py:245 #: apps/participation/views.py:284
#, python-brace-format #, python-brace-format
msgid "Update team {trigram}" msgid "Update team {trigram}"
msgstr "Mise à jour de l'équipe {trigram}" msgstr "Mise à jour de l'équipe {trigram}"
#: apps/participation/views.py:282 apps/registration/views.py:243 #: apps/participation/views.py:323 apps/registration/views.py:283
#, python-brace-format #, python-brace-format
msgid "Photo authorization of {student}.{ext}" msgid "Photo authorization of {student}.{ext}"
msgstr "Autorisation de droit à l'image de {student}.{ext}" msgstr "Autorisation de droit à l'image de {student}.{ext}"
#: apps/participation/views.py:286 #: apps/participation/views.py:327
#, python-brace-format #, python-brace-format
msgid "Photo authorizations of team {trigram}.zip" msgid "Photo authorizations of team {trigram}.zip"
msgstr "Autorisations de droit à l'image de l'équipe {trigram}.zip" msgstr "Autorisations de droit à l'image de l'équipe {trigram}.zip"
#: apps/participation/views.py:304 #: apps/participation/views.py:345
msgid "The team is already validated or the validation is pending." msgid "The team is already validated or the validation is pending."
msgstr "La validation de l'équipe est déjà faite ou en cours." msgstr "La validation de l'équipe est déjà faite ou en cours."
#: apps/participation/views.py:348 #: apps/participation/views.py:391
msgid "The team is not validated yet." msgid "The team is not validated yet."
msgstr "L'équipe n'est pas encore validée." msgstr "L'équipe n'est pas encore validée."
#: apps/participation/views.py:357 #: apps/participation/views.py:401
#, python-brace-format #, python-brace-format
msgid "Participation of team {trigram}" msgid "Participation of team {trigram}"
msgstr "Participation de l'équipe {trigram}" msgstr "Participation de l'équipe {trigram}"
#: apps/participation/views.py:394 #: apps/participation/views.py:438
msgid "Create question" msgid "Create question"
msgstr "Créer une question" msgstr "Créer une question"
#: apps/participation/views.py:491 #: apps/participation/views.py:540
msgid "Calendar update" msgid "Calendar update"
msgstr "Mise à jour du calendrier" msgstr "Mise à jour du calendrier"
@ -730,7 +785,15 @@ msgstr "participant"
msgid "coach" msgid "coach"
msgstr "encadrant" msgstr "encadrant"
#: apps/registration/forms.py:68 #: apps/registration/forms.py:31
msgid "This email address is already used."
msgstr "Cette adresse e-mail est déjà utilisée."
#: apps/registration/forms.py:78
msgid "The uploaded file size must be under 2 Mo."
msgstr "Le fichier envoyé doit peser moins de 2 Mo."
#: apps/registration/forms.py:80
msgid "The uploaded file must be a PDF, PNG of JPEG file." msgid "The uploaded file must be a PDF, PNG of JPEG file."
msgstr "Le fichier envoyé doit être au format PDF, PNG ou JPEG." msgstr "Le fichier envoyé doit être au format PDF, PNG ou JPEG."
@ -832,13 +895,21 @@ msgstr "Votre email a été validé avec succès."
msgid "You can now <a href=\"%(login_url)s\">log in</a>." msgid "You can now <a href=\"%(login_url)s\">log in</a>."
msgstr "Vous pouvez désormais vous <a href=\"%(login_url)s\">connecter</a>." msgstr "Vous pouvez désormais vous <a href=\"%(login_url)s\">connecter</a>."
#: apps/registration/templates/registration/email_validation_complete.html:22 #: apps/registration/templates/registration/email_validation_complete.html:23
msgid "" msgid ""
"The link was invalid. The token may have expired. Please send us an email to " "The link was invalid. The token may have expired, or your account is already "
"activate your account." "activated. However, your account seems to be already valid."
msgstr "" msgstr ""
"Le lien est invalide. Le jeton a peut-être expiré. Merci de nous envoyer un " "Le lien est invalide. Le jeton a peut-être expiré, ou votre compte est déjà "
"mail pour activer votre compte." "activé. Toutefois, il semble que votre compte est déjà valide."
#: apps/registration/templates/registration/email_validation_complete.html:25
msgid ""
"The link was invalid. The token may have expired, or your account is already "
"activated. Please send us an email to activate your account."
msgstr ""
"Le lien est invalide. Le jeton a peut-être expiré, ou votre compte est déjà "
"activé. Merci de nous envoyer un mail pour activer votre compte."
#: apps/registration/templates/registration/email_validation_email_sent.html:10 #: apps/registration/templates/registration/email_validation_email_sent.html:10
msgid "Account activation" msgid "Account activation"
@ -907,9 +978,11 @@ msgid "Your password has been set. You may go ahead and log in now."
msgstr "Votre mot de passe a été changé. Vous pouvez désormais vous connecter." msgstr "Votre mot de passe a été changé. Vous pouvez désormais vous connecter."
#: apps/registration/templates/registration/password_reset_complete.html:10 #: apps/registration/templates/registration/password_reset_complete.html:10
#: templates/base.html:130 templates/base.html:221 templates/base.html:222 #: corres2math/templates/base.html:139 corres2math/templates/base.html:251
#: templates/registration/login.html:7 templates/registration/login.html:8 #: corres2math/templates/base.html:252
#: templates/registration/login.html:25 #: corres2math/templates/registration/login.html:7
#: corres2math/templates/registration/login.html:8
#: corres2math/templates/registration/login.html:25
msgid "Log in" msgid "Log in"
msgstr "Connexion" msgstr "Connexion"
@ -963,7 +1036,7 @@ msgstr "Réinitialiser mon mot de passe"
#: apps/registration/templates/registration/signup.html:5 #: apps/registration/templates/registration/signup.html:5
#: apps/registration/templates/registration/signup.html:8 #: apps/registration/templates/registration/signup.html:8
#: apps/registration/templates/registration/signup.html:20 #: apps/registration/templates/registration/signup.html:20
#: apps/registration/views.py:28 #: apps/registration/views.py:32
msgid "Sign up" msgid "Sign up"
msgstr "Inscription" msgstr "Inscription"
@ -1040,53 +1113,57 @@ msgid "Update user"
msgstr "Modifier l'utilisateur" msgstr "Modifier l'utilisateur"
#: apps/registration/templates/registration/user_detail.html:77 #: apps/registration/templates/registration/user_detail.html:77
#: apps/registration/views.py:206 #: apps/registration/views.py:246
msgid "Upload photo authorization" msgid "Upload photo authorization"
msgstr "Téléverser l'autorisation de droit à l'image" msgstr "Téléverser l'autorisation de droit à l'image"
#: apps/registration/views.py:64 #: apps/registration/views.py:40
msgid "You can't register now."
msgstr "Vous ne pouvez pas vous inscrire maintenant."
#: apps/registration/views.py:84
msgid "Email validation" msgid "Email validation"
msgstr "Validation de l'adresse mail" msgstr "Validation de l'adresse mail"
#: apps/registration/views.py:66 #: apps/registration/views.py:86
msgid "Validate email" msgid "Validate email"
msgstr "Valider l'adresse mail" msgstr "Valider l'adresse mail"
#: apps/registration/views.py:105 #: apps/registration/views.py:125
msgid "Email validation unsuccessful" msgid "Email validation unsuccessful"
msgstr "Échec de la validation de l'adresse mail" msgstr "Échec de la validation de l'adresse mail"
#: apps/registration/views.py:116 #: apps/registration/views.py:136
msgid "Email validation email sent" msgid "Email validation email sent"
msgstr "Mail de confirmation de l'adresse mail envoyé" msgstr "Mail de confirmation de l'adresse mail envoyé"
#: apps/registration/views.py:124 #: apps/registration/views.py:144
msgid "Resend email validation link" msgid "Resend email validation link"
msgstr "Renvoyé le lien de validation de l'adresse mail" msgstr "Renvoyé le lien de validation de l'adresse mail"
#: apps/registration/views.py:158 #: apps/registration/views.py:180
#, python-brace-format #, python-brace-format
msgid "Detail of user {user}" msgid "Detail of user {user}"
msgstr "Détails de l'utilisateur {user}" msgstr "Détails de l'utilisateur {user}"
#: apps/registration/views.py:179 #: apps/registration/views.py:210
#, python-brace-format #, python-brace-format
msgid "Update user {user}" msgid "Update user {user}"
msgstr "Mise à jour de l'utilisateur {user}" msgstr "Mise à jour de l'utilisateur {user}"
#: corres2math/settings.py:154 #: corres2math/settings.py:157
msgid "English" msgid "English"
msgstr "Anglais" msgstr "Anglais"
#: corres2math/settings.py:155 #: corres2math/settings.py:158
msgid "French" msgid "French"
msgstr "Français" msgstr "Français"
#: templates/400.html:6 #: corres2math/templates/400.html:6
msgid "Bad request" msgid "Bad request"
msgstr "Requête invalide" msgstr "Requête invalide"
#: templates/400.html:7 #: corres2math/templates/400.html:7
msgid "" msgid ""
"Sorry, your request was bad. Don't know what could be wrong. An email has " "Sorry, your request was bad. Don't know what could be wrong. An email has "
"been sent to webmasters with the details of the error. You can now watch " "been sent to webmasters with the details of the error. You can now watch "
@ -1096,23 +1173,23 @@ msgstr ""
"email a été envoyé aux administrateurs avec les détails de l'erreur. Vous " "email a été envoyé aux administrateurs avec les détails de l'erreur. Vous "
"pouvez désormais retourner voir des vidéos." "pouvez désormais retourner voir des vidéos."
#: templates/403.html:6 #: corres2math/templates/403.html:6
msgid "Permission denied" msgid "Permission denied"
msgstr "Permission refusée" msgstr "Permission refusée"
#: templates/403.html:7 #: corres2math/templates/403.html:7
msgid "You don't have the right to perform this request." msgid "You don't have the right to perform this request."
msgstr "Vous n'avez pas le droit d'effectuer cette requête." msgstr "Vous n'avez pas le droit d'effectuer cette requête."
#: templates/403.html:10 templates/404.html:10 #: corres2math/templates/403.html:10 corres2math/templates/404.html:10
msgid "Exception message:" msgid "Exception message:"
msgstr "Message d'erreur :" msgstr "Message d'erreur :"
#: templates/404.html:6 #: corres2math/templates/404.html:6
msgid "Page not found" msgid "Page not found"
msgstr "Page non trouvée" msgstr "Page non trouvée"
#: templates/404.html:7 #: corres2math/templates/404.html:7
#, python-format #, python-format
msgid "" msgid ""
"The requested path <code>%(request_path)s</code> was not found on the server." "The requested path <code>%(request_path)s</code> was not found on the server."
@ -1120,11 +1197,11 @@ msgstr ""
"Le chemin demandé <code>%(request_path)s</code> n'a pas été trouvé sur le " "Le chemin demandé <code>%(request_path)s</code> n'a pas été trouvé sur le "
"serveur." "serveur."
#: templates/500.html:6 #: corres2math/templates/500.html:6
msgid "Server error" msgid "Server error"
msgstr "Erreur du serveur" msgstr "Erreur du serveur"
#: templates/500.html:7 #: corres2math/templates/500.html:7
msgid "" msgid ""
"Sorry, an error occurred when processing your request. An email has been " "Sorry, an error occurred when processing your request. An email has been "
"sent to webmasters with the detail of the error, and this will be fixed " "sent to webmasters with the detail of the error, and this will be fixed "
@ -1135,47 +1212,55 @@ msgstr ""
"avec les détails de l'erreur. Vous pouvez désormais retourner voir des " "avec les détails de l'erreur. Vous pouvez désormais retourner voir des "
"vidéos." "vidéos."
#: templates/base.html:64 #: corres2math/templates/base.html:64
msgid "Home" msgid "Home"
msgstr "Accueil" msgstr "Accueil"
#: templates/base.html:88 #: corres2math/templates/base.html:75
msgid "Users"
msgstr "Utilisateurs"
#: corres2math/templates/base.html:78
msgid "Teams"
msgstr "Équipes"
#: corres2math/templates/base.html:95
msgid "My team" msgid "My team"
msgstr "Mon équipe" msgstr "Mon équipe"
#: templates/base.html:93 #: corres2math/templates/base.html:100
msgid "My participation" msgid "My participation"
msgstr "Ma participation" msgstr "Ma participation"
#: templates/base.html:100 #: corres2math/templates/base.html:107
msgid "Chat" msgid "Chat"
msgstr "Chat" msgstr "Chat"
#: templates/base.html:104 #: corres2math/templates/base.html:111
msgid "Administration" msgid "Administration"
msgstr "Administration" msgstr "Administration"
#: templates/base.html:112 #: corres2math/templates/base.html:119
msgid "Search..." msgid "Search..."
msgstr "Chercher ..." msgstr "Chercher ..."
#: templates/base.html:121 #: corres2math/templates/base.html:128
msgid "Return to admin view" msgid "Return to admin view"
msgstr "Retourner à l'interface administrateur" msgstr "Retourner à l'interface administrateur"
#: templates/base.html:126 #: corres2math/templates/base.html:134
msgid "Register" msgid "Register"
msgstr "S'inscrire" msgstr "S'inscrire"
#: templates/base.html:142 #: corres2math/templates/base.html:151
msgid "My account" msgid "My account"
msgstr "Mon compte" msgstr "Mon compte"
#: templates/base.html:145 #: corres2math/templates/base.html:154
msgid "Log out" msgid "Log out"
msgstr "Déconnexion" msgstr "Déconnexion"
#: templates/base.html:162 #: corres2math/templates/base.html:171
#, python-format #, python-format
msgid "" msgid ""
"Your email address is not validated. Please click on the link you received " "Your email address is not validated. Please click on the link you received "
@ -1186,23 +1271,27 @@ msgstr ""
"avez reçu par mail. Vous pouvez renvoyer un mail en cliquant sur <a href=" "avez reçu par mail. Vous pouvez renvoyer un mail en cliquant sur <a href="
"\"%(send_email_url)s\">ce lien</a>." "\"%(send_email_url)s\">ce lien</a>."
#: templates/base.html:186 #: corres2math/templates/base.html:195
msgid "Contact us" msgid "Contact us"
msgstr "Nous contacter" msgstr "Nous contacter"
#: templates/base.html:219 #: corres2math/templates/base.html:214
msgid "About"
msgstr "À propos"
#: corres2math/templates/base.html:238
msgid "Search results" msgid "Search results"
msgstr "Résultats de la recherche" msgstr "Résultats de la recherche"
#: templates/registration/logged_out.html:8 #: corres2math/templates/registration/logged_out.html:8
msgid "Thanks for spending some quality time with the Web site today." msgid "Thanks for spending some quality time with the Web site today."
msgstr "Merci d'avoir utilisé la plateforme des Correspondances." msgstr "Merci d'avoir utilisé la plateforme des Correspondances."
#: templates/registration/logged_out.html:9 #: corres2math/templates/registration/logged_out.html:9
msgid "Log in again" msgid "Log in again"
msgstr "Se reconnecter" msgstr "Se reconnecter"
#: templates/registration/login.html:13 #: corres2math/templates/registration/login.html:13
#, python-format #, python-format
msgid "" msgid ""
"You are authenticated as %(user)s, but are not authorized to access this " "You are authenticated as %(user)s, but are not authorized to access this "
@ -1211,18 +1300,19 @@ msgstr ""
"Vous êtes connectés en tant que %(user)s, mais n'êtes pas autorisés à " "Vous êtes connectés en tant que %(user)s, mais n'êtes pas autorisés à "
"accéder à cette page. Voulez-vous vous reconnecter avec un autre compte ?" "accéder à cette page. Voulez-vous vous reconnecter avec un autre compte ?"
#: templates/registration/login.html:23 #: corres2math/templates/registration/login.html:23
msgid "Forgotten your password or username?" msgid "Forgotten your password or username?"
msgstr "Mot de passe oublié ?" msgstr "Mot de passe oublié ?"
#: templates/search/search.html:6 templates/search/search.html:11 #: corres2math/templates/search/search.html:6
#: corres2math/templates/search/search.html:10
msgid "Search" msgid "Search"
msgstr "Chercher" msgstr "Chercher"
#: templates/search/search.html:16 #: corres2math/templates/search/search.html:15
msgid "Results" msgid "Results"
msgstr "Résultats" msgstr "Résultats"
#: templates/search/search.html:26 #: corres2math/templates/search/search.html:25
msgid "No results found." msgid "No results found."
msgstr "Aucun résultat." msgstr "Aucun résultat."

View File

@ -5,6 +5,7 @@ upstream corres2math {
server { server {
listen 80; listen 80;
server_name corres2math; server_name corres2math;
client_max_body_size 50M;
location / { location / {
proxy_pass http://corres2math; proxy_pass http://corres2math;

View File

@ -1,17 +1,19 @@
Django~=3.0 Django~=3.0
django-bootstrap-datepicker-plus django-bootstrap-datepicker-plus~=3.0
django-cas-server django-cas-server~=1.2
django-crispy-forms django-crispy-forms~=1.9
django-extensions django-extensions~=3.0
django-filter~=2.3.0 django-filter~=2.3
django-haystack~=3.0 django-haystack~=3.0
django-mailer django-mailer~=2.0
django-polymorphic django-polymorphic~=3.0
django-tables2 django-tables2~=2.3
djangorestframework~=3.11.1 djangorestframework~=3.12
django-rest-polymorphic django-rest-polymorphic~=0.1
matrix-nio gunicorn~=20.0
ptpython matrix-nio~=0.15
python-magic~=0.4.18 psycopg2-binary~=2.8
gunicorn ptpython~=3.0
whoosh python-magic~=0.4
sympasoap~=1.0
whoosh~=2.7

18
tox.ini
View File

@ -7,12 +7,22 @@ envlist =
skipsdist = True skipsdist = True
[testenv] [testenv]
sitepackages = True sitepackages = False
deps = deps =
-r{toxinidir}/requirements.txt
coverage coverage
Django~=3.1
django-bootstrap-datepicker-plus~=3.0
django-crispy-forms~=1.9
django-filter~=2.3
django-haystack~=3.0
django-polymorphic~=3.0
django-tables2~=2.3
djangorestframework~=3.12
django-rest-polymorphic~=0.1
python-magic~=0.4
whoosh~=2.7
commands = commands =
coverage run --omit='*migrations*,apps/scripts*' --source=apps ./manage.py test apps/ coverage run --source=apps,corres2math ./manage.py test apps/ corres2math/
coverage report -m coverage report -m
[testenv:linters] [testenv:linters]
@ -25,7 +35,7 @@ deps =
pep8-naming pep8-naming
pyflakes pyflakes
commands = commands =
flake8 apps/ flake8 apps/ corres2math/
[flake8] [flake8]
exclude = exclude =