diff --git a/apps/participation/apps.py b/apps/participation/apps.py index 98690b3..e775176 100644 --- a/apps/participation/apps.py +++ b/apps/participation/apps.py @@ -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): diff --git a/apps/participation/forms.py b/apps/participation/forms.py index 907e9fd..fdc9802 100644 --- a/apps/participation/forms.py +++ b/apps/participation/forms.py @@ -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',) diff --git a/apps/participation/models.py b/apps/participation/models.py index 41aa5c2..322fb9a 100644 --- a/apps/participation/models.py +++ b/apps/participation/models.py @@ -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() diff --git a/apps/participation/search_indexes.py b/apps/participation/search_indexes.py index c1a2249..734d5bb 100644 --- a/apps/participation/search_indexes.py +++ b/apps/participation/search_indexes.py @@ -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: diff --git a/apps/participation/signals.py b/apps/participation/signals.py index d8caa42..a7ee7d4 100644 --- a/apps/participation/signals.py +++ b/apps/participation/signals.py @@ -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}") diff --git a/apps/participation/tests.py b/apps/participation/tests.py index 8ff219a..abd8dd7 100644 --- a/apps/participation/tests.py +++ b/apps/participation/tests.py @@ -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) diff --git a/apps/participation/views.py b/apps/participation/views.py index 1353c39..758136b 100644 --- a/apps/participation/views.py +++ b/apps/participation/views.py @@ -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 diff --git a/apps/registration/apps.py b/apps/registration/apps.py index 3bf6fa5..7b1e19d 100644 --- a/apps/registration/apps.py +++ b/apps/registration/apps.py @@ -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): diff --git a/apps/registration/forms.py b/apps/registration/forms.py index 75a25e3..18d76eb 100644 --- a/apps/registration/forms.py +++ b/apps/registration/forms.py @@ -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',) diff --git a/apps/registration/models.py b/apps/registration/models.py index de33c65..7fe7292 100644 --- a/apps/registration/models.py +++ b/apps/registration/models.py @@ -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"), ) diff --git a/apps/registration/search_indexes.py b/apps/registration/search_indexes.py index b04b0b9..d52ce87 100644 --- a/apps/registration/search_indexes.py +++ b/apps/registration/search_indexes.py @@ -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: diff --git a/apps/registration/signals.py b/apps/registration/signals.py index 042ad06..57d440a 100644 --- a/apps/registration/signals.py +++ b/apps/registration/signals.py @@ -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) diff --git a/apps/registration/tables.py b/apps/registration/tables.py index bc617a9..7995ade 100644 --- a/apps/registration/tables.py +++ b/apps/registration/tables.py @@ -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")], diff --git a/apps/registration/tests.py b/apps/registration/tests.py index 8286565..18e5ff4 100644 --- a/apps/registration/tests.py +++ b/apps/registration/tests.py @@ -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) diff --git a/apps/registration/views.py b/apps/registration/views.py index a29b9a8..4626d31 100644 --- a/apps/registration/views.py +++ b/apps/registration/views.py @@ -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 diff --git a/corres2math/matrix.py b/corres2math/matrix.py index 784c905..cb1e0e6 100644 --- a/corres2math/matrix.py +++ b/corres2math/matrix.py @@ -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={