Add a lot of comments

This commit is contained in:
Yohann D'ANELLO 2020-10-30 19:46:46 +01:00
parent 971169fe2c
commit 8236a9fe14
16 changed files with 491 additions and 7 deletions

View File

@ -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):

View File

@ -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',)

View File

@ -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()

View File

@ -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:

View File

@ -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}")

View File

@ -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)

View File

@ -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

View File

@ -3,6 +3,9 @@ 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):

View File

@ -9,6 +9,11 @@ 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: [
@ -29,6 +34,10 @@ class SignupForm(UserCreationForm):
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
@ -41,12 +50,18 @@ class UserForm(forms.ModelForm):
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 = ('student_class', 'school', 'give_contact_to_animath',)
class PhotoAuthorizationForm(forms.ModelForm):
"""
Form to send a photo authorization.
"""
def clean_photo_authorization(self):
file = self.files["photo_authorization"]
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
@ -63,12 +78,18 @@ class PhotoAuthorizationForm(forms.ModelForm):
class CoachRegistrationForm(forms.ModelForm):
"""
A coach can tell its professional activity.
"""
class Meta:
model = CoachRegistration
fields = ('professional_activity', 'give_contact_to_animath',)
class AdminRegistrationForm(forms.ModelForm):
"""
Admins can tell everything they want.
"""
class Meta:
model = AdminRegistration
fields = ('role', 'give_contact_to_animath',)

View File

@ -11,6 +11,11 @@ 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,
@ -28,6 +33,10 @@ class Registration(PolymorphicModel):
)
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))
@ -84,6 +93,10 @@ def get_random_filename(instance, filename):
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",
@ -129,6 +142,10 @@ class StudentRegistration(Registration):
class CoachRegistration(Registration):
"""
Specific registration for coaches.
They have a team and a professional activity.
"""
team = models.ForeignKey(
"participation.Team",
related_name="coachs",
@ -157,6 +174,10 @@ class CoachRegistration(Registration):
class AdminRegistration(Registration):
"""
Specific registration for admins.
They have a field to justify they status.
"""
role = models.TextField(
verbose_name=_("role of the administrator"),
)

View File

@ -4,6 +4,9 @@ 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:

View File

@ -5,10 +5,17 @@ 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:
@ -25,5 +32,9 @@ def send_email_link(instance, **_):
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)

View File

@ -5,6 +5,9 @@ from .models import Registration
class RegistrationTable(tables.Table):
"""
Table of all registrations.
"""
last_name = tables.LinkColumn(
'registration:user_detail',
args=[tables.A("user_id")],

View File

@ -10,6 +10,9 @@ from .models import CoachRegistration, Registration, 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)
@ -29,6 +32,9 @@ class TestRegistration(TestCase):
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)
@ -45,6 +51,9 @@ class TestRegistration(TestCase):
self.assertEqual(response.status_code, 200)
def test_registration(self):
"""
Ensure that the signup form is working successfully.
"""
response = self.client.get(reverse("registration:signup"))
self.assertEqual(response.status_code, 200)
@ -109,6 +118,9 @@ class TestRegistration(TestCase):
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)
@ -127,6 +139,9 @@ class TestRegistration(TestCase):
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,)))
@ -134,6 +149,9 @@ class TestRegistration(TestCase):
self.assertEqual(response.status_code, 200)
def test_update_user(self):
"""
Update the user information, for each type of user.
"""
for user, data in [(self.user, dict(role="Bot")),
(self.student, dict(student_class=11, school="Sky")),
(self.coach, dict(professional_activity="God"))]:
@ -162,6 +180,9 @@ class TestRegistration(TestCase):
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)

View File

@ -19,6 +19,9 @@ from .models import StudentRegistration
class SignupView(CreateView):
"""
Signup, as a participant or a coach.
"""
model = User
form_class = SignupForm
template_name = "registration/signup.html"
@ -126,23 +129,34 @@ class UserResendValidationEmailView(LoginRequiredMixin, DetailView):
class MyAccountDetailView(LoginRequiredMixin, RedirectView):
"""
Redirect to our own profile detail page.
"""
def get_redirect_url(self, *args, **kwargs):
return reverse_lazy("registration:user_detail", args=(self.request.user.pk,))
class UserDetailView(LoginRequiredMixin, DetailView):
"""
Display the detail about a user.
"""
model = User
context_object_name = "user_object"
template_name = "registration/user_detail.html"
def dispatch(self, request, *args, **kwargs):
user = request.user
# Only an admin or the concerned user can see the information
if not user.registration.is_admin and user.pk != kwargs["pk"]:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class UserUpdateView(LoginRequiredMixin, UpdateView):
"""
Update the detail about a user and its registration.
"""
model = User
form_class = UserForm
template_name = "registration/update_user.html"
@ -176,6 +190,9 @@ class UserUpdateView(LoginRequiredMixin, UpdateView):
class UserUploadPhotoAuthorizationView(LoginRequiredMixin, UpdateView):
"""
A participant can send its photo authorization.
"""
model = StudentRegistration
form_class = PhotoAuthorizationForm
template_name = "registration/upload_photo_authorization.html"
@ -198,6 +215,9 @@ class UserUploadPhotoAuthorizationView(LoginRequiredMixin, UpdateView):
class PhotoAuthorizationView(LoginRequiredMixin, View):
"""
Display the sent photo authorization.
"""
def get(self, request, *args, **kwargs):
filename = kwargs["filename"]
path = f"media/authorization/photo/{filename}"
@ -207,18 +227,21 @@ class PhotoAuthorizationView(LoginRequiredMixin, View):
user = request.user
if not user.registration.is_admin and user.pk != student.user.pk:
raise PermissionDenied
# Guess mime type of the file
mime = Magic(mime=True)
mime_type = mime.from_file(path)
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
# Replace file name
true_file_name = _("Photo authorization of {student}.{ext}").format(student=str(student), ext=ext)
return FileResponse(open(path, "rb"), content_type=mime_type, filename=true_file_name)
class UserImpersonateView(LoginRequiredMixin, RedirectView):
"""
An administrator can log in through this page as someone else, and act as this other person.
"""
def dispatch(self, request, *args, **kwargs):
"""
An administrator can log in through this page as someone else, and act as this other person.
"""
if self.request.user.registration.is_admin:
if not User.objects.filter(pk=kwargs["pk"]).exists():
raise Http404

View File

@ -6,11 +6,23 @@ from nio import *
class Matrix:
"""
Utility class to manage interaction with the Matrix homeserver.
This log in the @corres2mathbot account (must be created before).
The access token is then stored.
All is done with this bot account, that is a server administrator.
Tasks are normally asynchronous, but for compatibility we make
them synchronous.
"""
_token: str = None
_device_id: str = None
@classmethod
async def _get_client(cls) -> AsyncClient:
"""
Retrieve the bot account.
If not logged, log in and store access token.
"""
client = AsyncClient("https://correspondances-maths.fr", "@corres2mathbot:correspondances-maths.fr")
client.user_id = "@corres2mathbot:correspondances-maths.fr"
@ -35,12 +47,18 @@ class Matrix:
@classmethod
@async_to_sync
async def set_display_name(cls, name: str) -> Union[ProfileSetDisplayNameResponse, ProfileSetDisplayNameError]:
"""
Set the display name of the bot account.
"""
client = await cls._get_client()
return await client.set_displayname(name)
@classmethod
@async_to_sync
async def set_avatar(cls, avatar_url: str) -> Union[ProfileSetAvatarResponse, ProfileSetAvatarError]:
"""
Set the display avatar of the bot account.
"""
client = await cls._get_client()
return await client.set_avatar(avatar_url)
@ -55,6 +73,58 @@ class Matrix:
monitor: Optional[TransferMonitor] = None,
filesize: Optional[int] = None,
) -> Tuple[Union[UploadResponse, UploadError], Optional[Dict[str, Any]]]:
"""
Upload a file to the content repository.
Returns a tuple containing:
- Either a `UploadResponse` if the request was successful, or a
`UploadError` if there was an error with the request
- A dict with file decryption info if encrypt is ``True``,
else ``None``.
Args:
data_provider (Callable, SynchronousFile, AsyncFile): A function
returning the data to upload or a file object. File objects
must be opened in binary mode (``mode="r+b"``). Callables
returning a path string, Path, async iterable or aiofiles
open binary file object allow the file data to be read in an
asynchronous and lazy way (without reading the entire file
into memory). Returning a synchronous iterable or standard
open binary file object will still allow the data to be read
lazily, but not asynchronously.
The function will be called again if the upload fails
due to a server timeout, in which case it must restart
from the beginning.
Callables receive two arguments: the total number of
429 "Too many request" errors that occured, and the total
number of server timeout exceptions that occured, thus
cleanup operations can be performed for retries if necessary.
content_type (str): The content MIME type of the file,
e.g. "image/png".
Defaults to "application/octet-stream", corresponding to a
generic binary file.
Custom values are ignored if encrypt is ``True``.
filename (str, optional): The file's original name.
encrypt (bool): If the file's content should be encrypted,
necessary for files that will be sent to encrypted rooms.
Defaults to ``False``.
monitor (TransferMonitor, optional): If a ``TransferMonitor``
object is passed, it will be updated by this function while
uploading.
From this object, statistics such as currently
transferred bytes or estimated remaining time can be gathered
while the upload is running as a task; it also allows
for pausing and cancelling.
filesize (int, optional): Size in bytes for the file to transfer.
If left as ``None``, some servers might refuse the upload.
"""
client = await cls._get_client()
return await client.upload(data_provider, content_type, filename, encrypt, monitor, filesize)
@ -74,6 +144,61 @@ class Matrix:
initial_state=(),
power_level_override: Optional[Dict[str, Any]] = None,
) -> Union[RoomCreateResponse, RoomCreateError]:
"""
Create a new room.
Returns either a `RoomCreateResponse` if the request was successful or
a `RoomCreateError` if there was an error with the request.
Args:
visibility (RoomVisibility): whether to have the room published in
the server's room directory or not.
Defaults to ``RoomVisibility.private``.
alias (str, optional): The desired canonical alias local part.
For example, if set to "foo" and the room is created on the
"example.com" server, the room alias will be
"#foo:example.com".
name (str, optional): A name to set for the room.
topic (str, optional): A topic to set for the room.
room_version (str, optional): The room version to set.
If not specified, the homeserver will use its default setting.
If a version not supported by the homeserver is specified,
a 400 ``M_UNSUPPORTED_ROOM_VERSION`` error will be returned.
federate (bool): Whether to allow users from other homeservers from
joining the room. Defaults to ``True``.
Cannot be changed later.
is_direct (bool): If this should be considered a
direct messaging room.
If ``True``, the server will set the ``is_direct`` flag on
``m.room.member events`` sent to the users in ``invite``.
Defaults to ``False``.
preset (RoomPreset, optional): The selected preset will set various
rules for the room.
If unspecified, the server will choose a preset from the
``visibility``: ``RoomVisibility.public`` equates to
``RoomPreset.public_chat``, and
``RoomVisibility.private`` equates to a
``RoomPreset.private_chat``.
invite (list): A list of user id to invite to the room.
initial_state (list): A list of state event dicts to send when
the room is created.
For example, a room could be made encrypted immediatly by
having a ``m.room.encryption`` event dict.
power_level_override (dict): A ``m.room.power_levels content`` dict
to override the default.
The dict will be applied on top of the generated
``m.room.power_levels`` event before it is sent to the room.
"""
client = await cls._get_client()
return await client.room_create(
visibility, alias, name, topic, room_version, federate, is_direct, preset, invite, initial_state,
@ -81,6 +206,10 @@ class Matrix:
@classmethod
async def resolve_room_alias(cls, room_alias: str) -> Optional[str]:
"""
Resolve a room alias to a room ID.
Return None if the alias does not exist.
"""
client = await cls._get_client()
resp: RoomResolveAliasResponse = await client.room_resolve_alias(room_alias)
if isinstance(resp, RoomResolveAliasError):
@ -90,6 +219,17 @@ class Matrix:
@classmethod
@async_to_sync
async def invite(cls, room_id: str, user_id: str) -> Union[RoomInviteResponse, RoomInviteError]:
"""
Invite a user to a room.
Returns either a `RoomInviteResponse` if the request was successful or
a `RoomInviteError` if there was an error with the request.
Args:
room_id (str): The room id of the room that the user will be
invited to.
user_id (str): The user id of the user that should be invited.
"""
client = await cls._get_client()
if room_id.startswith("#"):
room_id = await cls.resolve_room_alias(room_id)
@ -98,6 +238,21 @@ class Matrix:
@classmethod
@async_to_sync
async def kick(cls, room_id: str, user_id: str, reason: str = None) -> Union[RoomKickResponse, RoomInviteError]:
"""
Kick a user from a room, or withdraw their invitation.
Kicking a user adjusts their membership to "leave" with an optional
reason.
Returns either a `RoomKickResponse` if the request was successful or
a `RoomKickError` if there was an error with the request.
Args:
room_id (str): The room id of the room that the user will be
kicked from.
user_id (str): The user_id of the user that should be kicked.
reason (str, optional): A reason for which the user is kicked.
"""
client = await cls._get_client()
if room_id.startswith("#"):
room_id = await cls.resolve_room_alias(room_id)
@ -107,6 +262,19 @@ class Matrix:
@async_to_sync
async def set_room_power_level(cls, room_id: str, user_id: str, power_level: int)\
-> Union[RoomPutStateResponse, RoomPutStateError]:
"""
Put a given power level to a user in a certain room.
Returns either a `RoomPutStateResponse` if the request was successful or
a `RoomPutStateError` if there was an error with the request.
Args:
room_id (str): The room id of the room where the power level
of the user should be updated.
user_id (str): The user_id of the user which power level should
be updated.
power_level (int): The target power level to give.
"""
client = await cls._get_client()
if room_id.startswith("#"):
room_id = await cls.resolve_room_alias(room_id)
@ -119,6 +287,20 @@ class Matrix:
@async_to_sync
async def set_room_power_level_event(cls, room_id: str, event: str, power_level: int)\
-> Union[RoomPutStateResponse, RoomPutStateError]:
"""
Define the minimal power level to have to send a certain event type
in a given room.
Returns either a `RoomPutStateResponse` if the request was successful or
a `RoomPutStateError` if there was an error with the request.
Args:
room_id (str): The room id of the room where the power level
of the event should be updated.
event (str): The event name which minimal power level should
be updated.
power_level (int): The target power level to give.
"""
client = await cls._get_client()
if room_id.startswith("#"):
room_id = await cls.resolve_room_alias(room_id)
@ -134,7 +316,19 @@ class Matrix:
@async_to_sync
async def set_room_avatar(cls, room_id: str, avatar_uri: str)\
-> Union[RoomPutStateResponse, RoomPutStateError]:
"""
Define the avatar of a room.
Returns either a `RoomPutStateResponse` if the request was successful or
a `RoomPutStateError` if there was an error with the request.
Args:
room_id (str): The room id of the room where the avatar
should be changed.
avatar_uri (str): The internal avatar URI to apply.
"""
client = await cls._get_client()
client
if room_id.startswith("#"):
room_id = await cls.resolve_room_alias(room_id)
return await client.room_put_state(room_id, "m.room.avatar", content={