1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-06-21 01:18:22 +02:00

Clone Corres2math platform

This commit is contained in:
Yohann D'ANELLO
2020-12-27 11:49:54 +01:00
parent 3d9bd88a41
commit 03eca29316
151 changed files with 10032 additions and 0 deletions

4
apps/api/__init__.py Normal file
View File

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

13
apps/api/apps.py Normal file
View File

@ -0,0 +1,13 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class APIConfig(AppConfig):
"""
Manage the inscription through a JSON API.
"""
name = 'api'
verbose_name = _('API')

19
apps/api/serializers.py Normal file
View File

@ -0,0 +1,19 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import User
from rest_framework import serializers
class UserSerializer(serializers.ModelSerializer):
"""
Serialize a User object into JSON.
"""
class Meta:
model = User
exclude = (
'username',
'password',
'groups',
'user_permissions',
)

27
apps/api/tests.py Normal file
View File

@ -0,0 +1,27 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from unittest.case import skipIf
from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase
class TestAPIPages(TestCase):
def setUp(self):
self.user = User.objects.create_superuser(
username="admin",
password="apitest",
email="",
)
self.client.force_login(self.user)
def test_user_page(self):
response = self.client.get("/api/user/")
self.assertEqual(response.status_code, 200)
@skipIf("logs" not in settings.INSTALLED_APPS, reason="logs app is not used")
def test_logs_page(self):
response = self.client.get("/api/logs/")
self.assertEqual(response.status_code, 200)

26
apps/api/urls.py Normal file
View File

@ -0,0 +1,26 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.conf.urls import include, url
from rest_framework import routers
from .viewsets import UserViewSet
# Routers provide an easy way of automatically determining the URL conf.
# Register each app API router and user viewset
router = routers.DefaultRouter()
router.register('user', UserViewSet)
if "logs" in settings.INSTALLED_APPS:
from logs.api.urls import register_logs_urls
register_logs_urls(router, "logs")
app_name = 'api'
# Wire up our API using automatic URL routing.
# Additionally, we include login URLs for the browsable API.
urlpatterns = [
url('^', include(router.urls)),
url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
]

20
apps/api/viewsets.py Normal file
View File

@ -0,0 +1,20 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import User
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ModelViewSet
from .serializers import UserSerializer
class UserViewSet(ModelViewSet):
"""
Display list of users.
"""
queryset = User.objects.order_by("id").all()
serializer_class = UserSerializer
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['id', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ]
search_fields = ['$first_name', '$last_name', ]

View File

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

8
apps/eastereggs/apps.py Normal file
View File

@ -0,0 +1,8 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
class EastereggsConfig(AppConfig):
name = 'eastereggs'

View File

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

View File

@ -0,0 +1,19 @@
{% extends "index.html" %}
{% block content %}
<div id="index-content"></div>
{% include "eastereggs/xp_modal.html" %}
{% endblock %}
{% block extrajavascript %}
<script>
$(document).ready(function() {
$("#index-content").load("{% url "index" %} #content");
function displayModal() {
$("#xpModal").modal('toggle');
setTimeout(displayModal, 400);
}
displayModal();
});
</script>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% load crispy_forms_filters i18n %}
<div id="xpModal" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Error" %}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{% trans "This task failed successfully." %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{% trans "Close" %}</button>
</div>
</div>
</div>
</div>

11
apps/eastereggs/urls.py Normal file
View File

@ -0,0 +1,11 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from django.views.generic import TemplateView
app_name = "eastereggs"
urlpatterns = [
path("xp/", TemplateView.as_view(template_name="eastereggs/xp.html")),
]

4
apps/logs/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'logs.apps.LogsConfig'

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

@ -0,0 +1,19 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from rest_framework import serializers
from ..models import Changelog
class ChangelogSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Changelog types.
The djangorestframework plugin will analyse the model `Changelog` and parse all fields in the API.
"""
class Meta:
model = Changelog
fields = '__all__'
# noinspection PyProtectedMember
read_only_fields = [f.name for f in model._meta.get_fields()] # Changelogs are read-only protected

11
apps/logs/api/urls.py Normal file
View File

@ -0,0 +1,11 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import ChangelogViewSet
def register_logs_urls(router, path):
"""
Configure router for Activity REST API.
"""
router.register(path, ChangelogViewSet)

28
apps/logs/api/views.py Normal file
View File

@ -0,0 +1,28 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter
from rest_framework.viewsets import ModelViewSet
from .serializers import ChangelogSerializer
from ..models import Changelog
class ChangelogViewSet(ModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,
then render it on /api/logs/
"""
def check_permissions(self, request):
# Only superusers can get access to logs
return self.request.user and self.request.user.is_superuser
queryset = Changelog.objects.all()
serializer_class = ChangelogSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter]
filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ]
ordering_fields = ['timestamp', 'id', ]
ordering = ['-id', ]

18
apps/logs/apps.py Normal file
View File

@ -0,0 +1,18 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.db.models.signals import post_delete, post_save, pre_save
from django.utils.translation import gettext_lazy as _
class LogsConfig(AppConfig):
name = 'logs'
verbose_name = _('Logs')
def ready(self):
# noinspection PyUnresolvedReferences
from . import signals
pre_save.connect(signals.pre_save_object)
post_save.connect(signals.save_object)
post_delete.connect(signals.delete_object)

View File

@ -0,0 +1,37 @@
# Generated by Django 3.1.3 on 2020-11-04 12:05
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Changelog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP Address')),
('instance_pk', models.CharField(max_length=255, verbose_name='identifier')),
('previous', models.TextField(blank=True, default='', verbose_name='previous data')),
('data', models.TextField(blank=True, default='', verbose_name='new data')),
('action', models.CharField(choices=[('create', 'create'), ('edit', 'edit'), ('delete', 'delete')], default='edit', max_length=16, verbose_name='action')),
('timestamp', models.DateTimeField(default=django.utils.timezone.now, verbose_name='timestamp')),
('model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype', verbose_name='model')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'changelog',
'verbose_name_plural': 'changelogs',
},
),
]

View File

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

88
apps/logs/models.py Normal file
View File

@ -0,0 +1,88 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
class Changelog(models.Model):
"""
Store each modification in the database (except sessions and logging),
including creating, editing and deleting models.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
null=True,
verbose_name=_('user'),
)
ip = models.GenericIPAddressField(
null=True,
blank=True,
verbose_name=_("IP Address")
)
model = models.ForeignKey(
ContentType,
on_delete=models.PROTECT,
null=False,
blank=False,
verbose_name=_('model'),
)
instance_pk = models.CharField(
max_length=255,
null=False,
blank=False,
verbose_name=_('identifier'),
)
previous = models.TextField(
blank=True,
default="",
verbose_name=_('previous data'),
)
data = models.TextField(
blank=True,
default="",
verbose_name=_('new data'),
)
action = models.CharField( # create, edit or delete
max_length=16,
null=False,
blank=False,
choices=[
('create', _('create')),
('edit', _('edit')),
('delete', _('delete')),
],
default='edit',
verbose_name=_('action'),
)
timestamp = models.DateTimeField(
null=False,
blank=False,
default=timezone.now,
name='timestamp',
verbose_name=_('timestamp'),
)
def delete(self, using=None, keep_parents=False):
raise ValidationError(_("Logs cannot be destroyed."))
class Meta:
verbose_name = _("changelog")
verbose_name_plural = _("changelogs")
def __str__(self):
return _("Changelog of type \"{action}\" for model {model} at {timestamp}").format(
action=self.get_action_display(), model=str(self.model), timestamp=str(self.timestamp))

132
apps/logs/signals.py Normal file
View File

@ -0,0 +1,132 @@
import getpass
from tfjm.middlewares import get_current_authenticated_user, get_current_ip
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from rest_framework.renderers import JSONRenderer
from rest_framework.serializers import ModelSerializer
from .models import Changelog
# Ces modèles ne nécessitent pas de logs
EXCLUDED = [
'admin.logentry',
'authtoken.token',
'contenttypes.contenttype',
'logs.changelog', # Never remove this line
'mailer.dontsendentry',
'mailer.message',
'mailer.messagelog',
'migrations.migration',
'sessions.session',
]
def pre_save_object(sender, instance, **kwargs):
"""
Before a model get saved, we get the previous instance that is currently in the database
"""
qs = sender.objects.filter(pk=instance.pk).all()
if qs.exists():
instance._previous = qs.get()
else:
instance._previous = None
def save_object(sender, instance, **kwargs):
"""
Each time a model is saved, an entry in the table `Changelog` is added in the database
in order to store each modification made
"""
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
return
# noinspection PyProtectedMember
previous = instance._previous
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
user, ip = get_current_authenticated_user(), get_current_ip()
if user is None:
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
ip = "127.0.0.1"
username = getpass.getuser()
user = User.objects.get(username=username) if User.objects.filter(username=username).exists() else None
# On n'enregistre pas les connexions
# noinspection PyProtectedMember
if user is not None and instance._meta.label_lower == "auth.user" and previous \
and instance.last_login != previous.last_login:
return
changed_fields = '__all__'
if previous:
# On ne garde que les champs modifiés
changed_fields = []
for field in instance._meta.fields:
if field.name.endswith("_ptr"):
# A field ending with _ptr is a OneToOneRel with a subclass, e.g. NoteClub.note_ptr -> Note
continue
if getattr(instance, field.name) != getattr(previous, field.name):
changed_fields.append(field.name)
if len(changed_fields) == 0:
# Pas de log s'il n'y a pas de modification
return
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles avec uniquement les champs modifiés
class CustomSerializer(ModelSerializer):
class Meta:
model = instance.__class__
fields = changed_fields
previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else ""
instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8")
Changelog.objects.create(user=user,
ip=ip,
model=ContentType.objects.get_for_model(instance),
instance_pk=instance.pk,
previous=previous_json,
data=instance_json,
action=("edit" if previous else "create")
).save()
def delete_object(sender, instance, **kwargs):
"""
Each time a model is deleted, an entry in the table `Changelog` is added in the database
"""
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
return
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
user, ip = get_current_authenticated_user(), get_current_ip()
if user is None:
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
ip = "127.0.0.1"
username = getpass.getuser()
user = User.objects.get(username=username) if User.objects.filter(username=username).exists() else None
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles
class CustomSerializer(ModelSerializer):
class Meta:
model = instance.__class__
fields = '__all__'
instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8")
Changelog.objects.create(user=user,
ip=ip,
model=ContentType.objects.get_for_model(instance),
instance_pk=instance.pk,
previous=instance_json,
data="",
action="delete"
)

21
apps/logs/tests.py Normal file
View File

@ -0,0 +1,21 @@
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.test import TestCase
from .models import Changelog
class TestChangelog(TestCase):
def test_logs(self):
user = User.objects.create(email="admin@example.com")
self.assertTrue(Changelog.objects.filter(action="create", instance_pk=user.pk,
model=ContentType.objects.get_for_model(User)).exists())
old_user_pk = user.pk
user.delete()
self.assertTrue(Changelog.objects.filter(action="delete", instance_pk=old_user_pk,
model=ContentType.objects.get_for_model(User)).exists())
changelog = Changelog.objects.first()
self.assertRaises(ValidationError, changelog.delete)
str(Changelog.objects.all())

View File

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

View File

@ -0,0 +1,49 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Participation, Phase, Question, Team, Video
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'trigram', 'problem', 'valid',)
search_fields = ('name', 'trigram',)
list_filter = ('participation__problem', 'participation__valid',)
def problem(self, team):
return team.participation.get_problem_display()
problem.short_description = _('problem number')
def valid(self, team):
return team.participation.valid
valid.short_description = _('valid')
@admin.register(Participation)
class ParticipationAdmin(admin.ModelAdmin):
list_display = ('team', 'problem', 'valid',)
search_fields = ('team__name', 'team__trigram',)
list_filter = ('problem', 'valid',)
@admin.register(Video)
class VideoAdmin(admin.ModelAdmin):
list_display = ('participation', 'link',)
search_fields = ('participation__team__name', 'participation__team__trigram', 'link',)
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
list_display = ('participation', 'question',)
search_fields = ('participation__team__name', 'participation__team__trigram', 'question',)
@admin.register(Phase)
class PhaseAdmin(admin.ModelAdmin):
list_display = ('phase_number', 'start', 'end',)
ordering = ('phase_number', 'start',)

View File

@ -0,0 +1,18 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.db.models.signals import post_save, pre_delete, pre_save
class ParticipationConfig(AppConfig):
"""
The participation app contains the data about the teams, videos, ...
"""
name = 'participation'
def ready(self):
from participation.signals import create_team_participation, delete_related_videos, update_mailing_list
pre_save.connect(update_mailing_list, "participation.Team")
pre_delete.connect(delete_related_videos, "participation.Participation")
post_save.connect(create_team_participation, "participation.Team")

194
apps/participation/forms.py Normal file
View File

@ -0,0 +1,194 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import re
from bootstrap_datepicker_plus import DateTimePickerInput
from django import forms
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from .models import Participation, Phase, Question, Team, Video
class TeamForm(forms.ModelForm):
"""
Form to create a team, with the name and the trigram,
and if the team accepts that Animath diffuse the videos.
"""
def clean_trigram(self):
trigram = self.cleaned_data["trigram"].upper()
if not re.match("[A-Z]{3}", trigram):
raise ValidationError(_("The trigram must be composed of three uppercase letters."))
return trigram
class Meta:
model = Team
fields = ('name', 'trigram', 'grant_animath_access_videos',)
class JoinTeamForm(forms.ModelForm):
"""
Form to join a team by the access code.
"""
def clean_access_code(self):
access_code = self.cleaned_data["access_code"]
if not Team.objects.filter(access_code=access_code).exists():
raise ValidationError(_("No team was found with this access code."))
return access_code
def clean(self):
cleaned_data = super().clean()
if "access_code" in cleaned_data:
team = Team.objects.get(access_code=cleaned_data["access_code"])
self.instance = team
return cleaned_data
class Meta:
model = Team
fields = ('access_code',)
class ParticipationForm(forms.ModelForm):
"""
Form to update the problem of a team participation.
"""
class Meta:
model = Participation
fields = ('problem',)
class RequestValidationForm(forms.Form):
"""
Form to ask about validation.
"""
_form_type = forms.CharField(
initial="RequestValidationForm",
widget=forms.HiddenInput(),
)
engagement = forms.BooleanField(
label=_("I engage myself to participate to the whole \"Correspondances\"."),
required=True,
)
class ValidateParticipationForm(forms.Form):
"""
Form to let administrators to accept or refuse a team.
"""
_form_type = forms.CharField(
initial="ValidateParticipationForm",
widget=forms.HiddenInput(),
)
message = forms.CharField(
label=_("Message to address to the team:"),
widget=forms.Textarea(),
)
class UploadVideoForm(forms.ModelForm):
"""
Form to upload a video, for a solution or a synthesis.
"""
class Meta:
model = Video
fields = ('link',)
def clean(self):
if Phase.current_phase().phase_number != 1 and Phase.current_phase().phase_number != 4 and self.instance.link:
self.add_error("link", _("You can't upload your video after the deadline."))
return super().clean()
class ReceiveParticipationForm(forms.ModelForm):
"""
Update the received participation of a participation.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["received_participation"].queryset = Participation.objects.filter(
~Q(pk=self.instance.pk) & Q(problem=self.instance.problem, valid=True)
)
class Meta:
model = Participation
fields = ('received_participation',)
class SendParticipationForm(forms.ModelForm):
"""
Update the sent participation of a participation.
"""
sent_participation = forms.ModelChoiceField(
queryset=Participation.objects,
label=lambda: _("Send to team"),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
try:
self.fields["sent_participation"].initial = self.instance.sent_participation
except ObjectDoesNotExist: # No sent participation
pass
self.fields["sent_participation"].queryset = Participation.objects.filter(
~Q(pk=self.instance.pk) & Q(problem=self.instance.problem, valid=True)
)
def clean(self, commit=True):
cleaned_data = super().clean()
if "sent_participation" in cleaned_data:
participation = cleaned_data["sent_participation"]
participation.received_participation = self.instance
self.instance = participation
return cleaned_data
class Meta:
model = Participation
fields = ('sent_participation',)
class QuestionForm(forms.ModelForm):
"""
Create or update a question.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
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:
model = Question
fields = ('question',)
class PhaseForm(forms.ModelForm):
"""
Form to update the calendar of a phase.
"""
class Meta:
model = Phase
fields = ('start', 'end',)
widgets = {
'start': DateTimePickerInput(format='%d/%m/%Y %H:%M'),
'end': DateTimePickerInput(format='%d/%m/%Y %H:%M'),
}
def clean(self):
# Ensure that dates are in a right order
cleaned_data = super().clean()
start = cleaned_data["start"]
end = cleaned_data["end"]
if end <= start:
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=start).exists():
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=end).exists():
self.add_error("end", _("This phase must end after the next phases."))
return cleaned_data

View File

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

View File

@ -0,0 +1,92 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from asgiref.sync import async_to_sync
from tfjm.matrix import Matrix, RoomPreset, RoomVisibility
from django.core.management import BaseCommand
from registration.models import AdminRegistration, Registration
class Command(BaseCommand):
def handle(self, *args, **options):
Matrix.set_display_name("Bot du TFJM²")
if not os.getenv("SYNAPSE_PASSWORD"):
avatar_uri = "plop"
else: # pragma: no cover
if not os.path.isfile(".matrix_avatar"):
stat_file = os.stat("tfjm/static/logo.svg")
with open("tfjm/static/logo.svg", "rb") as f:
resp = Matrix.upload(f, filename="logo.svg", content_type="image/svg",
filesize=stat_file.st_size)[0][0]
avatar_uri = resp.content_uri
with open(".matrix_avatar", "w") as f:
f.write(avatar_uri)
Matrix.set_avatar(avatar_uri)
with open(".matrix_avatar", "r") as f:
avatar_uri = f.read().rstrip(" \t\r\n")
if not async_to_sync(Matrix.resolve_room_alias)("#faq:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias="faq",
name="FAQ",
topic="Posez toutes vos questions ici !",
federate=False,
preset=RoomPreset.public_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)("#annonces:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias="annonces",
name="Annonces",
topic="Informations importantes du TFJM²",
federate=False,
preset=RoomPreset.public_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)("#je-cherche-une-equipe:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias="je-cherche-une-equipe",
name="Je cherche une équipe",
topic="Le Tinder du TFJM²",
federate=False,
preset=RoomPreset.public_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)("#flood:tfjm.org"):
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:tfjm.org", avatar_uri)
Matrix.set_room_avatar("#faq:tfjm.org", avatar_uri)
Matrix.set_room_avatar("#je-cherche-une-equipe:tfjm.org", avatar_uri)
Matrix.set_room_avatar("#flood:tfjm.org", avatar_uri)
Matrix.set_room_power_level_event("#annonces:tfjm.org", "events_default", 50)
for r in Registration.objects.all():
Matrix.invite("#annonces:tfjm.org", f"@{r.matrix_username}:tfjm.org")
Matrix.invite("#faq:tfjm.org", f"@{r.matrix_username}:tfjm.org")
Matrix.invite("#je-cherche-une-equipe:tfjm.org",
f"@{r.matrix_username}:tfjm.org")
Matrix.invite("#flood:tfjm.org", f"@{r.matrix_username}:tfjm.org")
for admin in AdminRegistration.objects.all():
Matrix.set_room_power_level("#annonces:tfjm.org",
f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level("#faq:tfjm.org",
f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level("#flood:tfjm.org",
f"@{admin.matrix_username}:tfjm.org", 95)

View File

@ -0,0 +1,43 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from tfjm.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 du TFJM²", "hotline",
"Liste de diffusion pour contacter toutes les équipes validées du TFJM².",
"education", raise_error=False)
sympa.create_list("equipes-non-valides", "Équipes du TFJM²", "hotline",
"Liste de diffusion pour contacter toutes les équipes non validées du TFJM².",
"education", raise_error=False)
for problem in range(1, 4):
sympa.create_list(f"probleme-{problem}",
f"Équipes du TFJM² participant au problème {problem}", "hotline",
f"Liste de diffusion pour contacter les équipes participant au problème {problem}"
f" du TFJM².", "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

@ -0,0 +1,44 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from tfjm.matrix import Matrix, RoomVisibility
from django.core.management import BaseCommand
from participation.models import Participation
class Command(BaseCommand):
def handle(self, *args, **options):
for participation in Participation.objects.filter(valid=True).all():
for i, question in enumerate(participation.questions.order_by("id").all()):
solution_author = participation.received_participation.team
alias = f"equipe-{solution_author.trigram.lower()}-question-{i}"
room_id = f"#{alias}:tfjm.org"
Matrix.create_room(
visibility=RoomVisibility.public,
alias=alias,
name=f"Solution équipe {solution_author.trigram} - question {i+1}",
topic=f"Échange entre l'équipe {solution_author.name} ({solution_author.trigram}) "
f"et l'équipe {participation.team.name} ({participation.team.trigram}) "
f"autour de la question {i+1} sur le problème {participation.problem}",
federate=False,
invite=[f"@{registration.matrix_username}:tfjm.org" for registration in
list(participation.team.students.all()) + list(participation.team.coachs.all()) +
list(solution_author.students.all()) + list(solution_author.coachs.all())],
)
Matrix.set_room_power_level_event(room_id, "events_default", 21)
for registration in solution_author.students.all():
Matrix.set_room_power_level(room_id,
f"@{registration.matrix_username}:tfjm.org", 42)
Matrix.send_message(room_id, "Bienvenue dans la troisième phase du TFJM² !")
Matrix.send_message(room_id, f"L'équipe {participation.team.name} a visionné la vidéo de l'équipe "
f"{solution_author.name} sur le problème {participation.problem}, et a posé "
"une série de questions.")
Matrix.send_message(room_id, "L'équipe ayant composé la vidéo doit maintenant proposer une réponse.")
Matrix.send_message(room_id, "Une fois la réponse apportée, vous pourrez ensuite échanger plus "
"librement autour de la question, au travers de ce canal.")
Matrix.send_message(room_id, "**Question posée :**", formatted_body="<strong>Question posée :</strong>")
Matrix.send_message(room_id, question.question,
formatted_body=f"<font color=\"#ff0000\">{question.question}</font>")
# TODO Setup the bot the set the power level of all members of the room to 42

View File

@ -0,0 +1,138 @@
# Generated by Django 3.1.3 on 2020-11-04 12:05
import django.core.validators
from django.db import migrations, models
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):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Participation',
fields=[
('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')),
('valid', models.BooleanField(default=None, help_text='The video got the validation of the administrators.', null=True, verbose_name='valid')),
],
options={
'verbose_name': 'participation',
'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(
name='Team',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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')),
('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={
'verbose_name': 'team',
'verbose_name_plural': 'teams',
},
),
migrations.CreateModel(
name='Video',
fields=[
('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')),
('valid', models.BooleanField(default=None, help_text='The video got the validation of the administrators.', null=True, verbose_name='valid')),
],
options={
'verbose_name': 'video',
'verbose_name_plural': 'videos',
},
),
migrations.AddIndex(
model_name='team',
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(
model_name='participation',
name='received_participation',
field=models.OneToOneField(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sent_participation', to='participation.participation', verbose_name='received participation'),
),
migrations.AddField(
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.AddField(
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'),
),
migrations.AddField(
model_name='participation',
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

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

View File

@ -0,0 +1,307 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import os
import re
from tfjm.lists import get_sympa_client
from tfjm.matrix import Matrix, RoomPreset, RoomVisibility
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import RegexValidator
from django.db import models
from django.db.models import Index
from django.template.loader import render_to_string
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
class Team(models.Model):
"""
The Team model represents a real team that participates to the Correspondances.
This only includes the registration detail.
"""
name = models.CharField(
max_length=255,
verbose_name=_("name"),
unique=True,
)
trigram = models.CharField(
max_length=3,
verbose_name=_("trigram"),
help_text=_("The trigram must be composed of three uppercase letters."),
unique=True,
validators=[RegexValidator("[A-Z]{3}")],
)
access_code = models.CharField(
max_length=6,
verbose_name=_("access code"),
help_text=_("The access code let other people to join the team."),
)
grant_animath_access_videos = models.BooleanField(
verbose_name=_("Grant Animath to publish my video"),
help_text=_("Give the authorisation to publish the video on the main website to promote the action."),
default=False,
)
@property
def email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"equipe-{self.trigram.lower()}@{os.getenv('SYMPA_HOST', 'localhost')}"
def create_mailing_list(self):
"""
Create a new Sympa mailing list to contact the team.
"""
get_sympa_client().create_list(
f"equipe-{self.trigram.lower()}",
f"Équipe {self.name} ({self.trigram})",
"hotline", # TODO Use a custom sympa template
f"Liste de diffusion pour contacter l'équipe {self.name} du TFJM²",
"education",
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):
"""
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}")
def save(self, *args, **kwargs):
if not self.access_code:
# if the team got created, generate the access code, create the contact mailing list
# and create a dedicated Matrix room.
self.access_code = get_random_string(6)
self.create_mailing_list()
Matrix.create_room(
visibility=RoomVisibility.private,
name=f"#équipe-{self.trigram.lower()}",
alias=f"equipe-{self.trigram.lower()}",
topic=f"Discussion de l'équipe {self.name}",
preset=RoomPreset.private_chat,
)
return super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse_lazy("participation:team_detail", args=(self.pk,))
def __str__(self):
return _("Team {name} ({trigram})").format(name=self.name, trigram=self.trigram)
class Meta:
verbose_name = _("team")
verbose_name_plural = _("teams")
indexes = [
Index(fields=("trigram", )),
]
class Participation(models.Model):
"""
The Participation model contains all data that are related to the participation:
chosen problem, validity status, videos,...
"""
team = models.OneToOneField(
Team,
on_delete=models.CASCADE,
verbose_name=_("team"),
)
problem = models.IntegerField(
choices=[(i, format_lazy(_("Problem #{problem:d}"), problem=i)) for i in range(1, 4)],
null=True,
default=None,
verbose_name=_("problem number"),
)
valid = models.BooleanField(
null=True,
default=None,
verbose_name=_("valid"),
help_text=_("The video got the validation of the administrators."),
)
solution = models.OneToOneField(
"participation.Video",
on_delete=models.SET_NULL,
related_name="participation_solution",
null=True,
default=None,
verbose_name=_("solution video"),
)
received_participation = models.OneToOneField(
"participation.Participation",
on_delete=models.PROTECT,
related_name="sent_participation",
null=True,
default=None,
verbose_name=_("received participation"),
)
synthesis = models.OneToOneField(
"participation.Video",
on_delete=models.SET_NULL,
related_name="participation_synthesis",
null=True,
default=None,
verbose_name=_("synthesis video"),
)
def get_absolute_url(self):
return reverse_lazy("participation:participation_detail", args=(self.pk,))
def __str__(self):
return _("Participation of the team {name} ({trigram})").format(name=self.team.name, trigram=self.team.trigram)
class Meta:
verbose_name = _("participation")
verbose_name_plural = _("participations")
class Video(models.Model):
"""
The Video model only contains a link and a validity status.
"""
link = models.URLField(
verbose_name=_("link"),
help_text=_("The full video link."),
)
valid = models.BooleanField(
null=True,
default=None,
verbose_name=_("valid"),
help_text=_("The video got the validation of the administrators."),
)
@property
def participation(self):
"""
Retrives the participation that is associated to this video,
whatever it is a solution or a synthesis.
"""
try:
# If this is a solution
return self.participation_solution
except ObjectDoesNotExist:
# If this is a synthesis
return self.participation_synthesis
@property
def platform(self):
"""
According to the link, retrieve the platform that is used to upload the video.
"""
if "youtube.com" in self.link or "youtu.be" in self.link:
return "youtube"
return "unknown"
@property
def youtube_code(self):
"""
If the video is uploaded on Youtube, search in the URL the video code.
"""
return re.compile("(https?://|)(www\\.|)(youtube\\.com/watch\\?v=|youtu\\.be/)([a-zA-Z0-9-_]*)?.*?")\
.match(self.link).group(4)
def as_iframe(self):
"""
Generate the HTML code to embed the video in an iframe, according to the type of the host platform.
"""
if self.platform == "youtube":
return render_to_string("participation/youtube_iframe.html", context=dict(youtube_code=self.youtube_code))
return None
def __str__(self):
return _("Video of team {name} ({trigram})")\
.format(name=self.participation.team.name, trigram=self.participation.team.trigram)
class Meta:
verbose_name = _("video")
verbose_name_plural = _("videos")
class Question(models.Model):
"""
Question to ask to the team that sent a solution.
"""
participation = models.ForeignKey(
Participation,
on_delete=models.CASCADE,
verbose_name=_("participation"),
related_name="questions",
)
question = models.TextField(
verbose_name=_("question"),
)
def __str__(self):
return self.question
class Phase(models.Model):
"""
The Phase model corresponds to the dates of the phase.
"""
phase_number = models.AutoField(
primary_key=True,
unique=True,
verbose_name=_("phase number"),
)
description = models.CharField(
max_length=255,
verbose_name=_("phase description"),
)
start = models.DateTimeField(
verbose_name=_("start date of the given phase"),
default=timezone.now,
)
end = models.DateTimeField(
verbose_name=_("end date of the given phase"),
default=timezone.now,
)
@classmethod
def current_phase(cls):
"""
Retrieve the current phase of this day
"""
qs = Phase.objects.filter(start__lte=timezone.now(), end__gte=timezone.now())
if qs.exists():
return qs.get()
qs = Phase.objects.filter(start__lte=timezone.now()).order_by("phase_number").all()
return qs.last() if qs.exists() else None
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}")\
.format(phase_number=self.phase_number, start=self.start, end=self.end)
class Meta:
verbose_name = _("phase")
verbose_name_plural = _("phases")

View File

@ -0,0 +1,36 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from haystack import indexes
from .models import Participation, Team, Video
class TeamIndex(indexes.ModelSearchIndex, indexes.Indexable):
"""
Index all teams by their name and trigram.
"""
text = indexes.NgramField(document=True, use_template=True)
class Meta:
model = Team
class ParticipationIndex(indexes.ModelSearchIndex, indexes.Indexable):
"""
Index all participations by their team name and team trigram.
"""
text = indexes.NgramField(document=True, use_template=True)
class Meta:
model = Participation
class VideoIndex(indexes.ModelSearchIndex, indexes.Indexable):
"""
Index all teams by their team name and team trigram.
"""
text = indexes.NgramField(document=True, use_template=True)
class Meta:
model = Video

View File

@ -0,0 +1,46 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from tfjm.lists import get_sympa_client
from participation.models import Participation, Team, Video
def create_team_participation(instance, created, **_):
"""
When a team got created, create an associated team and create Video objects.
"""
participation = Participation.objects.get_or_create(team=instance)[0]
if not participation.solution:
participation.solution = Video.objects.create()
if not participation.synthesis:
participation.synthesis = Video.objects.create()
participation.save()
if not created:
participation.team.create_mailing_list()
def update_mailing_list(instance: Team, **_):
"""
When a team name or trigram got updated, update mailing lists and Matrix rooms
"""
if instance.pk:
old_team = Team.objects.get(pk=instance.pk)
if old_team.name != instance.name or old_team.trigram != instance.trigram:
# TODO Rename Matrix room
# Delete old mailing list, create a new one
old_team.delete_mailing_list()
instance.create_mailing_list()
# Subscribe all team members in the mailing list
for student in instance.students.all():
get_sympa_client().subscribe(student.user.email, f"equipe-{instance.trigram.lower()}", False,
f"{student.user.first_name} {student.user.last_name}")
for coach in instance.coachs.all():
get_sympa_client().subscribe(coach.user.email, f"equipe-{instance.trigram.lower()}", False,
f"{coach.user.first_name} {coach.user.last_name}")
def delete_related_videos(instance: Participation, **_):
if instance.solution:
instance.solution.delete()
if instance.synthesis:
instance.synthesis.delete()

View File

@ -0,0 +1,91 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from .models import Phase, Team
class CalendarTable(tables.Table):
class Meta:
attrs = {
'class': 'table table condensed table-striped',
}
row_attrs = {
'class': lambda record: 'bg-success' if timezone.now() > record.end else
'bg-warning' if timezone.now() > record.start else
'bg-danger',
'data-id': lambda record: str(record.phase_number),
}
model = Phase
fields = ('phase_number', 'description', 'start', 'end',)
template_name = 'django_tables2/bootstrap4.html'
order_by = ('phase_number',)
# noinspection PyTypeChecker
class TeamTable(tables.Table):
name = tables.LinkColumn(
'participation:team_detail',
args=[tables.A("id")],
verbose_name=lambda: _("name").capitalize(),
)
problem = tables.Column(
accessor="participation__problem",
verbose_name=lambda: _("problem number").capitalize(),
)
class Meta:
attrs = {
'class': 'table table condensed table-striped',
}
model = Team
fields = ('name', 'trigram', 'problem',)
template_name = 'django_tables2/bootstrap4.html'
# noinspection PyTypeChecker
class ParticipationTable(tables.Table):
name = tables.LinkColumn(
'participation:participation_detail',
args=[tables.A("id")],
verbose_name=lambda: _("name").capitalize(),
accessor="team__name",
)
trigram = tables.Column(
verbose_name=lambda: _("trigram").capitalize(),
accessor="team__trigram",
)
problem = tables.Column(
verbose_name=lambda: _("problem number").capitalize(),
)
class Meta:
attrs = {
'class': 'table table condensed table-striped',
}
model = Team
fields = ('name', 'trigram', 'problem',)
template_name = 'django_tables2/bootstrap4.html'
class VideoTable(tables.Table):
participation_name = tables.LinkColumn(
'participation:participation_detail',
args=[tables.A("participation__pk")],
verbose_name=lambda: _("name").capitalize(),
accessor=tables.A("participation__team__name"),
)
class Meta:
attrs = {
'class': 'table table condensed table-striped',
}
model = Team
fields = ('participation_name', 'link',)
template_name = 'django_tables2/bootstrap4.html'

View File

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<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.tfjm.org/#/room/#faq:tfjm.org" 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 %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-success" type="submit">{% trans "Create" %}</button>
</form>
{% endblock content %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-primary" type="submit">{% trans "Join" %}</button>
</form>
{% endblock content %}

View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Demande de validation - TFJM²</title>
</head>
<body>
<p>
Bonjour {{ user.registration }},
</p>
<p>
L'équipe « {{ team.name }} » ({{ team.trigram }}) vient de demander à valider son équipe pour participer
au {{ team.participation.get_problem_display }} du TFJM² 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 :
<a href="https://{{ domain }}{% url "participation:team_detail" pk=team.pk %}">
https://{{ domain }}{% url "participation:team_detail" pk=team.pk %}
</a>
</p>
<p>
Cordialement,
</p>
<p>
L'organisation du TFJM² des Jeunes Mathématicien·ne·s
</p>
</body>
</html>

View File

@ -0,0 +1,10 @@
Bonjour {{ user.registration }},
L'équipe « {{ team.name }} » ({{ team.trigram }}) vient de demander à valider son équipe pour participer
au {{ team.participation.get_problem_display }} du TFJM² 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 :
https://{{ domain }}{% url "participation:team_detail" pk=team.pk %}
Cordialement,
L'organisation du TFJM² des Jeunes Mathématicien·ne·s

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Équipe non validée TFJM²</title>
</head>
<body>
Bonjour,<br/>
<br />
Maleureusement, votre équipe « {{ team.name }} » ({{ team.trigram }}) n'a pas été validée. Veuillez vérifier que vos autorisations
de droit à l'image sont correctes. Les organisateurs vous adressent ce message :<br />
<br />
{{ message }}<br />
<br />
N'hésitez pas à nous contacter à l'adresse <a href="mailto:contact@tfjm.org">contact@tfjm.org</a>
pour plus d'informations.
<br/>
Cordialement,<br/>
<br/>
Le comité d'organisation du TFJM² des Jeunes Mathématicien·ne·s
</body>
</html>

View File

@ -0,0 +1,12 @@
Bonjour,
Maleureusement, votre équipe « {{ team.name }} » ({{ team.trigram }}) n'a pas été validée. Veuillez vérifier que vos
autorisations de droit à l'image sont correctes. Les organisateurs vous adressent ce message :
{{ message }}
N'hésitez pas à nous contacter à l'adresse contact@tfjm.org pour plus d'informations.
Cordialement,
Le comité d'organisation du TFJM² des Jeunes Mathématicien·ne·s

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Équipe validée TFJM²</title>
</head>
<body>
Bonjour,<br/>
<br/>
Félicitations ! Votre équipe « {{ team.name }} » ({{ team.trigram }}) est désormais validée ! Vous êtes désormais apte à travailler sur
votre problème. Lorsque les Correspondances auront débutées, vous pourrez soumettre votre vidéo sur la plateforme d'inscription.<br>
Les organisateurs vous adressent ce message :<br/>
<br/>
{{ message }}<br />
<br/>
Cordialement,<br/>
<br/>
Le comité d'organisation du TFJM² des Jeunes Mathématicien·ne·s
</body>
</html>

View File

@ -0,0 +1,12 @@
Bonjour,
Félicitations ! Votre équipe « {{ team.name }} » ({{ team.trigram }}) est désormais validée ! Vous êtes désormais apte à travailler sur
votre problème. Lorsque les Correspondances auront débutées, vous pourrez soumettre votre vidéo sur la plateforme d'inscription.
Les organisateurs vous adressent ce message :
{{ message }}
Cordialement,
Le comité d'organisation du TFJM² des Jeunes Mathématicien·ne·s

View File

@ -0,0 +1,300 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% trans "any" as any %}
<div class="card bg-light shadow">
<div class="card-header text-center">
<h4>{% trans "Participation of team" %} {{ participation.team.name }} ({{ participation.team.trigram }})</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-2">{% trans "Team:" %}</dt>
<dd class="col-sm-10"><a href="{% url "participation:team_detail" pk=participation.team.pk %}">{{ participation.team }}</a></dd>
<dt class="col-sm-2">{% trans "Chosen problem:" %}</dt>
<dd class="col-sm-10">{{ participation.get_problem_display }}</dd>
</dl>
<div id="solution-container">
<dl class="row">
{% trans "No video sent" as novideo %}
<dt class="col-sm-2">{% trans "Proposed solution:" %}</dt>
<dd class="col-sm-10"><a href="{{ participation.solution.link|default:"#" }}"{% if participation.solution.link %} target="_blank"{% endif %}>
{{ participation.solution.link|default:novideo }}</a>
{% if current_phase.phase_number == 1 or participation.solution.link == "" %}
<button class="btn btn-primary" data-toggle="modal" data-target="#uploadSolutionModal">{% trans "Upload" %}</button>
{% endif %}
{% if participation.solution.link %}
<button class="btn btn-info" data-toggle="modal" data-target="#displaySolutionModal">{% trans "Display" %}</button>
{% endif %}
</dd>
</dl>
</div>
</div>
</div>
{% if user.registration.is_admin or current_phase.phase_number >= 2 %}
<hr>
<div class="row">
<div class="col-md-6">
<div class="card bg-light shadow">
<div class="card-header text-center">
<h4>{% trans "Sent solution" %}</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-xl-5 text-right">{% trans "Team that received your solution:" %}</dt>
<dd class="col-md-5">{{ participation.sent_participation.team|default:any }}</dd>
{% if user.registration.is_admin %}
<dd class="col-xs-2">
<button class="btn btn-primary" data-toggle="modal" data-target="#defineSentParticipationModal">{% trans "Change" %}</button>
</dd>
{% endif %}
</dl>
{% if current_phase.phase_number == 2 %}
<div class="alert alert-info">
{% blocktrans trimmed %}
The mentioned team received your video. They are now watching your video,
and formulating questions. You would be able to exchange with the other phase during
the next phase.
{% endblocktrans %}
</div>
{% elif current_phase.phase_number == 3 %}
<div class="alert alert-info">
{% blocktrans trimmed with user_id=user.pk %}
The other team sent you questions about your solution. Your are now able to answer them,
then to exchange freely with the other team. You can click on the Chat button, or to
connect to your dedicated Matrix account:
<code>@tfjm_{{ user_id }}:tfjm.org</code>.
You can use your own Matrix client, or use the dedicated Element client:
<a href="https://element.tfjm.org">element.correpondances-maths.fr</a>
{% endblocktrans %}
</div>
{% elif current_phase.phase_number == 4 %}
<dl class="row">
<dt class="col-xl-5 text-right">{% trans "Synthesis from the other team:" %}</dt>
<dd class="col-sm-7"><a href="{{ participation.received_participation.synthesis.link|default:"#" }}"{% if participation.received_participation.synthesis.link %} target="_blank"{% endif %}>
{{ participation.received_participation.synthesis.link|default:novideo }}</a>
{% if participation.received_participation.synthesis.link %}
<button class="btn btn-info" data-toggle="modal" data-target="#displayOtherSynthesisModal">{% trans "Display" %}</button>
{% endif %}
</dd>
</dl>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card bg-light shadow">
<div class="card-header text-center">
<h4>{% trans "Received solution" %}</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-xl-5 text-right">{% trans "Team that sent you their solution:" %}</dt>
<dd class="col-md-5">{{ participation.received_participation.team|default:any }}</dd>
{% if user.registration.is_admin %}
<dd class="col-xs-2">
<button class="btn btn-primary" data-toggle="modal" data-target="#defineReceivedParticipationModal">{% trans "Change" %}</button>
</dd>
{% endif %}
<dt class="col-xl-5 text-right">{% trans "Proposed solution:" %}</dt>
<dd class="col-sm-7"><a href="{{ participation.received_participation.solution.link|default:"#" }}"{% if participation.received_participation.solution.link %} target="_blank"{% endif %}>
{{ participation.received_participation.solution.link|default:novideo }}</a>
{% if participation.received_participation.solution.link %}
<button class="btn btn-info" data-toggle="modal" data-target="#displayOtherSolutionModal">{% trans "Display" %}</button>
{% endif %}
</dd>
{% if current_phase.phase_number == 2 %}
<div class="alert alert-info">
{% blocktrans trimmed %}
You received a solution about the same problem that you treated from another team.
You are now encouraged to see the video, then to ask from 3 to 6 questions about the video.
After that, you will be invited to exchange with the other team about the solution.
{% endblocktrans %}
</div>
{% for question in participation.questions.all %}
<dd class="col-md-9 text-truncate">{{ question.question }}</dd>
<dd class="col-md-3">
<button class="btn btn-primary" data-toggle="modal" data-target="#updateQuestion{{ forloop.counter }}Modal">{% trans "Change" %}</button>
</dd>
<hr>
{% endfor %}
{% if user.registration.participates %}
<button class="btn btn-success" data-toggle="modal" data-target="#addQuestionModal">
<i class="fas fa-plus-circle"></i> {% trans "Add a question" %}
</button>
{% endif %}
{% elif current_phase.phase_number == 3 %}
<div class="alert alert-info">
{% blocktrans trimmed with user_id=user.pk %}
You sent your questions to the other team about their solution. When they answer to
your questions, you will be able to exchange freely with the other team.
You can click on the Chat button, or to connect to your dedicated Matrix account:
<code>@tfjm_{{ user_id }}:tfjm.org</code>.
You can use your own Matrix client, or use the dedicated Element client:
<a href="https://element.tfjm.org">element.correpondances-maths.fr</a>
{% endblocktrans %}
</div>
{% elif current_phase.phase_number == 4 %}
<div id="solution-container">
<dl class="row">
{% trans "No video sent" as novideo %}
<dt class="col-sm-5 text-right">{% trans "Your synthesis of the exchange:" %}</dt>
<dd class="col-sm-7"><a href="{{ participation.synthesis.link|default:"#" }}"{% if participation.synthesis.link %} target="_blank"{% endif %}>
{{ participation.synthesis.link|default:novideo }}</a>
<button class="btn btn-primary" data-toggle="modal" data-target="#uploadSynthesisModal">{% trans "Upload" %}</button>
{% if participation.synthesis.link %}
<button class="btn btn-info" data-toggle="modal" data-target="#displaySynthesisModal">{% trans "Display" %}</button>
{% endif %}
</dd>
</dl>
</div>
{% endif %}
</dl>
</div>
</div>
</div>
</div>
{% endif %}
{% if user.registration.is_admin %}
{% trans "Define received video" as modal_title %}
{% trans "Update" as modal_button %}
{% url "participation:participation_receive_participation" pk=participation.pk as modal_action %}
{% include "base_modal.html" with modal_id="defineReceivedParticipation" %}
{% trans "Define team that receives your video" as modal_title %}
{% trans "Update" as modal_button %}
{% url "participation:participation_send_participation" pk=participation.pk as modal_action %}
{% include "base_modal.html" with modal_id="defineSentParticipation" %}
{% endif %}
{% trans "Upload video" as modal_title %}
{% trans "Upload" as modal_button %}
{% url "participation:upload_video" pk=participation.solution_id as modal_action %}
{% include "base_modal.html" with modal_id="uploadSolution" %}
{% trans "Display solution" as modal_title %}
{% trans "This video platform is not supported yet." as unsupported_platform %}
{% include "base_modal.html" with modal_id="displaySolution" modal_action="" modal_button="" modal_additional_class="modal-lg" modal_content=participation.solution.as_iframe|default:unsupported_platform %}
{% if user.registration.is_admin or current_phase.phase_number >= 2 %}
{% if participation.received_participation.solution.link %}
{% trans "Display solution" as modal_title %}
{% 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 %}
{% endif %}
{% endif %}
{% if user.registration.participates and current_phase.phase_number == 2 %}
{% trans "Add question" as modal_title %}
{% trans "Add" as modal_button %}
{% url "participation:add_question" pk=participation.pk as modal_action %}
{% include "base_modal.html" with modal_id="addQuestion" modal_button_type="success" %}
{% for question in participation.questions.all %}
{% with number_str=forloop.counter|stringformat:"d"%}
{% with modal_id="updateQuestion"|add:number_str %}
{% trans "Delete" as delete %}
{% with extra_modal_button='<button class="btn btn-danger" type="button" data-dismiss="modal" data-toggle="modal" data-target="#deleteQuestion'|add:number_str|add:'Modal">'|add:delete|add:"</button>"|safe %}
{% trans "Update question" as modal_title %}
{% trans "Update" as modal_button %}
{% url "participation:update_question" pk=question.pk as modal_action %}
{% include "base_modal.html" %}
{% endwith %}
{% endwith %}
{% with modal_id="deleteQuestion"|add:number_str %}
{% trans "Delete question" as modal_title %}
{% trans "Delete" as modal_button %}
{% url "participation:delete_question" pk=question.pk as modal_action %}
{% include "base_modal.html" with modal_button_type="danger" %}
{% endwith %}
{% endwith %}
{% endfor %}
{% endif %}
{% if current_phase.phase_number >= 4 %}
{% if participation.received_participation.synthesis.link %}
{% trans "Display synthesis" as modal_title %}
{% trans "This video platform is not supported yet." as unsupported_platform %}
{% include "base_modal.html" with modal_id="displayOtherSynthesis" modal_action="" modal_button="" modal_additional_class="modal-lg" modal_content=participation.received_participation.synthesis.as_iframe|default:unsupported_platform %}
{% endif %}
{% trans "Upload video" as modal_title %}
{% trans "Upload" as modal_button %}
{% url "participation:upload_video" pk=participation.synthesis_id as modal_action %}
{% include "base_modal.html" with modal_id="uploadSynthesis" %}
{% if participation.synthesis.link %}
{% trans "Display synthesis" as modal_title %}
{% trans "This video platform is not supported yet." as unsupported_platform %}
{% include "base_modal.html" with modal_id="displaySynthesis" modal_action="" modal_button="" modal_additional_class="modal-lg" modal_content=participation.synthesis.as_iframe|default:unsupported_platform %}
{% endif %}
{% endif %}
{% endblock %}
{% block extrajavascript %}
<script>
$(document).ready(function() {
{% if user.registration.is_admin %}
$('button[data-target="#defineReceivedParticipationModal"]').click(function() {
let modalBody = $("#defineReceivedParticipationModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:participation_receive_participation" pk=participation.pk %} #form-content");
});
$('button[data-target="#defineSentParticipationModal"]').click(function() {
let modalBody = $("#defineSentParticipationModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:participation_send_participation" pk=participation.pk %} #form-content");
});
{% endif %}
{% if user.registration.participates and current_phase.phase_number == 2 %}
$('button[data-target="#addQuestionModal"]').click(function() {
let modalBody = $("#addQuestionModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:add_question" pk=participation.pk %} #form-content");
});
{% for question in participation.questions.all %}
$('button[data-target="#updateQuestion{{ forloop.counter }}Modal"]').click(function() {
let modalBody = $("#updateQuestion{{ forloop.counter }}Modal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:update_question" pk=question.pk %} #form-content");
});
$('button[data-target="#deleteQuestion{{ forloop.counter }}Modal"]').click(function() {
let modalBody = $("#deleteQuestion{{ forloop.counter }}Modal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:delete_question" pk=question.pk %} #form-content");
});
{% endfor %}
{% endif %}
$('button[data-target="#uploadSolutionModal"]').click(function() {
let modalBody = $("#uploadSolutionModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:upload_video" pk=participation.solution_id %} #form-content");
});
{% if current_phase.phase_number == 4 %}
$('button[data-target="#uploadSynthesisModal"]').click(function() {
let modalBody = $("#uploadSynthesisModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:upload_video" pk=participation.synthesis_id %} #form-content");
});
{% endif %}
});
</script>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post" action="{% url "participation:update_phase" pk=object.pk %}">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-primary" type="submit">{% trans "Update" %}</button>
</form>
{% endblock content %}

View File

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% load django_tables2 i18n static %}
{% block extracss %}
<link rel="stylesheet" href="{% static "bootstrap_datepicker_plus/css/datepicker-widget.css" %}">
{% endblock %}
{% block contenttitle %}
<h2>{% trans "Calendar" %}</h2>
{% endblock %}
{% block content %}
<div id="form-content">
{% render_table table %}
{% trans "Update phase" as modal_title %}
{% trans "Update" as modal_button %}
{% include "base_modal.html" with modal_id="updatePhase" %}
</div>
{% endblock %}
{% block extrajavascript %}
{% if user.registration.is_admin %}
<script>
$("tr").click(function () {
let modalBody = $("#updatePhaseModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:calendar" %}" + $(this).data("id") + "/ #form-content");
$("#updatePhase-form").attr("action", "{% url "participation:calendar" %}" + $(this).data("id") + "/")
$("#updatePhaseModal").modal();
})
</script>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post">
<div id="form-content">
<div class="alert alert-danger">
{% trans "Are you sure you want to delete this question?" %}
</div>
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-danger" type="submit">{% trans "Delete" %}</button>
</form>
{% endblock content %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-primary" type="submit">{% trans "Send" %}</button>
</form>
{% endblock content %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-primary" type="submit">{% trans "Update" %}</button>
</form>
{% endblock content %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-primary" type="submit">{% trans "Update" %}</button>
</form>
{% endblock content %}

View File

@ -0,0 +1,148 @@
{% extends "base.html" %}
{% load i18n %}
{% load crispy_forms_filters %}
{% block content %}
<div class="card bg-light shadow">
<div class="card-header text-center">
<h4>{{ team.name }}</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-6 text-right">{% trans "Name:" %}</dt>
<dd class="col-sm-6">{{ team.name }}</dd>
<dt class="col-sm-6 text-right">{% trans "Trigram:" %}</dt>
<dd class="col-sm-6">{{ team.trigram }}</dd>
<dt class="col-sm-6 text-right">{% trans "Email:" %}</dt>
<dd class="col-sm-6"><a href="mailto:{{ team.email }}">{{ team.email }}</a></dd>
<dt class="col-sm-6 text-right">{% trans "Access code:" %}</dt>
<dd class="col-sm-6">{{ team.access_code }}</dd>
<dt class="col-sm-6 text-right">{% trans "Coachs:" %}</dt>
<dd class="col-sm-6">
{% for coach in team.coachs.all %}
<a href="{% url "registration:user_detail" pk=coach.user.pk %}">{{ coach }}</a>{% if not forloop.last %},{% endif %}
{% empty %}
{% trans "any" %}
{% endfor %}
</dd>
<dt class="col-sm-6 text-right">{% trans "Participants:" %}</dt>
<dd class="col-sm-6">
{% for student in team.students.all %}
<a href="{% url "registration:user_detail" pk=student.user.pk %}">{{ student }}</a>{% if not forloop.last %},{% endif %}
{% empty %}
{% trans "any" %}
{% endfor %}
</dd>
<dt class="col-sm-6 text-right">{% trans "Chosen problem:" %}</dt>
{% trans "any" as any %}
<dd class="col-sm-6">{{ team.participation.get_problem_display|default:any }}</dd>
<dt class="col-sm-6 text-right">{% trans "Grant Animath to publish our video:" %}</dt>
<dd class="col-sm-6">{{ team.grant_animath_access_videos|yesno }}</dd>
<dt class="col-sm-6 text-right">{% trans "Authorizations:" %}</dt>
<dd class="col-sm-6">
{% for student in team.students.all %}
{% if student.photo_authorization %}
<a href="{{ student.photo_authorization.url }}" data-turbolinks="false">{{ student }}</a>{% if not forloop.last %},{% endif %}
{% else %}
{{ student }} ({% trans "Not uploaded yet" %}){% if not forloop.last %},{% endif %}
{% endif %}
{% endfor %}
</dd>
</dl>
</div>
<div class="card-footer text-center">
<button class="btn btn-primary" data-toggle="modal" data-target="#updateTeamModal">{% trans "Update" %}</button>
{% if not team.participation.valid %}
<button class="btn btn-danger" data-toggle="modal" data-target="#leaveTeamModal">{% trans "Leave" %}</button>
{% endif %}
</div>
</div>
<hr>
{% if team.participation.valid %}
<div class="text-center">
<a class="btn btn-info" href="{% url "participation:participation_detail" pk=team.participation.pk %}">
<i class="fas fa-video"></i> {% trans "Access to team participation" %} <i class="fas fa-video"></i>
</a>
</div>
{% elif team.participation.valid == None %} {# Team did not ask for validation #}
{% if user.registration.participates %}
{% if can_validate %}
<div class="alert alert-info">
{% trans "Your team has at least 3 members and all photo authorizations were given: the team can be validated." %}
<div class="text-center">
<form method="post">
{% csrf_token %}
{{ request_validation_form|crispy }}
<button class="btn btn-success" name="request-validation">{% trans "Submit my team to validation" %}</button>
</form>
</div>
</div>
{% else %}
<div class="alert alert-warning">
{% trans "Your team must be composed of 3 members and each member must upload its photo authorization and confirm its email address." %}
</div>
{% endif %}
{% else %}
<div class="alert alert-warning">
{% trans "This team didn't ask for validation yet." %}
</div>
{% endif %}
{% else %} {# Team is waiting for validation #}
{% if user.registration.participates %}
<div class="alert alert-warning">
{% trans "Your validation is pending." %}
</div>
{% else %}
<div class="alert alert-info">
{% trans "The team requested to be validated. You may now control the authorizations and confirm that they can participate." %}
</div>
<form method="post">
{% csrf_token %}
{{ validation_form|crispy }}
<div class="input-group btn-group">
<button class="btn btn-success" name="validate" type="submit">{% trans "Validate" %}</button>
<button class="btn btn-danger" name="invalidate" type="submit">{% trans "Invalidate" %}</button>
</div>
</form>
{% endif %}
{% endif %}
{% trans "Update team" as modal_title %}
{% trans "Update" as modal_button %}
{% url "participation:update_team" pk=team.pk as modal_action %}
{% include "base_modal.html" with modal_id="updateTeam" %}
{% trans "Leave team" as modal_title %}
{% trans "Leave" as modal_button %}
{% url "participation:team_leave" as modal_action %}
{% include "base_modal.html" with modal_id="leaveTeam" modal_button_type="danger" %}
{% endblock %}
{% block extrajavascript %}
<script>
$(document).ready(function() {
$('button[data-target="#updateTeamModal"]').click(function() {
let modalBody = $("#updateTeamModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:update_team" pk=team.pk %} #form-content");
});
$('button[data-target="#leaveTeamModal"]').click(function() {
let modalBody = $("#leaveTeamModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:team_leave" %} #form-content");
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<form method="post">
<div class="alert alert-warning" id="form-content">
{% csrf_token %}
{% trans "Are you sure that you want to leave this team?" %}
</div>
<button class="btn btn-danger" type="submit">{% trans "Leave" %}</button>
</form>
{% endblock %}

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,15 @@
{% extends "base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
{{ participation_form|crispy }}
</div>
<button class="btn btn-success" type="submit">{% trans "Update" %}</button>
</form>
{% endblock content %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-success" type="submit">{% trans "Upload" %}</button>
</form>
{% endblock content %}

View File

@ -0,0 +1,8 @@
<div style="position: relative; width: 100%; padding-bottom: 56.25%;">
<iframe src="https://www.youtube.com/embed/{{ youtube_code }}"
frameborder="0"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>
</div>

View File

@ -0,0 +1,4 @@
{{ object.team.name }}
{{ object.team.trigram }}
{{ object.problem }}
{{ object.get_problem_display }}

View File

@ -0,0 +1,2 @@
{{ object.name }}
{{ object.trigram }}

View File

@ -0,0 +1,5 @@
{{ object.link }}
{{ object.participation.team.name }}
{{ object.participation.team.trigram }}
{{ object.participation.problem }}
{{ object.participation.get_problem_display }}

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)

853
apps/participation/tests.py Normal file
View File

@ -0,0 +1,853 @@
# 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.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.core.management import call_command
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from registration.models import CoachRegistration, StudentRegistration
from .models import Participation, Phase, Question, Team
class TestStudentParticipation(TestCase):
def setUp(self) -> None:
self.superuser = User.objects.create_superuser(
username="admin",
email="admin@example.com",
password="toto1234",
)
self.user = User.objects.create(
first_name="Toto",
last_name="Toto",
email="toto@example.com",
password="toto",
)
StudentRegistration.objects.create(
user=self.user,
student_class=12,
school="Earth",
give_contact_to_animath=True,
email_confirmed=True,
)
self.team = Team.objects.create(
name="Super team",
trigram="AAA",
access_code="azerty",
grant_animath_access_videos=True,
)
self.question = Question.objects.create(participation=self.team.participation,
question="Pourquoi l'existence précède l'essence ?")
self.client.force_login(self.user)
self.second_user = User.objects.create(
first_name="Lalala",
last_name="Lalala",
email="lalala@example.com",
password="lalala",
)
StudentRegistration.objects.create(
user=self.second_user,
student_class=11,
school="Moon",
give_contact_to_animath=True,
email_confirmed=True,
)
self.second_team = Team.objects.create(
name="Poor team",
trigram="FFF",
access_code="qwerty",
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):
"""
Load Django-admin pages.
"""
self.client.force_login(self.superuser)
# Test team pages
response = self.client.get(reverse("admin:index") + "participation/team/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index")
+ f"participation/team/{self.team.pk}/change/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") +
f"r/{ContentType.objects.get_for_model(Team).id}/"
f"{self.team.pk}/")
self.assertRedirects(response, "http://" + Site.objects.get().domain +
str(self.team.get_absolute_url()), 302, 200)
# Test participation pages
self.team.participation.valid = True
self.team.participation.save()
response = self.client.get(reverse("admin:index") + "participation/participation/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index")
+ f"participation/participation/{self.team.participation.pk}/change/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") +
f"r/{ContentType.objects.get_for_model(Participation).id}/"
f"{self.team.participation.pk}/")
self.assertRedirects(response, "http://" + Site.objects.get().domain +
str(self.team.participation.get_absolute_url()), 302, 200)
# Test video pages
response = self.client.get(reverse("admin:index") + "participation/video/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index")
+ f"participation/video/{self.team.participation.solution.pk}/change/")
self.assertEqual(response.status_code, 200)
# Test question pages
response = self.client.get(reverse("admin:index") + "participation/question/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index")
+ f"participation/question/{self.question.pk}/change/")
self.assertEqual(response.status_code, 200)
# Test phase pages
response = self.client.get(reverse("admin:index") + "participation/phase/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") + "participation/phase/1/change/")
self.assertEqual(response.status_code, 200)
def test_create_team(self):
"""
Try to create a team.
"""
response = self.client.get(reverse("participation:create_team"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:create_team"), data=dict(
name="Test team",
trigram="123",
grant_animath_access_videos=False,
))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:create_team"), data=dict(
name="Test team",
trigram="TES",
grant_animath_access_videos=False,
))
self.assertTrue(Team.objects.filter(trigram="TES").exists())
team = Team.objects.get(trigram="TES")
self.assertRedirects(response, reverse("participation:team_detail", args=(team.pk,)), 302, 200)
# Already in a team
response = self.client.post(reverse("participation:create_team"), data=dict(
name="Test team 2",
trigram="TET",
grant_animath_access_videos=False,
))
self.assertEqual(response.status_code, 403)
def test_join_team(self):
"""
Try to join an existing team.
"""
response = self.client.get(reverse("participation:join_team"))
self.assertEqual(response.status_code, 200)
team = Team.objects.create(name="Test", trigram="TES")
response = self.client.post(reverse("participation:join_team"), data=dict(
access_code="éééééé",
))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:join_team"), data=dict(
access_code=team.access_code,
))
self.assertRedirects(response, reverse("participation:team_detail", args=(team.pk,)), 302, 200)
self.assertTrue(Team.objects.filter(trigram="TES").exists())
# Already joined
response = self.client.post(reverse("participation:join_team"), data=dict(
access_code=team.access_code,
))
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):
"""
Test redirection.
"""
response = self.client.get(reverse("participation:my_team_detail"))
self.assertTrue(response.status_code, 200)
def test_team_detail(self):
"""
Try to display the information of a team.
"""
self.user.registration.team = self.team
self.user.registration.save()
response = self.client.get(reverse("participation:my_team_detail"))
self.assertRedirects(response, reverse("participation:team_detail", args=(self.team.pk,)), 302, 200)
response = self.client.get(reverse("participation:team_detail", args=(self.team.pk,)))
self.assertEqual(response.status_code, 200)
# Can't see other teams
self.second_user.registration.team = self.second_team
self.second_user.registration.save()
self.client.force_login(self.second_user)
response = self.client.get(reverse("participation:team_detail", args=(self.team.participation.pk,)))
self.assertEqual(response.status_code, 403)
def test_request_validate_team(self):
"""
The team ask for validation.
"""
self.user.registration.team = self.team
self.user.registration.save()
second_user = User.objects.create(
first_name="Blublu",
last_name="Blublu",
email="blublu@example.com",
password="blublu",
)
StudentRegistration.objects.create(
user=second_user,
student_class=12,
school="Jupiter",
give_contact_to_animath=True,
email_confirmed=True,
team=self.team,
photo_authorization="authorization/photo/mai-linh",
)
third_user = User.objects.create(
first_name="Zupzup",
last_name="Zupzup",
email="zupzup@example.com",
password="zupzup",
)
StudentRegistration.objects.create(
user=third_user,
student_class=10,
school="Sun",
give_contact_to_animath=False,
email_confirmed=True,
team=self.team,
photo_authorization="authorization/photo/yohann",
)
self.client.force_login(self.superuser)
# Admin users can't ask for validation
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="RequestValidationForm",
engagement=True,
))
self.assertEqual(resp.status_code, 200)
self.client.force_login(self.user)
self.assertIsNone(self.team.participation.valid)
resp = self.client.get(reverse("participation:team_detail", args=(self.team.pk,)))
self.assertEqual(resp.status_code, 200)
self.assertFalse(resp.context["can_validate"])
# Can't validate
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="RequestValidationForm",
engagement=True,
))
self.assertEqual(resp.status_code, 200)
self.user.registration.photo_authorization = "authorization/photo/ananas"
self.user.registration.save()
resp = self.client.get(reverse("participation:team_detail", args=(self.team.pk,)))
self.assertEqual(resp.status_code, 200)
self.assertFalse(resp.context["can_validate"])
self.team.participation.problem = 2
self.team.participation.save()
resp = self.client.get(reverse("participation:team_detail", args=(self.team.pk,)))
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.context["can_validate"])
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="RequestValidationForm",
engagement=True,
))
self.assertRedirects(resp, reverse("participation:team_detail", args=(self.team.pk,)), 302, 200)
self.team.participation.refresh_from_db()
self.assertFalse(self.team.participation.valid)
self.assertIsNotNone(self.team.participation.valid)
# Team already asked for validation
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="RequestValidationForm",
engagement=True,
))
self.assertEqual(resp.status_code, 200)
def test_validate_team(self):
"""
A team asked for validation. Try to validate it.
"""
self.team.participation.valid = False
self.team.participation.save()
# No right to do that
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="ValidateParticipationForm",
message="J'ai 4 ans",
validate=True,
))
self.assertEqual(resp.status_code, 200)
self.client.force_login(self.superuser)
resp = self.client.get(reverse("participation:team_detail", args=(self.team.pk,)))
self.assertEqual(resp.status_code, 200)
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="ValidateParticipationForm",
message="Woops I didn't said anything",
))
self.assertEqual(resp.status_code, 200)
# Test invalidate team
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="ValidateParticipationForm",
message="Wsh nope",
invalidate=True,
))
self.assertRedirects(resp, reverse("participation:team_detail", args=(self.team.pk,)), 302, 200)
self.team.participation.refresh_from_db()
self.assertIsNone(self.team.participation.valid)
# Team did not ask validation
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="ValidateParticipationForm",
message="Bienvenue ça va être trop cool",
validate=True,
))
self.assertEqual(resp.status_code, 200)
self.team.participation.valid = False
self.team.participation.save()
# Test validate team
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="ValidateParticipationForm",
message="Bienvenue ça va être trop cool",
validate=True,
))
self.assertRedirects(resp, reverse("participation:team_detail", args=(self.team.pk,)), 302, 200)
self.team.participation.refresh_from_db()
self.assertTrue(self.team.participation.valid)
def test_update_team(self):
"""
Try to update team information.
"""
self.user.registration.team = self.team
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,)))
self.assertEqual(response.status_code, 200)
# Form is invalid
response = self.client.post(reverse("participation:update_team", args=(self.team.pk,)), data=dict(
name="Updated team name",
trigram="BBB",
grant_animath_access_videos=True,
problem=42,
))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:update_team", args=(self.team.pk,)), data=dict(
name="Updated team name",
trigram="BBB",
grant_animath_access_videos=True,
problem=3,
))
self.assertRedirects(response, reverse("participation:team_detail", args=(self.team.pk,)), 302, 200)
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):
"""
Ensure a permission denied when we search my team participation when we are in no team.
"""
response = self.client.get(reverse("participation:my_participation_detail"))
self.assertEqual(response.status_code, 403)
def test_participation_detail(self):
"""
Try to display the detail of a team participation.
"""
self.user.registration.team = self.team
self.user.registration.save()
# Can't see the participation if it is not valid
response = self.client.get(reverse("participation:my_participation_detail"))
self.assertRedirects(response,
reverse("participation:participation_detail", args=(self.team.participation.pk,)),
302, 403)
self.team.participation.valid = True
self.team.participation.save()
response = self.client.get(reverse("participation:my_participation_detail"))
self.assertRedirects(response,
reverse("participation:participation_detail", args=(self.team.participation.pk,)),
302, 200)
response = self.client.get(reverse("participation:participation_detail", args=(self.team.participation.pk,)))
self.assertEqual(response.status_code, 200)
# Can't see other participations
self.second_user.registration.team = self.second_team
self.second_user.registration.save()
self.client.force_login(self.second_user)
response = self.client.get(reverse("participation:participation_detail", args=(self.team.participation.pk,)))
self.assertEqual(response.status_code, 403)
def test_upload_video(self):
"""
Try to send a solution video link.
"""
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:upload_video", args=(self.team.participation.solution.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:upload_video", args=(self.team.participation.solution.pk,)),
data=dict(link="https://youtube.com/watch?v=73nsrixx7eI"))
self.assertRedirects(response,
reverse("participation:participation_detail", args=(self.team.participation.id,)),
302, 200)
self.team.participation.refresh_from_db()
self.assertEqual(self.team.participation.solution.platform, "youtube")
self.assertEqual(self.team.participation.solution.youtube_code, "73nsrixx7eI")
response = self.client.get(reverse("participation:participation_detail", args=(self.team.participation.pk,)))
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):
"""
Load personal pages and ensure that these are protected.
"""
self.user.registration.team = self.team
self.user.registration.save()
resp = self.client.get(reverse("participation:team_detail", args=(self.second_team.pk,)))
self.assertEqual(resp.status_code, 403)
resp = self.client.get(reverse("participation:update_team", args=(self.second_team.pk,)))
self.assertEqual(resp.status_code, 403)
resp = self.client.get(reverse("participation:team_authorizations", args=(self.second_team.pk,)))
self.assertEqual(resp.status_code, 403)
resp = self.client.get(reverse("participation:participation_detail", args=(self.second_team.pk,)))
self.assertEqual(resp.status_code, 403)
resp = self.client.get(reverse("participation:upload_video",
args=(self.second_team.participation.solution.pk,)))
self.assertEqual(resp.status_code, 403)
resp = self.client.get(reverse("participation:upload_video",
args=(self.second_team.participation.synthesis.pk,)))
self.assertEqual(resp.status_code, 403)
resp = self.client.get(reverse("participation:add_question", args=(self.second_team.pk,)))
self.assertEqual(resp.status_code, 403)
question = Question.objects.create(participation=self.second_team.participation,
question=self.question.question)
resp = self.client.get(reverse("participation:update_question", args=(question.pk,)))
self.assertEqual(resp.status_code, 403)
resp = self.client.get(reverse("participation:delete_question", args=(question.pk,)))
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()
call_command('fix_matrix_channels')
call_command('setup_third_phase')
class TestAdmin(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_superuser(
username="admin@example.com",
email="admin@example.com",
password="admin",
)
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):
"""
Ensure that an admin can't create a team.
"""
response = self.client.post(reverse("participation:create_team"), data=dict(
name="Test team",
trigram="TES",
grant_animath_access_videos=False,
))
self.assertEqual(response.status_code, 403)
def test_join_team_forbidden(self):
"""
Ensure that an admin can't join a team.
"""
team = Team.objects.create(name="Test", trigram="TES")
response = self.client.post(reverse("participation:join_team"), data=dict(
access_code=team.access_code,
))
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):
"""
Ensure that an admin can't access to "My team".
"""
response = self.client.get(reverse("participation:my_team_detail"))
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

@ -0,0 +1,37 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from django.views.generic import TemplateView
from .views import CalendarView, CreateQuestionView, CreateTeamView, DeleteQuestionView, JoinTeamView, \
MyParticipationDetailView, MyTeamDetailView, ParticipationDetailView, PhaseUpdateView, \
SetParticipationReceiveParticipationView, SetParticipationSendParticipationView, TeamAuthorizationsView, \
TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, UpdateQuestionView, UploadVideoView
app_name = "participation"
urlpatterns = [
path("create_team/", CreateTeamView.as_view(), name="create_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/<int:pk>/", TeamDetailView.as_view(), name="team_detail"),
path("team/<int:pk>/update/", TeamUpdateView.as_view(), name="update_team"),
path("team/<int:pk>/authorizations/", TeamAuthorizationsView.as_view(), name="team_authorizations"),
path("team/leave/", TeamLeaveView.as_view(), name="team_leave"),
path("detail/", MyParticipationDetailView.as_view(), name="my_participation_detail"),
path("detail/<int:pk>/", ParticipationDetailView.as_view(), name="participation_detail"),
path("detail/upload-video/<int:pk>/", UploadVideoView.as_view(), name="upload_video"),
path("detail/<int:pk>/receive-participation/", SetParticipationReceiveParticipationView.as_view(),
name="participation_receive_participation"),
path("detail/<int:pk>/send-participation/", SetParticipationSendParticipationView.as_view(),
name="participation_send_participation"),
path("detail/<int:pk>/add-question/", CreateQuestionView.as_view(), name="add_question"),
path("update-question/<int:pk>/", UpdateQuestionView.as_view(), name="update_question"),
path("delete-question/<int:pk>/", DeleteQuestionView.as_view(), name="delete_question"),
path("calendar/", CalendarView.as_view(), name="calendar"),
path("calendar/<int:pk>/", PhaseUpdateView.as_view(), name="update_phase"),
path("chat/", TemplateView.as_view(template_name="participation/chat.html"), name="chat")
]

546
apps/participation/views.py Normal file
View File

@ -0,0 +1,546 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from io import BytesIO
from zipfile import ZipFile
from tfjm.lists import get_sympa_client
from tfjm.matrix import Matrix
from tfjm.views import AdminMixin
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.sites.models import Site
from django.core.exceptions import PermissionDenied
from django.core.mail import send_mail
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DeleteView, DetailView, FormView, RedirectView, TemplateView, UpdateView
from django.views.generic.edit import FormMixin, ProcessFormView
from django_tables2 import SingleTableView
from magic import Magic
from registration.models import AdminRegistration
from .forms import JoinTeamForm, ParticipationForm, PhaseForm, QuestionForm, \
ReceiveParticipationForm, RequestValidationForm, SendParticipationForm, TeamForm, \
UploadVideoForm, ValidateParticipationForm
from .models import Participation, Phase, Question, Team, Video
from .tables import CalendarTable, TeamTable
class CreateTeamView(LoginRequiredMixin, CreateView):
"""
Display the page to create a team for new users.
"""
model = Team
form_class = TeamForm
extra_context = dict(title=_("Create team"))
template_name = "participation/create_team.html"
def dispatch(self, request, *args, **kwargs):
user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
registration = user.registration
if not registration.participates:
raise PermissionDenied(_("You don't participate, so you can't create a team."))
elif registration.team:
raise PermissionDenied(_("You are already in a team."))
return super().dispatch(request, *args, **kwargs)
@transaction.atomic
def form_valid(self, form):
"""
When a team is about to be created, the user automatically
joins the team, a mailing list got created and the user is
automatically subscribed to this mailing list, and finally
a Matrix room is created and the user is invited in this room.
"""
ret = super().form_valid(form)
# The user joins the team
user = self.request.user
registration = user.registration
registration.team = form.instance
registration.save()
# Subscribe the user mail address to the team mailing list
get_sympa_client().subscribe(user.email, f"equipe-{form.instance.trigram.lower()}", False,
f"{user.first_name} {user.last_name}")
# Invite the user in the team Matrix room
Matrix.invite(f"#equipe-{form.instance.trigram.lower()}:tfjm.org",
f"@{user.registration.matrix_username}:tfjm.org")
return ret
def get_success_url(self):
return reverse_lazy("participation:team_detail", args=(self.object.pk,))
class JoinTeamView(LoginRequiredMixin, FormView):
"""
Participants can join a team with the access code of the team.
"""
model = Team
form_class = JoinTeamForm
extra_context = dict(title=_("Join team"))
template_name = "participation/create_team.html"
def dispatch(self, request, *args, **kwargs):
user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
registration = user.registration
if not registration.participates:
raise PermissionDenied(_("You don't participate, so you can't create a team."))
elif registration.team:
raise PermissionDenied(_("You are already in a team."))
return super().dispatch(request, *args, **kwargs)
@transaction.atomic
def form_valid(self, form):
"""
When a user joins a team, the user is automatically subscribed to
the team mailing list,the user is invited in the team Matrix room.
"""
self.object = form.instance
ret = super().form_valid(form)
# Join the team
user = self.request.user
registration = user.registration
registration.team = form.instance
registration.save()
# Subscribe to the team mailing list
get_sympa_client().subscribe(user.email, f"equipe-{form.instance.trigram.lower()}", False,
f"{user.first_name} {user.last_name}")
# Invite the user in the team Matrix room
Matrix.invite(f"#equipe-{form.instance.trigram.lower()}:tfjm.org",
f"@{user.registration.matrix_username}:tfjm.org")
return ret
def get_success_url(self):
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):
"""
Redirect to the detail of the team in which the user is.
"""
def get_redirect_url(self, *args, **kwargs):
user = self.request.user
registration = user.registration
if registration.participates:
if registration.team:
return reverse_lazy("participation:team_detail", args=(registration.team_id,))
raise PermissionDenied(_("You are not in a team."))
raise PermissionDenied(_("You don't participate, so you don't have any team."))
class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView):
"""
Display the detail of a team.
"""
model = Team
def get(self, request, *args, **kwargs):
user = request.user
self.object = self.get_object()
# Ensure that the user is an admin or a member of the team
if user.registration.is_admin or user.registration.participates and \
user.registration.team and user.registration.team.pk == kwargs["pk"]:
return super().get(request, *args, **kwargs)
raise PermissionDenied
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
team = self.get_object()
context["title"] = _("Detail of team {trigram}").format(trigram=self.object.trigram)
context["request_validation_form"] = RequestValidationForm(self.request.POST or None)
context["validation_form"] = ValidateParticipationForm(self.request.POST or None)
# A team is complete when there are at least 3 members that have sent their photo authorization
# and confirmed their email address
context["can_validate"] = team.students.count() >= 3 and \
all(r.email_confirmed for r in team.students.all()) and \
all(r.photo_authorization for r in team.students.all()) and \
team.participation.problem
return context
def get_form_class(self):
if not self.request.POST:
return RequestValidationForm
elif self.request.POST["_form_type"] == "RequestValidationForm":
return RequestValidationForm
elif self.request.POST["_form_type"] == "ValidateParticipationForm":
return ValidateParticipationForm
def form_valid(self, form):
self.object = self.get_object()
if isinstance(form, RequestValidationForm):
return self.handle_request_validation(form)
elif isinstance(form, ValidateParticipationForm):
return self.handle_validate_participation(form)
def handle_request_validation(self, form):
"""
A team requests to be validated
"""
if not self.request.user.registration.participates:
form.add_error(None, _("You don't participate, so you can't request the validation of the team."))
return self.form_invalid(form)
if self.object.participation.valid is not None:
form.add_error(None, _("The validation of the team is already done or pending."))
return self.form_invalid(form)
if not self.get_context_data()["can_validate"]:
form.add_error(None, _("The team can't be validated: missing email address confirmations, "
"photo authorizations, people or the chosen problem is not set."))
return self.form_invalid(form)
self.object.participation.valid = False
self.object.participation.save()
for admin in AdminRegistration.objects.all():
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_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)
return super().form_valid(form)
def handle_validate_participation(self, form):
"""
An admin validates the team (or not)
"""
if not self.request.user.registration.is_admin:
form.add_error(None, _("You are not an administrator."))
return self.form_invalid(form)
elif self.object.participation.valid is not False:
form.add_error(None, _("This team has no pending validation."))
return self.form_invalid(form)
if "validate" in self.request.POST:
self.object.participation.valid = True
self.object.participation.save()
mail_context = dict(team=self.object, message=form.cleaned_data["message"])
mail_plain = render_to_string("participation/mails/team_validated.txt", 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)
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:
self.object.participation.valid = None
self.object.participation.save()
mail_context = dict(team=self.object, message=form.cleaned_data["message"])
mail_plain = render_to_string("participation/mails/team_not_validated.txt", mail_context)
mail_html = render_to_string("participation/mails/team_not_validated.html", mail_context)
send_mail("[Corres2math] Équipe non validée", mail_plain, None, [self.object.email],
html_message=mail_html)
else:
form.add_error(None, _("You must specify if you validate the registration or not."))
return self.form_invalid(form)
return super().form_valid(form)
def get_success_url(self):
return self.request.path
class TeamUpdateView(LoginRequiredMixin, UpdateView):
"""
Update the detail of a team
"""
model = Team
form_class = TeamForm
template_name = "participation/update_team.html"
def dispatch(self, request, *args, **kwargs):
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 and \
user.registration.team.pk == kwargs["pk"]:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["participation_form"] = ParticipationForm(data=self.request.POST or None,
instance=self.object.participation)
context["title"] = _("Update team {trigram}").format(trigram=self.object.trigram)
return context
@transaction.atomic
def form_valid(self, form):
participation_form = ParticipationForm(data=self.request.POST or None, instance=self.object.participation)
if not participation_form.is_valid():
return self.form_invalid(form)
participation_form.save()
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("participation:team_detail", args=(self.object.pk,))
class TeamAuthorizationsView(LoginRequiredMixin, DetailView):
"""
Get as a ZIP archive all the authorizations that are sent
"""
model = Team
def dispatch(self, request, *args, **kwargs):
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"]:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def get(self, request, *args, **kwargs):
team = self.get_object()
output = BytesIO()
zf = ZipFile(output, "w")
for student in team.students.all():
magic = Magic(mime=True)
mime_type = magic.from_file("media/" + student.photo_authorization.name)
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
zf.write("media/" + student.photo_authorization.name,
_("Photo authorization of {student}.{ext}").format(student=str(student), ext=ext))
zf.close()
response = HttpResponse(content_type="application/zip")
response["Content-Disposition"] = "attachment; filename=\"{filename}\"" \
.format(filename=_("Photo authorizations of team {trigram}.zip").format(trigram=team.trigram))
response.write(output.getvalue())
return response
class TeamLeaveView(LoginRequiredMixin, TemplateView):
"""
A team member leaves a team
"""
template_name = "participation/team_leave.html"
extra_context = dict(title=_("Leave team"))
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if not request.user.registration.participates or not request.user.registration.team:
raise PermissionDenied(_("You are not in a team."))
if request.user.registration.team.participation.valid:
raise PermissionDenied(_("The team is already validated or the validation is pending."))
return super().dispatch(request, *args, **kwargs)
@transaction.atomic()
def post(self, request, *args, **kwargs):
"""
When the team is left, the user is unsubscribed from the team mailing list
and kicked from the team room.
"""
team = request.user.registration.team
request.user.registration.team = None
request.user.registration.save()
get_sympa_client().unsubscribe(request.user.email, f"equipe-{team.trigram.lower()}", False)
Matrix.kick(f"#equipe-{team.trigram.lower()}:tfjm.org",
f"@{request.user.registration.matrix_username}:tfjm.org",
"Équipe quittée")
if team.students.count() + team.coachs.count() == 0:
team.delete()
return redirect(reverse_lazy("index"))
class MyParticipationDetailView(LoginRequiredMixin, RedirectView):
"""
Redirects to the detail view of the participation of the team.
"""
def get_redirect_url(self, *args, **kwargs):
user = self.request.user
registration = user.registration
if registration.participates:
if registration.team:
return reverse_lazy("participation:participation_detail", args=(registration.team.participation.id,))
raise PermissionDenied(_("You are not in a team."))
raise PermissionDenied(_("You don't participate, so you don't have any team."))
class ParticipationDetailView(LoginRequiredMixin, DetailView):
"""
Display detail about the participation of a team, and manage the video submission.
"""
model = Participation
def dispatch(self, request, *args, **kwargs):
user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
if not self.get_object().valid:
raise PermissionDenied(_("The team is not validated yet."))
if user.registration.is_admin or user.registration.participates \
and user.registration.team.participation \
and user.registration.team.participation.pk == kwargs["pk"]:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = lambda: _("Participation of team {trigram}").format(trigram=self.object.team.trigram)
context["current_phase"] = Phase.current_phase()
return context
class SetParticipationReceiveParticipationView(AdminMixin, UpdateView):
"""
Define the solution that a team will receive.
"""
model = Participation
form_class = ReceiveParticipationForm
template_name = "participation/receive_participation_form.html"
def get_success_url(self):
return reverse_lazy("participation:participation_detail", args=(self.kwargs["pk"],))
class SetParticipationSendParticipationView(AdminMixin, UpdateView):
"""
Define the team where the solution will be sent.
"""
model = Participation
form_class = SendParticipationForm
template_name = "participation/send_participation_form.html"
def get_success_url(self):
return reverse_lazy("participation:participation_detail", args=(self.kwargs["pk"],))
class CreateQuestionView(LoginRequiredMixin, CreateView):
"""
Ask a question to another team.
"""
participation: Participation
model = Question
form_class = QuestionForm
extra_context = dict(title=_("Create question"))
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
self.participation = Participation.objects.get(pk=kwargs["pk"])
if request.user.registration.is_admin or \
request.user.registration.participates and \
self.participation.valid and \
request.user.registration.team.pk == self.participation.team_id:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def form_valid(self, form):
form.instance.participation = self.participation
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("participation:participation_detail", args=(self.participation.pk,))
class UpdateQuestionView(LoginRequiredMixin, UpdateView):
"""
Edit a question.
"""
model = Question
form_class = QuestionForm
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
if not request.user.is_authenticated:
return self.handle_no_permission()
if request.user.registration.is_admin or \
request.user.registration.participates and \
self.object.participation.valid and \
request.user.registration.team.pk == self.object.participation.team_id:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def get_success_url(self):
return reverse_lazy("participation:participation_detail", args=(self.object.participation.pk,))
class DeleteQuestionView(LoginRequiredMixin, DeleteView):
"""
Remove a question.
"""
model = Question
extra_context = dict(title=_("Delete question"))
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
if not request.user.is_authenticated:
return self.handle_no_permission()
if request.user.registration.is_admin or \
request.user.registration.participates and \
self.object.participation.valid and \
request.user.registration.team.pk == self.object.participation.team_id:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def get_success_url(self):
return reverse_lazy("participation:participation_detail", args=(self.object.participation.pk,))
class UploadVideoView(LoginRequiredMixin, UpdateView):
"""
Upload a solution video for a team.
"""
model = Video
form_class = UploadVideoForm
template_name = "participation/upload_video.html"
extra_context = dict(title=_("Upload video"))
def dispatch(self, request, *args, **kwargs):
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.participation.pk == self.get_object().participation.pk:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def get_success_url(self):
return reverse_lazy("participation:participation_detail", args=(self.object.participation.pk,))
class CalendarView(SingleTableView):
"""
Display the calendar of the action.
"""
table_class = CalendarTable
model = Phase
extra_context = dict(title=_("Calendar"))
class PhaseUpdateView(AdminMixin, UpdateView):
"""
Update a phase of the calendar, if we have sufficient rights.
"""
model = Phase
form_class = PhaseForm
extra_context = dict(title=_("Calendar update"))
def get_success_url(self):
return reverse_lazy("participation:calendar")

View File

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

View File

@ -0,0 +1,29 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicParentModelAdmin
from .models import AdminRegistration, CoachRegistration, Registration, StudentRegistration
@admin.register(Registration)
class RegistrationAdmin(PolymorphicParentModelAdmin):
child_models = (StudentRegistration, CoachRegistration, AdminRegistration,)
list_display = ("user", "type", "email_confirmed",)
polymorphic_list = True
@admin.register(StudentRegistration)
class StudentRegistrationAdmin(PolymorphicChildModelAdmin):
pass
@admin.register(CoachRegistration)
class CoachRegistrationAdmin(PolymorphicChildModelAdmin):
pass
@admin.register(AdminRegistration)
class AdminRegistrationAdmin(PolymorphicChildModelAdmin):
pass

23
apps/registration/apps.py Normal file
View File

@ -0,0 +1,23 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.db.models.signals import post_save, pre_save
class RegistrationConfig(AppConfig):
"""
Registration app contains the detail about users only.
"""
name = 'registration'
def ready(self):
from registration.signals import create_admin_registration, invite_to_public_rooms, \
set_username, send_email_link
pre_save.connect(set_username, "auth.User")
pre_save.connect(send_email_link, "auth.User")
post_save.connect(create_admin_registration, "auth.User")
post_save.connect(invite_to_public_rooms, "registration.Registration")
post_save.connect(invite_to_public_rooms, "registration.StudentRegistration")
post_save.connect(invite_to_public_rooms, "registration.CoachRegistration")
post_save.connect(invite_to_public_rooms, "registration.AdminRegistration")

17
apps/registration/auth.py Normal file
View File

@ -0,0 +1,17 @@
# 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): # pragma: no cover
"""
Override Django Auth User model to define a custom Matrix username.
"""
def attributs(self):
d = super().attributs()
if self.user:
d["matrix_username"] = self.user.registration.matrix_username
d["display_name"] = str(self.user.registration)
return d

View File

@ -0,0 +1,26 @@
[
{
"model": "cas_server.servicepattern",
"pk": 1,
"fields": {
"pos": 100,
"name": "Plateforme du TFJM²",
"pattern": "^https://tfjm.org:8448/.*$",
"user_field": "matrix_username",
"restrict_users": false,
"proxy": true,
"proxy_callback": true,
"single_log_out": true,
"single_log_out_callback": ""
}
},
{
"model": "cas_server.replaceattributname",
"pk": 1,
"fields": {
"name": "display_name",
"replace": "",
"service_pattern": 1
}
}
]

110
apps/registration/forms.py Normal file
View File

@ -0,0 +1,110 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.forms import FileInput
from django.utils.translation import gettext_lazy as _
from .models import AdminRegistration, CoachRegistration, StudentRegistration
class SignupForm(UserCreationForm):
"""
Signup form to registers participants and coaches
They can choose the role at the registration.
"""
role = forms.ChoiceField(
label=lambda: _("role").capitalize(),
choices=lambda: [
("participant", _("participant").capitalize()),
("coach", _("coach").capitalize()),
],
)
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):
super().__init__(*args, **kwargs)
self.fields["first_name"].required = True
self.fields["last_name"].required = True
self.fields["email"].required = True
class Meta:
model = User
fields = ('first_name', 'last_name', 'email', 'password1', 'password2', 'role',)
class UserForm(forms.ModelForm):
"""
Replace the default user form to require the first name, last name and the email.
The username is always equal to the email.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["first_name"].required = True
self.fields["last_name"].required = True
self.fields["email"].required = True
class Meta:
model = User
fields = ('first_name', 'last_name', 'email',)
class StudentRegistrationForm(forms.ModelForm):
"""
A student can update its class, its school and if it allows Animath to contact him/her later.
"""
class Meta:
model = StudentRegistration
fields = ('team', 'student_class', 'school', 'give_contact_to_animath', 'email_confirmed',)
class PhotoAuthorizationForm(forms.ModelForm):
"""
Form to send a photo authorization.
"""
def clean_photo_authorization(self):
if "photo_authorization" in self.files:
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"]:
raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file."))
return self.cleaned_data["photo_authorization"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["photo_authorization"].widget = FileInput()
class Meta:
model = StudentRegistration
fields = ('photo_authorization',)
class CoachRegistrationForm(forms.ModelForm):
"""
A coach can tell its professional activity.
"""
class Meta:
model = CoachRegistration
fields = ('team', 'professional_activity', 'give_contact_to_animath', 'email_confirmed',)
class AdminRegistrationForm(forms.ModelForm):
"""
Admins can tell everything they want.
"""
class Meta:
model = AdminRegistration
fields = ('role', 'give_contact_to_animath', 'email_confirmed',)

View File

@ -0,0 +1,74 @@
# Generated by Django 3.1.3 on 2020-11-04 12:05
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import registration.models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('participation', '0001_initial'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Registration',
fields=[
('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')),
('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')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'registration',
'verbose_name_plural': 'registrations',
},
),
migrations.CreateModel(
name='AdminRegistration',
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')),
('role', models.TextField(verbose_name='role of the administrator')),
],
options={
'verbose_name': 'admin registration',
'verbose_name_plural': 'admin registrations',
},
bases=('registration.registration',),
),
migrations.CreateModel(
name='StudentRegistration',
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')),
('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')),
('photo_authorization', models.FileField(blank=True, default='', upload_to=registration.models.get_random_filename, verbose_name='photo authorization')),
('team', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='students', to='participation.team', verbose_name='team')),
],
options={
'verbose_name': 'student registration',
'verbose_name_plural': 'student registrations',
},
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, 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

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

199
apps/registration/models.py Normal file
View File

@ -0,0 +1,199 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from tfjm.tokens import email_validation_token
from django.contrib.sites.models import Site
from django.db import models
from django.template import loader
from django.urls import reverse_lazy
from django.utils.crypto import get_random_string
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext_lazy as _
from polymorphic.models import PolymorphicModel
class Registration(PolymorphicModel):
"""
Registrations store extra content that are not asked in the User Model.
This is specific to the role of the user, see StudentRegistration,
ClassRegistration or AdminRegistration..
"""
user = models.OneToOneField(
"auth.User",
on_delete=models.CASCADE,
verbose_name=_("user"),
)
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"),
)
def send_email_validation_link(self):
"""
The account got created or the email got changed.
Send an email that contains a link to validate the address.
"""
subject = "[Corres2math] " + str(_("Activate your Correspondances account"))
token = email_validation_token.make_token(self.user)
uid = urlsafe_base64_encode(force_bytes(self.user.pk))
site = Site.objects.first()
message = loader.render_to_string('registration/mails/email_validation_email.txt',
{
'user': self.user,
'domain': site.domain,
'token': token,
'uid': uid,
})
html = loader.render_to_string('registration/mails/email_validation_email.html',
{
'user': self.user,
'domain': site.domain,
'token': token,
'uid': uid,
})
self.user.email_user(subject, message, html_message=html)
@property
def type(self): # pragma: no cover
raise NotImplementedError
@property
def form_class(self): # pragma: no cover
raise NotImplementedError
@property
def participates(self):
return isinstance(self, StudentRegistration) or isinstance(self, CoachRegistration)
@property
def is_admin(self):
return isinstance(self, AdminRegistration) or self.user.is_superuser
@property
def matrix_username(self):
return f"tfjm_{self.user.pk}"
def get_absolute_url(self):
return reverse_lazy("registration:user_detail", args=(self.user_id,))
def __str__(self):
return f"{self.user.first_name} {self.user.last_name}"
class Meta:
verbose_name = _("registration")
verbose_name_plural = _("registrations")
def get_random_filename(instance, filename):
return "authorization/photo/" + get_random_string(64)
class StudentRegistration(Registration):
"""
Specific registration for students.
They have a team, a student class and a school.
"""
team = models.ForeignKey(
"participation.Team",
related_name="students",
on_delete=models.PROTECT,
null=True,
default=None,
verbose_name=_("team"),
)
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"),
)
photo_authorization = models.FileField(
verbose_name=_("photo authorization"),
upload_to=get_random_filename,
blank=True,
default="",
)
@property
def type(self):
return _("student")
@property
def form_class(self):
from registration.forms import StudentRegistrationForm
return StudentRegistrationForm
class Meta:
verbose_name = _("student registration")
verbose_name_plural = _("student registrations")
class CoachRegistration(Registration):
"""
Specific registration for coaches.
They have a team and a professional activity.
"""
team = models.ForeignKey(
"participation.Team",
related_name="coachs",
on_delete=models.PROTECT,
null=True,
default=None,
verbose_name=_("team"),
)
professional_activity = models.TextField(
verbose_name=_("professional activity"),
)
@property
def type(self):
return _("coach")
@property
def form_class(self):
from registration.forms import CoachRegistrationForm
return CoachRegistrationForm
class Meta:
verbose_name = _("coach registration")
verbose_name_plural = _("coach registrations")
class AdminRegistration(Registration):
"""
Specific registration for admins.
They have a field to justify they status.
"""
role = models.TextField(
verbose_name=_("role of the administrator"),
)
@property
def type(self):
return _("admin")
@property
def form_class(self):
from registration.forms import AdminRegistrationForm
return AdminRegistrationForm
class Meta:
verbose_name = _("admin registration")
verbose_name_plural = _("admin registrations")

View File

@ -0,0 +1,16 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from haystack import indexes
from .models import Registration
class RegistrationIndex(indexes.ModelSearchIndex, indexes.Indexable):
"""
Registrations are indexed by the user detail.
"""
text = indexes.NgramField(document=True, use_template=True)
class Meta:
model = Registration

View File

@ -0,0 +1,56 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from tfjm.lists import get_sympa_client
from tfjm.matrix import Matrix
from django.contrib.auth.models import User
from .models import AdminRegistration, Registration
def set_username(instance, **_):
"""
Ensure that the user username is always equal to the user email address.
"""
instance.username = instance.email
def send_email_link(instance, **_):
"""
If the email address got changed, send a new validation link
and update the registration status in the team mailing list.
"""
if instance.pk:
old_instance = User.objects.get(pk=instance.pk)
if old_instance.email != instance.email:
registration = Registration.objects.get(user=instance)
registration.email_confirmed = False
registration.save()
registration.user = instance
registration.send_email_validation_link()
if registration.participates and registration.team:
get_sympa_client().unsubscribe(old_instance.email, f"equipe-{registration.team.trigram.lower()}", False)
get_sympa_client().subscribe(instance.email, f"equipe-{registration.team.trigram.lower()}", False,
f"{instance.first_name} {instance.last_name}")
def create_admin_registration(instance, **_):
"""
When a super user got created through console,
ensure that an admin registration is created.
"""
if instance.is_superuser:
AdminRegistration.objects.get_or_create(user=instance)
def invite_to_public_rooms(instance: Registration, created: bool, **_):
"""
When a user got registered, automatically invite the Matrix user into public rooms.
"""
if not created:
Matrix.invite("#annonces:tfjm.org", f"@{instance.matrix_username}:tfjm.org")
Matrix.invite("#faq:tfjm.org", f"@{instance.matrix_username}:tfjm.org")
Matrix.invite("#je-cherche-une-equip:tfjm.org",
f"@{instance.matrix_username}:tfjm.org")
Matrix.invite("#flood:tfjm.org", f"@{instance.matrix_username}:tfjm.org")

View File

@ -0,0 +1,27 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from .models import Registration
class RegistrationTable(tables.Table):
"""
Table of all registrations.
"""
last_name = tables.LinkColumn(
'registration:user_detail',
args=[tables.A("user_id")],
verbose_name=lambda: _("last name").capitalize(),
accessor="user__last_name",
)
class Meta:
attrs = {
'class': 'table table condensed table-striped',
}
model = Registration
fields = ('last_name', 'user__first_name', 'user__email', 'type',)
template_name = 'django_tables2/bootstrap4.html'

View File

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
{% if validlink %}
<p>
{% trans "Your email have successfully been validated." %}
</p>
<p>
{% blocktrans %}You can now <a href="{{ login_url }}">log in</a>.{% endblocktrans %}
</p>
{% else %}
<p>
{% 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>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block content %}
<div class="card bg-light">
<h3 class="card-header text-center">
{% trans "Account activation" %}
</h3>
<div class="card-body">
<p>
{% trans "An email has been sent. Please click on the link to activate your account." %}
</p>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% load i18n %}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<p>
{% trans "Hi" %} {{ user.username }},
</p>
<p>
{% trans "You recently registered on the Correspondances platform. Please click on the link below to confirm your registration." %}
</p>
<p>
<a href="https://{{ domain }}{% url 'registration:email_validation' uidb64=uid token=token %}">
https://{{ domain }}{% url 'registration:email_validation' uidb64=uid token=token %}
</a>
</p>
<p>
{% trans "This link is only valid for a couple of days, after that you will need to contact us to validate your email." %}
</p>
<p>
{% trans "Thanks" %},
</p>
--
<p>
{% trans "The Correspondances team." %}<br>
</p>

View File

@ -0,0 +1,13 @@
{% load i18n %}
{% trans "Hi" %} {{ user.username }},
{% trans "You recently registered on the Correspondances platform. Please click on the link below to confirm your registration." %}
https://{{ domain }}{% url 'registration:email_validation' uidb64=uid token=token %}
{% trans "This link is only valid for a couple of days, after that you will need to contact us to validate your email." %}
{% trans "Thanks" %},
{% trans "The Correspondances team." %}

View File

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block content %}
<p>{% trans 'Your password was changed.' %}</p>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<form method="post">{% csrf_token %}
<p>{% trans "Please enter your old password, for security's sake, and then enter your new password twice so we can verify you typed it in correctly." %}</p>
{{ form | crispy }}
<input class="btn btn-primary" type="submit" value="{% trans 'Change my password' %}">
</form>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block content %}
<p>{% trans "Your password has been set. You may go ahead and log in now." %}</p>
<p>
<a href="{{ login_url }}" class="btn btn-success">{% trans 'Log in' %}</a>
</p>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
{% if validlink %}
<p>{% trans "Please enter your new password twice so we can verify you typed it in correctly." %}</p>
<form method="post">{% csrf_token %}
{{ form | crispy }}
<input class="btn btn-primary" type="submit" value="{% trans 'Change my password' %}">
</form>
{% else %}
<p>{% trans "The password reset link was invalid, possibly because it has already been used. Please request a new password reset." %}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block content %}
<p>{% trans "We've emailed you instructions for setting your password, if an account exists with the email you entered. You should receive them shortly." %}</p>
<p>{% trans "If you don't receive an email, please make sure you've entered the address you registered with, and check your spam folder." %}</p>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% comment %}
SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %}
{% load i18n crispy_forms_tags %}
{% block content %}
<p>{% trans "Forgotten your password? Enter your email address below, and we'll email instructions for setting a new one." %}</p>
<form method="post">{% csrf_token %}
{{ form | crispy }}
<input class="btn btn-primary" type="submit" value="{% trans 'Reset my password' %}">
</form>
{% endblock %}

View File

@ -0,0 +1,44 @@
<!-- templates/signup.html -->
{% extends 'base.html' %}
{% load crispy_forms_filters %}
{% load i18n %}
{% block title %}{% trans "Sign up" %}{% endblock %}
{% block content %}
<h2>{% trans "Sign up" %}</h2>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<div id="student_registration_form">
{{ student_registration_form|crispy }}
</div>
<div id="coach_registration_form" class="d-none">
{{ coach_registration_form|crispy }}
</div>
<button class="btn btn-success" type="submit">
{% trans "Sign up" %}
</button>
</form>
{% endblock %}
{% block extrajavascript %}
<script>
$(document).ready(function() {
$("#id_role").change(function() {
let selected_role = $("#id_role :selected");
if (selected_role.val() === "participant") {
$("#student_registration_form").removeClass("d-none");
$("#coach_registration_form").addClass("d-none");
}
else {
$("#student_registration_form").addClass("d-none");
$("#coach_registration_form").removeClass("d-none");
}
});
$("#student_registration_form :input").removeAttr("required");
$("#coach_registration_form :input").removeAttr("required");
});
</script>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
{{ registration_form|crispy }}
</div>
<button class="btn btn-success" type="submit">{% trans "Update" %}</button>
</form>
{% endblock content %}

View File

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% load i18n static crispy_forms_filters %}
{% block content %}
<a class="btn btn-info" href="{% url "registration:user_detail" pk=object.user.pk %}"><i class="fas fa-arrow-left"></i> {% trans "Back to the user detail" %}</a>
<hr>
<form method="post" enctype="multipart/form-data">
<div id="form-content">
<div class="alert alert-info">
{% trans "Authorzation templates:" %}
<a class="alert-link" href="{% static "Autorisation de droit à l'image - majeur.pdf" %}">{% trans "Adult" %}</a>
<a class="alert-link" href="{% static "Autorisation de droit à l'image - mineur.pdf" %}">{% trans "Child" %}</a>
</div>
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-success" type="submit">{% trans "Upload" %}</button>
</form>
{% endblock %}

View File

@ -0,0 +1,98 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% trans "any" as any %}
<div class="card bg-light shadow">
<div class="card-header text-center">
<h4>{{ user_object.first_name }} {{ user_object.last_name }}</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-6 text-right">{% trans "Last name:" %}</dt>
<dd class="col-sm-6">{{ user_object.last_name }}</dd>
<dt class="col-sm-6 text-right">{% trans "First name:" %}</dt>
<dd class="col-sm-6">{{ user_object.first_name }}</dd>
<dt class="col-sm-6 text-right">{% trans "Email:" %}</dt>
<dd class="col-sm-6"><a href="mailto:{{ user_object.email }}">{{ user_object.email }}</a>
{% if not user_object.registration.email_confirmed %} (<em>{% trans "Not confirmed" %}, <a href="{% url "registration:email_validation_resend" pk=user_object.pk %}">{% trans "resend the validation link" %}</a></em>){% endif %}</dd>
{% if user_object.registration.participates or True %}
<dt class="col-sm-6 text-right">{% trans "Team:" %}</dt>
{% trans "any" as any %}
<dd class="col-sm-6">
<a href="{% if user_object.registration.team %}{% url "participation:team_detail" pk=user_object.registration.team.pk %}{% else %}#{% endif %}">
{{ user_object.registration.team|default:any }}
</a>
</dd>
{% endif %}
{% if user_object.registration.studentregistration %}
<dt class="col-sm-6 text-right">{% trans "Student class:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.get_student_class_display }}</dd>
<dt class="col-sm-6 text-right">{% trans "School:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.school }}</dd>
<dt class="col-sm-6 text-right">{% trans "Photo authorization:" %}</dt>
<dd class="col-sm-6">
{% if user_object.registration.photo_authorization %}
<a href="{{ user_object.registration.photo_authorization.url }}" data-turbolinks="false">{% trans "Download" %}</a>
{% endif %}
{% if user_object.pk == user.pk %}
<button class="btn btn-primary" data-toggle="modal" data-target="#uploadPhotoAuthorizationModal">{% trans "Replace" %}</button>
{% endif %}
</dd>
{% elif user_object.registration.coachregistration %}
<dt class="col-sm-6 text-right">{% trans "Profesional activity:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.professional_activity }}</dd>
{% elif user_object.registration.adminregistration %}
<dt class="col-sm-6 text-right">{% trans "Role:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.role }}</dd>
{% endif %}
<dt class="col-sm-6 text-right">{% trans "Grant Animath to contact me in the future about other actions:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.give_contact_to_animath|yesno }}</dd>
</dl>
</div>
{% if user.pk == user_object.pk or user.registration.is_admin %}
<div class="card-footer text-center">
<button class="btn btn-primary" data-toggle="modal" data-target="#updateUserModal">{% trans "Update" %}</button>
{% if user.registration.is_admin %}
<a class="btn btn-info" href="{% url "registration:user_impersonate" pk=user_object.pk %}">{% trans "Impersonate" %}</a>
{% endif %}
</div>
{% endif %}
</div>
{% trans "Update user" as modal_title %}
{% trans "Update" as modal_button %}
{% url "registration:update_user" pk=user_object.pk as modal_action %}
{% include "base_modal.html" with modal_id="updateUser" %}
{% trans "Upload photo authorization" as modal_title %}
{% trans "Upload" as modal_button %}
{% url "registration:upload_user_photo_authorization" pk=user_object.registration.pk as modal_action %}
{% include "base_modal.html" with modal_id="uploadPhotoAuthorization" modal_enctype="multipart/form-data" %}
{% endblock %}
{% block extrajavascript %}
<script>
$(document).ready(function() {
$('button[data-target="#updateUserModal"]').click(function() {
let modalBody = $("#updateUserModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "registration:update_user" pk=user_object.pk %} #form-content");
});
$('button[data-target="#uploadPhotoAuthorizationModal"]').click(function() {
let modalBody = $("#uploadPhotoAuthorizationModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "registration:upload_user_photo_authorization" pk=user_object.registration.pk %} #form-content");
});
});
</script>
{% endblock %}

View File

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

View File

@ -0,0 +1,5 @@
{{ object.user.last_name }}
{{ object.user.first_name }}
{{ object.user.email }}
{{ object.type }}
{{ object.role }}

View File

@ -0,0 +1,7 @@
{{ object.user.first_name }}
{{ object.user.last_name }}
{{ object.user.email }}
{{ object.type }}
{{ object.professional_activity }}
{{ object.team.name }}
{{ object.team.trigram }}

View File

@ -0,0 +1,8 @@
{{ object.user.first_name }}
{{ object.user.last_name }}
{{ object.user.email }}
{{ object.type }}
{{ object.get_student_class_display }}
{{ object.school }}
{{ object.team.name }}
{{ object.team.trigram }}

View File

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

View File

@ -0,0 +1,28 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django import template
from django_tables2 import Table
from participation.models import Participation, Team, Video
from participation.tables import ParticipationTable, TeamTable, VideoTable
from ..models import Registration
from ..tables import RegistrationTable
def search_table(results):
model_class = results[0].object.__class__
table_class = Table
if issubclass(model_class, Registration):
table_class = RegistrationTable
elif issubclass(model_class, Team):
table_class = TeamTable
elif issubclass(model_class, Participation):
table_class = ParticipationTable
elif issubclass(model_class, Video):
table_class = VideoTable
return table_class([result.object for result in results], prefix=model_class._meta.model_name)
register = template.Library()
register.filter("search_table", search_table)

395
apps/registration/tests.py Normal file
View File

@ -0,0 +1,395 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta
import os
from tfjm.tokens import email_validation_token
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management import call_command
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from participation.models import Phase, Team
from .models import AdminRegistration, CoachRegistration, StudentRegistration
class TestIndexPage(TestCase):
def test_index(self) -> None:
"""
Display the index page, without any right.
"""
response = self.client.get(reverse("index"))
self.assertEqual(response.status_code, 200)
def test_not_authenticated(self):
"""
Try to load some pages without being authenticated.
"""
response = self.client.get(reverse("registration:reset_admin"))
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):
def setUp(self) -> None:
self.user = User.objects.create_superuser(
username="admin",
password="admin",
email="admin@example.com",
)
self.client.force_login(self.user)
self.student = User.objects.create(email="student@example.com")
StudentRegistration.objects.create(user=self.student, student_class=11, school="Earth")
self.coach = User.objects.create(email="coach@example.com")
CoachRegistration.objects.create(user=self.coach, professional_activity="Teacher")
def test_admin_pages(self):
"""
Check that admin pages are rendering successfully.
"""
response = self.client.get(reverse("admin:index") + "registration/registration/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index")
+ f"registration/registration/{self.user.registration.pk}/change/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") +
f"r/{ContentType.objects.get_for_model(AdminRegistration).id}/"
f"{self.user.registration.pk}/")
self.assertRedirects(response, "http://" + Site.objects.get().domain +
str(self.user.registration.get_absolute_url()), 302, 200)
response = self.client.get(reverse("admin:index")
+ f"registration/registration/{self.student.registration.pk}/change/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") +
f"r/{ContentType.objects.get_for_model(StudentRegistration).id}/"
f"{self.student.registration.pk}/")
self.assertRedirects(response, "http://" + Site.objects.get().domain +
str(self.student.registration.get_absolute_url()), 302, 200)
response = self.client.get(reverse("admin:index")
+ f"registration/registration/{self.coach.registration.pk}/change/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") +
f"r/{ContentType.objects.get_for_model(CoachRegistration).id}/"
f"{self.coach.registration.pk}/")
self.assertRedirects(response, "http://" + Site.objects.get().domain +
str(self.coach.registration.get_absolute_url()), 302, 200)
def test_registration(self):
"""
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"))
self.assertEqual(response.status_code, 200)
# Incomplete form
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",
))
self.assertEqual(response.status_code, 200)
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.assertRedirects(response, reverse("registration:email_validation_sent"), 302, 200)
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"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("registration:signup"), data=dict(
last_name="Toto",
first_name="Coach",
email="coachtoto@example.com",
password1="azertyuiopazertyuiop",
password2="azertyuiopazertyuiop",
role="coach",
professional_activity="God",
give_contact_to_animath=True,
))
self.assertRedirects(response, reverse("registration:email_validation_sent"), 302, 200)
self.assertTrue(User.objects.filter(email="coachtoto@example.com").exists())
user = User.objects.get(email="coachtoto@example.com")
token = email_validation_token.make_token(user)
uid = urlsafe_base64_encode(force_bytes(user.pk))
response = self.client.get(reverse("registration:email_validation", kwargs=dict(uidb64=uid, token=token)))
self.assertEqual(response.status_code, 200)
user.registration.refresh_from_db()
self.assertTrue(user.registration.email_confirmed)
# Token has expired
response = self.client.get(reverse("registration:email_validation", kwargs=dict(uidb64=uid, token=token)))
self.assertEqual(response.status_code, 400)
# Uid does not exist
response = self.client.get(reverse("registration:email_validation", kwargs=dict(uidb64=0, token="toto")))
self.assertEqual(response.status_code, 400)
response = self.client.get(reverse("registration:email_validation_resend", args=(user.pk,)))
self.assertRedirects(response, reverse("registration:email_validation_sent"), 302, 200)
def test_login(self):
"""
With a registered user, try to log in
"""
response = self.client.get(reverse("login"))
self.assertEqual(response.status_code, 200)
self.client.logout()
response = self.client.post(reverse("login"), data=dict(
username="admin",
password="toto",
))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("login"), data=dict(
username="admin@example.com",
password="admin",
))
self.assertRedirects(response, reverse("index"), 302, 200)
def test_user_detail(self):
"""
Load a user detail page.
"""
response = self.client.get(reverse("registration:my_account_detail"))
self.assertRedirects(response, 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)
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):
"""
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")),
(self.student, dict(student_class=11, school="Sky")),
(self.coach, dict(professional_activity="God"))]:
response = self.client.get(reverse("registration:update_user", args=(user.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("registration:update_user", args=(user.pk,)), data=dict(
first_name="Changed",
last_name="Name",
email="new_" + user.email,
give_contact_to_animath=True,
email_confirmed=True,
team_id="",
))
self.assertEqual(response.status_code, 200)
data.update(
first_name="Changed",
last_name="Name",
email="new_" + user.email,
give_contact_to_animath=True,
email_confirmed=True,
team_id="",
)
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)
user.refresh_from_db()
self.assertEqual(user.email, user.username)
self.assertFalse(user.registration.email_confirmed)
self.assertEqual(user.first_name, "Changed")
def test_upload_photo_authorization(self):
"""
Try to upload a photo authorization.
"""
response = self.client.get(reverse("registration:upload_user_photo_authorization",
args=(self.student.registration.pk,)))
self.assertEqual(response.status_code, 200)
# README is not a valid PDF file
response = self.client.post(reverse("registration:upload_user_photo_authorization",
args=(self.student.registration.pk,)), data=dict(
photo_authorization=open("README.md", "rb"),
))
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",
args=(self.student.registration.pk,)), data=dict(
photo_authorization=open("tfjm/static/Autorisation de droit à l'image - majeur.pdf", "rb"),
))
self.assertRedirects(response, reverse("registration:user_detail", args=(self.student.pk,)), 302, 200)
self.student.registration.refresh_from_db()
self.assertTrue(self.student.registration.photo_authorization)
response = self.client.get(reverse("photo_authorization",
args=(self.student.registration.photo_authorization.name.split('/')[-1],)))
self.assertEqual(response.status_code, 200)
from participation.models import Team
team = Team.objects.create(name="Test", trigram="TES")
self.student.registration.team = team
self.student.registration.save()
response = self.client.get(reverse("participation:team_authorizations", args=(team.pk,)))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["content-type"], "application/zip")
# Do it twice, ensure that the previous authorization got deleted
old_authoratization = self.student.registration.photo_authorization.path
response = self.client.post(reverse("registration:upload_user_photo_authorization",
args=(self.student.registration.pk,)), data=dict(
photo_authorization=open("tfjm/static/Autorisation de droit à l'image - majeur.pdf", "rb"),
))
self.assertRedirects(response, reverse("registration:user_detail", args=(self.student.pk,)), 302, 200)
self.assertFalse(os.path.isfile(old_authoratization))
self.student.registration.refresh_from_db()
self.student.registration.photo_authorization.delete()
def test_user_detail_forbidden(self):
"""
Create a new user and ensure that it can't see the detail of another user.
"""
self.client.force_login(self.coach)
response = self.client.get(reverse("registration:user_detail", args=(self.user.pk,)))
self.assertEqual(response.status_code, 403)
response = self.client.get(reverse("registration:update_user", args=(self.user.pk,)))
self.assertEqual(response.status_code, 403)
response = self.client.get(reverse("registration:upload_user_photo_authorization", args=(self.user.pk,)))
self.assertEqual(response.status_code, 403)
response = self.client.get(reverse("photo_authorization", args=("inexisting-authorization",)))
self.assertEqual(response.status_code, 404)
with open("media/authorization/photo/example", "w") as f:
f.write("I lost the game.")
self.student.registration.photo_authorization = "authorization/photo/example"
self.student.registration.save()
response = self.client.get(reverse("photo_authorization", args=("example",)))
self.assertEqual(response.status_code, 403)
os.remove("media/authorization/photo/example")
def test_impersonate(self):
"""
Admin can impersonate other people to act as them.
"""
response = self.client.get(reverse("registration:user_impersonate", args=(0x7ffff42ff,)))
self.assertEqual(response.status_code, 404)
# Impersonate student account
response = self.client.get(reverse("registration:user_impersonate", args=(self.student.pk,)))
self.assertRedirects(response, reverse("registration:user_detail", args=(self.student.pk,)), 302, 200)
self.assertEqual(self.client.session["_fake_user_id"], self.student.id)
# Reset admin view
response = self.client.get(reverse("registration:reset_admin"))
self.assertRedirects(response, reverse("index"), 302, 200)
self.assertFalse("_fake_user_id" in self.client.session)
def test_research(self):
"""
Try to search some things.
"""
call_command("rebuild_index", "--noinput", "-v", 0)
response = self.client.get(reverse("haystack_search") + "?q=" + self.user.email)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["object_list"])
response = self.client.get(reverse("haystack_search") + "?q=" +
str(self.coach.registration.professional_activity))
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["object_list"])
response = self.client.get(reverse("haystack_search") + "?q=" +
self.student.registration.get_student_class_display())
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["object_list"])

Some files were not shown because too many files have changed in this diff Show More