mirror of
https://gitlab.com/animath/si/plateforme-corres2math.git
synced 2025-06-22 01:58:21 +02:00
Add a lot of comments
This commit is contained in:
@ -3,6 +3,9 @@ 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):
|
||||
|
@ -9,6 +9,10 @@ from .models import Participation, Phase, 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):
|
||||
@ -21,6 +25,9 @@ class TeamForm(forms.ModelForm):
|
||||
|
||||
|
||||
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():
|
||||
@ -40,12 +47,18 @@ class JoinTeamForm(forms.ModelForm):
|
||||
|
||||
|
||||
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(),
|
||||
@ -58,6 +71,9 @@ class RequestValidationForm(forms.Form):
|
||||
|
||||
|
||||
class ValidateParticipationForm(forms.Form):
|
||||
"""
|
||||
Form to let administrators to accept or refuse a team.
|
||||
"""
|
||||
_form_type = forms.CharField(
|
||||
initial="ValidateParticipationForm",
|
||||
widget=forms.HiddenInput(),
|
||||
@ -70,6 +86,9 @@ class ValidateParticipationForm(forms.Form):
|
||||
|
||||
|
||||
class UploadVideoForm(forms.ModelForm):
|
||||
"""
|
||||
Form to upload a video, for a solution or a synthesis.
|
||||
"""
|
||||
class Meta:
|
||||
model = Video
|
||||
fields = ('link',)
|
||||
@ -81,6 +100,9 @@ class UploadVideoForm(forms.ModelForm):
|
||||
|
||||
|
||||
class PhaseForm(forms.ModelForm):
|
||||
"""
|
||||
Form to update the calendar of a phase.
|
||||
"""
|
||||
class Meta:
|
||||
model = Phase
|
||||
fields = ('start', 'end',)
|
||||
|
@ -17,6 +17,10 @@ from nio import RoomPreset, RoomVisibility
|
||||
|
||||
|
||||
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"),
|
||||
@ -45,9 +49,15 @@ class Team(models.Model):
|
||||
|
||||
@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})",
|
||||
@ -58,10 +68,15 @@ class Team(models.Model):
|
||||
)
|
||||
|
||||
def delete_mailing_list(self):
|
||||
"""
|
||||
Drop the Sympa mailing list, if the team is empty or if the trigram changed.
|
||||
"""
|
||||
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()
|
||||
|
||||
@ -90,6 +105,10 @@ class Team(models.Model):
|
||||
|
||||
|
||||
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,
|
||||
@ -149,6 +168,9 @@ class Participation(models.Model):
|
||||
|
||||
|
||||
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."),
|
||||
@ -163,23 +185,38 @@ class Video(models.Model):
|
||||
|
||||
@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("https://www.youtube.com/watch?v=73nsrixx7eI").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
|
||||
@ -194,6 +231,9 @@ class Video(models.Model):
|
||||
|
||||
|
||||
class Phase(models.Model):
|
||||
"""
|
||||
The Phase model corresponds to the dates of the phase.
|
||||
"""
|
||||
phase_number = models.AutoField(
|
||||
primary_key=True,
|
||||
unique=True,
|
||||
@ -217,6 +257,9 @@ class Phase(models.Model):
|
||||
|
||||
@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()
|
||||
|
@ -4,6 +4,9 @@ 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:
|
||||
@ -11,6 +14,9 @@ class TeamIndex(indexes.ModelSearchIndex, indexes.Indexable):
|
||||
|
||||
|
||||
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:
|
||||
@ -18,6 +24,9 @@ class ParticipationIndex(indexes.ModelSearchIndex, indexes.Indexable):
|
||||
|
||||
|
||||
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:
|
||||
|
@ -3,6 +3,9 @@ from participation.models import Participation, Team, Video
|
||||
|
||||
|
||||
def create_team_participation(instance, **_):
|
||||
"""
|
||||
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()
|
||||
@ -12,11 +15,17 @@ def create_team_participation(instance, **_):
|
||||
|
||||
|
||||
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}")
|
||||
|
@ -34,6 +34,9 @@ class TestStudentParticipation(TestCase):
|
||||
str(self.team.participation)
|
||||
|
||||
def test_create_team(self):
|
||||
"""
|
||||
Try to create a team.
|
||||
"""
|
||||
response = self.client.get(reverse("participation:create_team"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@ -61,6 +64,9 @@ class TestStudentParticipation(TestCase):
|
||||
))
|
||||
|
||||
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)
|
||||
|
||||
@ -84,10 +90,16 @@ class TestStudentParticipation(TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
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()
|
||||
|
||||
@ -98,6 +110,9 @@ class TestStudentParticipation(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_update_team(self):
|
||||
"""
|
||||
Try to update team information.
|
||||
"""
|
||||
self.user.registration.team = self.team
|
||||
self.user.registration.save()
|
||||
|
||||
@ -123,10 +138,16 @@ class TestStudentParticipation(TestCase):
|
||||
self.assertTrue(Team.objects.filter(trigram="BBB", participation__problem=3).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.assertTrue(response.status_code, 200)
|
||||
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()
|
||||
|
||||
@ -146,6 +167,9 @@ class TestStudentParticipation(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_upload_video(self):
|
||||
"""
|
||||
Try to send a solution video link.
|
||||
"""
|
||||
self.user.registration.team = self.team
|
||||
self.user.registration.save()
|
||||
|
||||
@ -178,21 +202,30 @@ class TestAdminForbidden(TestCase):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
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.assertTrue(response.status_code, 200)
|
||||
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, 200)
|
||||
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.assertTrue(response.status_code, 200)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
@ -27,6 +27,10 @@ from .tables import CalendarTable
|
||||
|
||||
|
||||
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"))
|
||||
@ -43,14 +47,24 @@ class CreateTeamView(LoginRequiredMixin, CreateView):
|
||||
|
||||
@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"#team-{form.instance.trigram.lower()}:correspondances-maths.fr",
|
||||
f"@{user.registration.matrix_username}:correspondances-maths.fr")
|
||||
return ret
|
||||
@ -60,6 +74,9 @@ class CreateTeamView(LoginRequiredMixin, CreateView):
|
||||
|
||||
|
||||
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"))
|
||||
@ -76,15 +93,24 @@ class JoinTeamView(LoginRequiredMixin, FormView):
|
||||
|
||||
@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"#team-{form.instance.trigram.lower()}:correspondances-maths.fr",
|
||||
f"@{user.registration.matrix_username}:correspondances-maths.fr")
|
||||
return ret
|
||||
@ -94,6 +120,10 @@ class JoinTeamView(LoginRequiredMixin, FormView):
|
||||
|
||||
|
||||
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
|
||||
@ -105,11 +135,15 @@ class MyTeamDetailView(LoginRequiredMixin, RedirectView):
|
||||
|
||||
|
||||
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.pk == kwargs["pk"]:
|
||||
return super().get(request, *args, **kwargs)
|
||||
raise PermissionDenied
|
||||
@ -120,6 +154,8 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
|
||||
team = self.get_object()
|
||||
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 \
|
||||
@ -188,6 +224,9 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
|
||||
|
||||
|
||||
class TeamUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Update the detail of a team
|
||||
"""
|
||||
model = Team
|
||||
form_class = TeamForm
|
||||
template_name = "participation/update_team.html"
|
||||
@ -218,6 +257,9 @@ class TeamUpdateView(LoginRequiredMixin, UpdateView):
|
||||
|
||||
|
||||
class TeamAuthorizationsView(LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
Get as a ZIP archive all the authorizations that are sent
|
||||
"""
|
||||
model = Team
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
@ -245,6 +287,10 @@ class TeamAuthorizationsView(LoginRequiredMixin, DetailView):
|
||||
|
||||
|
||||
class TeamLeaveView(LoginRequiredMixin, TemplateView):
|
||||
"""
|
||||
A team member leaves a team
|
||||
"""
|
||||
|
||||
template_name = "participation/team_leave.html"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
@ -258,6 +304,10 @@ class TeamLeaveView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
@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()
|
||||
@ -271,6 +321,9 @@ class TeamLeaveView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
|
||||
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
|
||||
@ -282,6 +335,9 @@ class MyParticipationDetailView(LoginRequiredMixin, RedirectView):
|
||||
|
||||
|
||||
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):
|
||||
@ -303,6 +359,9 @@ class ParticipationDetailView(LoginRequiredMixin, DetailView):
|
||||
|
||||
|
||||
class UploadVideoView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Upload a solution video for a team.
|
||||
"""
|
||||
model = Video
|
||||
form_class = UploadVideoForm
|
||||
template_name = "participation/upload_video.html"
|
||||
@ -319,11 +378,17 @@ class UploadVideoView(LoginRequiredMixin, UpdateView):
|
||||
|
||||
|
||||
class CalendarView(SingleTableView):
|
||||
"""
|
||||
Display the calendar of the action.
|
||||
"""
|
||||
table_class = CalendarTable
|
||||
model = Phase
|
||||
|
||||
|
||||
class PhaseUpdateView(AdminMixin, UpdateView):
|
||||
"""
|
||||
Update a phase of the calendar, if we have sufficient rights.
|
||||
"""
|
||||
model = Phase
|
||||
form_class = PhaseForm
|
||||
|
||||
|
Reference in New Issue
Block a user