mirror of
https://gitlab.com/animath/si/plateforme-corres2math.git
synced 2025-02-06 10:52:59 +00:00
Add a lot of comments
This commit is contained in:
parent
971169fe2c
commit
8236a9fe14
@ -3,6 +3,9 @@ from django.db.models.signals import post_save, pre_delete, pre_save
|
|||||||
|
|
||||||
|
|
||||||
class ParticipationConfig(AppConfig):
|
class ParticipationConfig(AppConfig):
|
||||||
|
"""
|
||||||
|
The participation app contains the data about the teams, videos, ...
|
||||||
|
"""
|
||||||
name = 'participation'
|
name = 'participation'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
|
@ -9,6 +9,10 @@ from .models import Participation, Phase, Team, Video
|
|||||||
|
|
||||||
|
|
||||||
class TeamForm(forms.ModelForm):
|
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):
|
def clean_trigram(self):
|
||||||
trigram = self.cleaned_data["trigram"].upper()
|
trigram = self.cleaned_data["trigram"].upper()
|
||||||
if not re.match("[A-Z]{3}", trigram):
|
if not re.match("[A-Z]{3}", trigram):
|
||||||
@ -21,6 +25,9 @@ class TeamForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class JoinTeamForm(forms.ModelForm):
|
class JoinTeamForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form to join a team by the access code.
|
||||||
|
"""
|
||||||
def clean_access_code(self):
|
def clean_access_code(self):
|
||||||
access_code = self.cleaned_data["access_code"]
|
access_code = self.cleaned_data["access_code"]
|
||||||
if not Team.objects.filter(access_code=access_code).exists():
|
if not Team.objects.filter(access_code=access_code).exists():
|
||||||
@ -40,12 +47,18 @@ class JoinTeamForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class ParticipationForm(forms.ModelForm):
|
class ParticipationForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form to update the problem of a team participation.
|
||||||
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Participation
|
model = Participation
|
||||||
fields = ('problem',)
|
fields = ('problem',)
|
||||||
|
|
||||||
|
|
||||||
class RequestValidationForm(forms.Form):
|
class RequestValidationForm(forms.Form):
|
||||||
|
"""
|
||||||
|
Form to ask about validation.
|
||||||
|
"""
|
||||||
_form_type = forms.CharField(
|
_form_type = forms.CharField(
|
||||||
initial="RequestValidationForm",
|
initial="RequestValidationForm",
|
||||||
widget=forms.HiddenInput(),
|
widget=forms.HiddenInput(),
|
||||||
@ -58,6 +71,9 @@ class RequestValidationForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class ValidateParticipationForm(forms.Form):
|
class ValidateParticipationForm(forms.Form):
|
||||||
|
"""
|
||||||
|
Form to let administrators to accept or refuse a team.
|
||||||
|
"""
|
||||||
_form_type = forms.CharField(
|
_form_type = forms.CharField(
|
||||||
initial="ValidateParticipationForm",
|
initial="ValidateParticipationForm",
|
||||||
widget=forms.HiddenInput(),
|
widget=forms.HiddenInput(),
|
||||||
@ -70,6 +86,9 @@ class ValidateParticipationForm(forms.Form):
|
|||||||
|
|
||||||
|
|
||||||
class UploadVideoForm(forms.ModelForm):
|
class UploadVideoForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form to upload a video, for a solution or a synthesis.
|
||||||
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Video
|
model = Video
|
||||||
fields = ('link',)
|
fields = ('link',)
|
||||||
@ -81,6 +100,9 @@ class UploadVideoForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class PhaseForm(forms.ModelForm):
|
class PhaseForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form to update the calendar of a phase.
|
||||||
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Phase
|
model = Phase
|
||||||
fields = ('start', 'end',)
|
fields = ('start', 'end',)
|
||||||
|
@ -17,6 +17,10 @@ from nio import RoomPreset, RoomVisibility
|
|||||||
|
|
||||||
|
|
||||||
class Team(models.Model):
|
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(
|
name = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
verbose_name=_("name"),
|
verbose_name=_("name"),
|
||||||
@ -45,9 +49,15 @@ class Team(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def email(self):
|
def email(self):
|
||||||
|
"""
|
||||||
|
:return: The mailing list to contact the team members.
|
||||||
|
"""
|
||||||
return f"equipe-{self.trigram.lower()}@{os.getenv('SYMPA_HOST', 'localhost')}"
|
return f"equipe-{self.trigram.lower()}@{os.getenv('SYMPA_HOST', 'localhost')}"
|
||||||
|
|
||||||
def create_mailing_list(self):
|
def create_mailing_list(self):
|
||||||
|
"""
|
||||||
|
Create a new Sympa mailing list to contact the team.
|
||||||
|
"""
|
||||||
get_sympa_client().create_list(
|
get_sympa_client().create_list(
|
||||||
f"equipe-{self.trigram.lower()}",
|
f"equipe-{self.trigram.lower()}",
|
||||||
f"Équipe {self.name} ({self.trigram})",
|
f"Équipe {self.name} ({self.trigram})",
|
||||||
@ -58,10 +68,15 @@ class Team(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def delete_mailing_list(self):
|
def delete_mailing_list(self):
|
||||||
|
"""
|
||||||
|
Drop the Sympa mailing list, if the team is empty or if the trigram changed.
|
||||||
|
"""
|
||||||
get_sympa_client().delete_list(f"equipe-{self.trigram}")
|
get_sympa_client().delete_list(f"equipe-{self.trigram}")
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.access_code:
|
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.access_code = get_random_string(6)
|
||||||
self.create_mailing_list()
|
self.create_mailing_list()
|
||||||
|
|
||||||
@ -90,6 +105,10 @@ class Team(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Participation(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 = models.OneToOneField(
|
||||||
Team,
|
Team,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@ -149,6 +168,9 @@ class Participation(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Video(models.Model):
|
class Video(models.Model):
|
||||||
|
"""
|
||||||
|
The Video model only contains a link and a validity status.
|
||||||
|
"""
|
||||||
link = models.URLField(
|
link = models.URLField(
|
||||||
verbose_name=_("link"),
|
verbose_name=_("link"),
|
||||||
help_text=_("The full video link."),
|
help_text=_("The full video link."),
|
||||||
@ -163,23 +185,38 @@ class Video(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def participation(self):
|
def participation(self):
|
||||||
|
"""
|
||||||
|
Retrives the participation that is associated to this video,
|
||||||
|
whatever it is a solution or a synthesis.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
# If this is a solution
|
||||||
return self.participation_solution
|
return self.participation_solution
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
|
# If this is a synthesis
|
||||||
return self.participation_synthesis
|
return self.participation_synthesis
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def platform(self):
|
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:
|
if "youtube.com" in self.link or "youtu.be" in self.link:
|
||||||
return "youtube"
|
return "youtube"
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def youtube_code(self):
|
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-_]*)?.*?")\
|
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)
|
.match("https://www.youtube.com/watch?v=73nsrixx7eI").group(4)
|
||||||
|
|
||||||
def as_iframe(self):
|
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":
|
if self.platform == "youtube":
|
||||||
return render_to_string("participation/youtube_iframe.html", context=dict(youtube_code=self.youtube_code))
|
return render_to_string("participation/youtube_iframe.html", context=dict(youtube_code=self.youtube_code))
|
||||||
return None
|
return None
|
||||||
@ -194,6 +231,9 @@ class Video(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Phase(models.Model):
|
class Phase(models.Model):
|
||||||
|
"""
|
||||||
|
The Phase model corresponds to the dates of the phase.
|
||||||
|
"""
|
||||||
phase_number = models.AutoField(
|
phase_number = models.AutoField(
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
unique=True,
|
unique=True,
|
||||||
@ -217,6 +257,9 @@ class Phase(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def current_phase(cls):
|
def current_phase(cls):
|
||||||
|
"""
|
||||||
|
Retrieve the current phase of this day
|
||||||
|
"""
|
||||||
qs = Phase.objects.filter(start__lte=timezone.now(), end__gte=timezone.now())
|
qs = Phase.objects.filter(start__lte=timezone.now(), end__gte=timezone.now())
|
||||||
if qs.exists():
|
if qs.exists():
|
||||||
return qs.get()
|
return qs.get()
|
||||||
|
@ -4,6 +4,9 @@ from .models import Participation, Team, Video
|
|||||||
|
|
||||||
|
|
||||||
class TeamIndex(indexes.ModelSearchIndex, indexes.Indexable):
|
class TeamIndex(indexes.ModelSearchIndex, indexes.Indexable):
|
||||||
|
"""
|
||||||
|
Index all teams by their name and trigram.
|
||||||
|
"""
|
||||||
text = indexes.NgramField(document=True, use_template=True)
|
text = indexes.NgramField(document=True, use_template=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -11,6 +14,9 @@ class TeamIndex(indexes.ModelSearchIndex, indexes.Indexable):
|
|||||||
|
|
||||||
|
|
||||||
class ParticipationIndex(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)
|
text = indexes.NgramField(document=True, use_template=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -18,6 +24,9 @@ class ParticipationIndex(indexes.ModelSearchIndex, indexes.Indexable):
|
|||||||
|
|
||||||
|
|
||||||
class VideoIndex(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)
|
text = indexes.NgramField(document=True, use_template=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -3,6 +3,9 @@ from participation.models import Participation, Team, Video
|
|||||||
|
|
||||||
|
|
||||||
def create_team_participation(instance, **_):
|
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]
|
participation = Participation.objects.get_or_create(team=instance)[0]
|
||||||
if not participation.solution:
|
if not participation.solution:
|
||||||
participation.solution = Video.objects.create()
|
participation.solution = Video.objects.create()
|
||||||
@ -12,11 +15,17 @@ def create_team_participation(instance, **_):
|
|||||||
|
|
||||||
|
|
||||||
def update_mailing_list(instance: Team, **_):
|
def update_mailing_list(instance: Team, **_):
|
||||||
|
"""
|
||||||
|
When a team name or trigram got updated, update mailing lists and Matrix rooms
|
||||||
|
"""
|
||||||
if instance.pk:
|
if instance.pk:
|
||||||
old_team = Team.objects.get(pk=instance.pk)
|
old_team = Team.objects.get(pk=instance.pk)
|
||||||
if old_team.name != instance.name or old_team.trigram != instance.trigram:
|
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()
|
old_team.delete_mailing_list()
|
||||||
instance.create_mailing_list()
|
instance.create_mailing_list()
|
||||||
|
# Subscribe all team members in the mailing list
|
||||||
for student in instance.students.all():
|
for student in instance.students.all():
|
||||||
get_sympa_client().subscribe(student.user.email, f"equipe-{instance.trigram.lower()}", False,
|
get_sympa_client().subscribe(student.user.email, f"equipe-{instance.trigram.lower()}", False,
|
||||||
f"{student.user.first_name} {student.user.last_name}")
|
f"{student.user.first_name} {student.user.last_name}")
|
||||||
|
@ -34,6 +34,9 @@ class TestStudentParticipation(TestCase):
|
|||||||
str(self.team.participation)
|
str(self.team.participation)
|
||||||
|
|
||||||
def test_create_team(self):
|
def test_create_team(self):
|
||||||
|
"""
|
||||||
|
Try to create a team.
|
||||||
|
"""
|
||||||
response = self.client.get(reverse("participation:create_team"))
|
response = self.client.get(reverse("participation:create_team"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@ -61,6 +64,9 @@ class TestStudentParticipation(TestCase):
|
|||||||
))
|
))
|
||||||
|
|
||||||
def test_join_team(self):
|
def test_join_team(self):
|
||||||
|
"""
|
||||||
|
Try to join an existing team.
|
||||||
|
"""
|
||||||
response = self.client.get(reverse("participation:join_team"))
|
response = self.client.get(reverse("participation:join_team"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@ -84,10 +90,16 @@ class TestStudentParticipation(TestCase):
|
|||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
def test_no_myteam_redirect_noteam(self):
|
def test_no_myteam_redirect_noteam(self):
|
||||||
|
"""
|
||||||
|
Test redirection.
|
||||||
|
"""
|
||||||
response = self.client.get(reverse("participation:my_team_detail"))
|
response = self.client.get(reverse("participation:my_team_detail"))
|
||||||
self.assertTrue(response.status_code, 200)
|
self.assertTrue(response.status_code, 200)
|
||||||
|
|
||||||
def test_team_detail(self):
|
def test_team_detail(self):
|
||||||
|
"""
|
||||||
|
Try to display the information of a team.
|
||||||
|
"""
|
||||||
self.user.registration.team = self.team
|
self.user.registration.team = self.team
|
||||||
self.user.registration.save()
|
self.user.registration.save()
|
||||||
|
|
||||||
@ -98,6 +110,9 @@ class TestStudentParticipation(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_update_team(self):
|
def test_update_team(self):
|
||||||
|
"""
|
||||||
|
Try to update team information.
|
||||||
|
"""
|
||||||
self.user.registration.team = self.team
|
self.user.registration.team = self.team
|
||||||
self.user.registration.save()
|
self.user.registration.save()
|
||||||
|
|
||||||
@ -123,10 +138,16 @@ class TestStudentParticipation(TestCase):
|
|||||||
self.assertTrue(Team.objects.filter(trigram="BBB", participation__problem=3).exists())
|
self.assertTrue(Team.objects.filter(trigram="BBB", participation__problem=3).exists())
|
||||||
|
|
||||||
def test_no_myparticipation_redirect_nomyparticipation(self):
|
def test_no_myparticipation_redirect_nomyparticipation(self):
|
||||||
|
"""
|
||||||
|
Ensure a permission denied when we search my team participation when we are in no team.
|
||||||
|
"""
|
||||||
response = self.client.get(reverse("participation:my_participation_detail"))
|
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):
|
def test_participation_detail(self):
|
||||||
|
"""
|
||||||
|
Try to display the detail of a team participation.
|
||||||
|
"""
|
||||||
self.user.registration.team = self.team
|
self.user.registration.team = self.team
|
||||||
self.user.registration.save()
|
self.user.registration.save()
|
||||||
|
|
||||||
@ -146,6 +167,9 @@ class TestStudentParticipation(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_upload_video(self):
|
def test_upload_video(self):
|
||||||
|
"""
|
||||||
|
Try to send a solution video link.
|
||||||
|
"""
|
||||||
self.user.registration.team = self.team
|
self.user.registration.team = self.team
|
||||||
self.user.registration.save()
|
self.user.registration.save()
|
||||||
|
|
||||||
@ -178,21 +202,30 @@ class TestAdminForbidden(TestCase):
|
|||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
def test_create_team_forbidden(self):
|
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(
|
response = self.client.post(reverse("participation:create_team"), data=dict(
|
||||||
name="Test team",
|
name="Test team",
|
||||||
trigram="TES",
|
trigram="TES",
|
||||||
grant_animath_access_videos=False,
|
grant_animath_access_videos=False,
|
||||||
))
|
))
|
||||||
self.assertTrue(response.status_code, 200)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
def test_join_team_forbidden(self):
|
def test_join_team_forbidden(self):
|
||||||
|
"""
|
||||||
|
Ensure that an admin can't join a team.
|
||||||
|
"""
|
||||||
team = Team.objects.create(name="Test", trigram="TES")
|
team = Team.objects.create(name="Test", trigram="TES")
|
||||||
|
|
||||||
response = self.client.post(reverse("participation:join_team"), data=dict(
|
response = self.client.post(reverse("participation:join_team"), data=dict(
|
||||||
access_code=team.access_code,
|
access_code=team.access_code,
|
||||||
))
|
))
|
||||||
self.assertTrue(response.status_code, 200)
|
self.assertTrue(response.status_code, 403)
|
||||||
|
|
||||||
def test_my_team_forbidden(self):
|
def test_my_team_forbidden(self):
|
||||||
|
"""
|
||||||
|
Ensure that an admin can't access to "My team".
|
||||||
|
"""
|
||||||
response = self.client.get(reverse("participation:my_team_detail"))
|
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):
|
class CreateTeamView(LoginRequiredMixin, CreateView):
|
||||||
|
"""
|
||||||
|
Display the page to create a team for new users.
|
||||||
|
"""
|
||||||
|
|
||||||
model = Team
|
model = Team
|
||||||
form_class = TeamForm
|
form_class = TeamForm
|
||||||
extra_context = dict(title=_("Create team"))
|
extra_context = dict(title=_("Create team"))
|
||||||
@ -43,14 +47,24 @@ class CreateTeamView(LoginRequiredMixin, CreateView):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def form_valid(self, form):
|
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)
|
ret = super().form_valid(form)
|
||||||
|
# The user joins the team
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
registration = user.registration
|
registration = user.registration
|
||||||
registration.team = form.instance
|
registration.team = form.instance
|
||||||
registration.save()
|
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,
|
get_sympa_client().subscribe(user.email, f"equipe-{form.instance.trigram.lower()}", False,
|
||||||
f"{user.first_name} {user.last_name}")
|
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",
|
Matrix.invite(f"#team-{form.instance.trigram.lower()}:correspondances-maths.fr",
|
||||||
f"@{user.registration.matrix_username}:correspondances-maths.fr")
|
f"@{user.registration.matrix_username}:correspondances-maths.fr")
|
||||||
return ret
|
return ret
|
||||||
@ -60,6 +74,9 @@ class CreateTeamView(LoginRequiredMixin, CreateView):
|
|||||||
|
|
||||||
|
|
||||||
class JoinTeamView(LoginRequiredMixin, FormView):
|
class JoinTeamView(LoginRequiredMixin, FormView):
|
||||||
|
"""
|
||||||
|
Participants can join a team with the access code of the team.
|
||||||
|
"""
|
||||||
model = Team
|
model = Team
|
||||||
form_class = JoinTeamForm
|
form_class = JoinTeamForm
|
||||||
extra_context = dict(title=_("Join team"))
|
extra_context = dict(title=_("Join team"))
|
||||||
@ -76,15 +93,24 @@ class JoinTeamView(LoginRequiredMixin, FormView):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def form_valid(self, form):
|
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
|
self.object = form.instance
|
||||||
ret = super().form_valid(form)
|
ret = super().form_valid(form)
|
||||||
|
|
||||||
|
# Join the team
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
registration = user.registration
|
registration = user.registration
|
||||||
registration.team = form.instance
|
registration.team = form.instance
|
||||||
registration.save()
|
registration.save()
|
||||||
|
|
||||||
|
# Subscribe to the team mailing list
|
||||||
get_sympa_client().subscribe(user.email, f"equipe-{form.instance.trigram.lower()}", False,
|
get_sympa_client().subscribe(user.email, f"equipe-{form.instance.trigram.lower()}", False,
|
||||||
f"{user.first_name} {user.last_name}")
|
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",
|
Matrix.invite(f"#team-{form.instance.trigram.lower()}:correspondances-maths.fr",
|
||||||
f"@{user.registration.matrix_username}:correspondances-maths.fr")
|
f"@{user.registration.matrix_username}:correspondances-maths.fr")
|
||||||
return ret
|
return ret
|
||||||
@ -94,6 +120,10 @@ class JoinTeamView(LoginRequiredMixin, FormView):
|
|||||||
|
|
||||||
|
|
||||||
class MyTeamDetailView(LoginRequiredMixin, RedirectView):
|
class MyTeamDetailView(LoginRequiredMixin, RedirectView):
|
||||||
|
"""
|
||||||
|
Redirect to the detail of the team in which the user is.
|
||||||
|
"""
|
||||||
|
|
||||||
def get_redirect_url(self, *args, **kwargs):
|
def get_redirect_url(self, *args, **kwargs):
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
registration = user.registration
|
registration = user.registration
|
||||||
@ -105,11 +135,15 @@ class MyTeamDetailView(LoginRequiredMixin, RedirectView):
|
|||||||
|
|
||||||
|
|
||||||
class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView):
|
class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView):
|
||||||
|
"""
|
||||||
|
Display the detail of a team.
|
||||||
|
"""
|
||||||
model = Team
|
model = Team
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
user = request.user
|
user = request.user
|
||||||
self.object = self.get_object()
|
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"]:
|
if user.registration.is_admin or user.registration.participates and user.registration.team.pk == kwargs["pk"]:
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
@ -120,6 +154,8 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
|
|||||||
team = self.get_object()
|
team = self.get_object()
|
||||||
context["request_validation_form"] = RequestValidationForm(self.request.POST or None)
|
context["request_validation_form"] = RequestValidationForm(self.request.POST or None)
|
||||||
context["validation_form"] = ValidateParticipationForm(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 \
|
context["can_validate"] = team.students.count() >= 3 and \
|
||||||
all(r.email_confirmed for r in team.students.all()) and \
|
all(r.email_confirmed for r in team.students.all()) and \
|
||||||
all(r.photo_authorization 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):
|
class TeamUpdateView(LoginRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
Update the detail of a team
|
||||||
|
"""
|
||||||
model = Team
|
model = Team
|
||||||
form_class = TeamForm
|
form_class = TeamForm
|
||||||
template_name = "participation/update_team.html"
|
template_name = "participation/update_team.html"
|
||||||
@ -218,6 +257,9 @@ class TeamUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
|
|
||||||
|
|
||||||
class TeamAuthorizationsView(LoginRequiredMixin, DetailView):
|
class TeamAuthorizationsView(LoginRequiredMixin, DetailView):
|
||||||
|
"""
|
||||||
|
Get as a ZIP archive all the authorizations that are sent
|
||||||
|
"""
|
||||||
model = Team
|
model = Team
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
@ -245,6 +287,10 @@ class TeamAuthorizationsView(LoginRequiredMixin, DetailView):
|
|||||||
|
|
||||||
|
|
||||||
class TeamLeaveView(LoginRequiredMixin, TemplateView):
|
class TeamLeaveView(LoginRequiredMixin, TemplateView):
|
||||||
|
"""
|
||||||
|
A team member leaves a team
|
||||||
|
"""
|
||||||
|
|
||||||
template_name = "participation/team_leave.html"
|
template_name = "participation/team_leave.html"
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
@ -258,6 +304,10 @@ class TeamLeaveView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
def post(self, request, *args, **kwargs):
|
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
|
team = request.user.registration.team
|
||||||
request.user.registration.team = None
|
request.user.registration.team = None
|
||||||
request.user.registration.save()
|
request.user.registration.save()
|
||||||
@ -271,6 +321,9 @@ class TeamLeaveView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
|
|
||||||
class MyParticipationDetailView(LoginRequiredMixin, RedirectView):
|
class MyParticipationDetailView(LoginRequiredMixin, RedirectView):
|
||||||
|
"""
|
||||||
|
Redirects to the detail view of the participation of the team.
|
||||||
|
"""
|
||||||
def get_redirect_url(self, *args, **kwargs):
|
def get_redirect_url(self, *args, **kwargs):
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
registration = user.registration
|
registration = user.registration
|
||||||
@ -282,6 +335,9 @@ class MyParticipationDetailView(LoginRequiredMixin, RedirectView):
|
|||||||
|
|
||||||
|
|
||||||
class ParticipationDetailView(LoginRequiredMixin, DetailView):
|
class ParticipationDetailView(LoginRequiredMixin, DetailView):
|
||||||
|
"""
|
||||||
|
Display detail about the participation of a team, and manage the video submission.
|
||||||
|
"""
|
||||||
model = Participation
|
model = Participation
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
@ -303,6 +359,9 @@ class ParticipationDetailView(LoginRequiredMixin, DetailView):
|
|||||||
|
|
||||||
|
|
||||||
class UploadVideoView(LoginRequiredMixin, UpdateView):
|
class UploadVideoView(LoginRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
Upload a solution video for a team.
|
||||||
|
"""
|
||||||
model = Video
|
model = Video
|
||||||
form_class = UploadVideoForm
|
form_class = UploadVideoForm
|
||||||
template_name = "participation/upload_video.html"
|
template_name = "participation/upload_video.html"
|
||||||
@ -319,11 +378,17 @@ class UploadVideoView(LoginRequiredMixin, UpdateView):
|
|||||||
|
|
||||||
|
|
||||||
class CalendarView(SingleTableView):
|
class CalendarView(SingleTableView):
|
||||||
|
"""
|
||||||
|
Display the calendar of the action.
|
||||||
|
"""
|
||||||
table_class = CalendarTable
|
table_class = CalendarTable
|
||||||
model = Phase
|
model = Phase
|
||||||
|
|
||||||
|
|
||||||
class PhaseUpdateView(AdminMixin, UpdateView):
|
class PhaseUpdateView(AdminMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
Update a phase of the calendar, if we have sufficient rights.
|
||||||
|
"""
|
||||||
model = Phase
|
model = Phase
|
||||||
form_class = PhaseForm
|
form_class = PhaseForm
|
||||||
|
|
||||||
|
@ -3,6 +3,9 @@ from django.db.models.signals import post_save, pre_save
|
|||||||
|
|
||||||
|
|
||||||
class RegistrationConfig(AppConfig):
|
class RegistrationConfig(AppConfig):
|
||||||
|
"""
|
||||||
|
Registration app contains the detail about users only.
|
||||||
|
"""
|
||||||
name = 'registration'
|
name = 'registration'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
|
@ -9,6 +9,11 @@ from .models import AdminRegistration, CoachRegistration, StudentRegistration
|
|||||||
|
|
||||||
|
|
||||||
class SignupForm(UserCreationForm):
|
class SignupForm(UserCreationForm):
|
||||||
|
"""
|
||||||
|
Signup form to registers participants and coaches
|
||||||
|
They can choose the role at the registration.
|
||||||
|
"""
|
||||||
|
|
||||||
role = forms.ChoiceField(
|
role = forms.ChoiceField(
|
||||||
label=lambda: _("role").capitalize(),
|
label=lambda: _("role").capitalize(),
|
||||||
choices=lambda: [
|
choices=lambda: [
|
||||||
@ -29,6 +34,10 @@ class SignupForm(UserCreationForm):
|
|||||||
|
|
||||||
|
|
||||||
class UserForm(forms.ModelForm):
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["first_name"].required = True
|
self.fields["first_name"].required = True
|
||||||
@ -41,12 +50,18 @@ class UserForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class StudentRegistrationForm(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:
|
class Meta:
|
||||||
model = StudentRegistration
|
model = StudentRegistration
|
||||||
fields = ('student_class', 'school', 'give_contact_to_animath',)
|
fields = ('student_class', 'school', 'give_contact_to_animath',)
|
||||||
|
|
||||||
|
|
||||||
class PhotoAuthorizationForm(forms.ModelForm):
|
class PhotoAuthorizationForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form to send a photo authorization.
|
||||||
|
"""
|
||||||
def clean_photo_authorization(self):
|
def clean_photo_authorization(self):
|
||||||
file = self.files["photo_authorization"]
|
file = self.files["photo_authorization"]
|
||||||
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
|
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
|
||||||
@ -63,12 +78,18 @@ class PhotoAuthorizationForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class CoachRegistrationForm(forms.ModelForm):
|
class CoachRegistrationForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
A coach can tell its professional activity.
|
||||||
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CoachRegistration
|
model = CoachRegistration
|
||||||
fields = ('professional_activity', 'give_contact_to_animath',)
|
fields = ('professional_activity', 'give_contact_to_animath',)
|
||||||
|
|
||||||
|
|
||||||
class AdminRegistrationForm(forms.ModelForm):
|
class AdminRegistrationForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Admins can tell everything they want.
|
||||||
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
model = AdminRegistration
|
model = AdminRegistration
|
||||||
fields = ('role', 'give_contact_to_animath',)
|
fields = ('role', 'give_contact_to_animath',)
|
||||||
|
@ -11,6 +11,11 @@ from polymorphic.models import PolymorphicModel
|
|||||||
|
|
||||||
|
|
||||||
class Registration(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(
|
user = models.OneToOneField(
|
||||||
"auth.User",
|
"auth.User",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@ -28,6 +33,10 @@ class Registration(PolymorphicModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def send_email_validation_link(self):
|
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"))
|
subject = "[Corres2math] " + str(_("Activate your Correspondances account"))
|
||||||
token = email_validation_token.make_token(self.user)
|
token = email_validation_token.make_token(self.user)
|
||||||
uid = urlsafe_base64_encode(force_bytes(self.user.pk))
|
uid = urlsafe_base64_encode(force_bytes(self.user.pk))
|
||||||
@ -84,6 +93,10 @@ def get_random_filename(instance, filename):
|
|||||||
|
|
||||||
|
|
||||||
class StudentRegistration(Registration):
|
class StudentRegistration(Registration):
|
||||||
|
"""
|
||||||
|
Specific registration for students.
|
||||||
|
They have a team, a student class and a school.
|
||||||
|
"""
|
||||||
team = models.ForeignKey(
|
team = models.ForeignKey(
|
||||||
"participation.Team",
|
"participation.Team",
|
||||||
related_name="students",
|
related_name="students",
|
||||||
@ -129,6 +142,10 @@ class StudentRegistration(Registration):
|
|||||||
|
|
||||||
|
|
||||||
class CoachRegistration(Registration):
|
class CoachRegistration(Registration):
|
||||||
|
"""
|
||||||
|
Specific registration for coaches.
|
||||||
|
They have a team and a professional activity.
|
||||||
|
"""
|
||||||
team = models.ForeignKey(
|
team = models.ForeignKey(
|
||||||
"participation.Team",
|
"participation.Team",
|
||||||
related_name="coachs",
|
related_name="coachs",
|
||||||
@ -157,6 +174,10 @@ class CoachRegistration(Registration):
|
|||||||
|
|
||||||
|
|
||||||
class AdminRegistration(Registration):
|
class AdminRegistration(Registration):
|
||||||
|
"""
|
||||||
|
Specific registration for admins.
|
||||||
|
They have a field to justify they status.
|
||||||
|
"""
|
||||||
role = models.TextField(
|
role = models.TextField(
|
||||||
verbose_name=_("role of the administrator"),
|
verbose_name=_("role of the administrator"),
|
||||||
)
|
)
|
||||||
|
@ -4,6 +4,9 @@ from .models import Registration
|
|||||||
|
|
||||||
|
|
||||||
class RegistrationIndex(indexes.ModelSearchIndex, indexes.Indexable):
|
class RegistrationIndex(indexes.ModelSearchIndex, indexes.Indexable):
|
||||||
|
"""
|
||||||
|
Registrations are indexed by the user detail.
|
||||||
|
"""
|
||||||
text = indexes.NgramField(document=True, use_template=True)
|
text = indexes.NgramField(document=True, use_template=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -5,10 +5,17 @@ from .models import AdminRegistration, Registration
|
|||||||
|
|
||||||
|
|
||||||
def set_username(instance, **_):
|
def set_username(instance, **_):
|
||||||
|
"""
|
||||||
|
Ensure that the user username is always equal to the user email address.
|
||||||
|
"""
|
||||||
instance.username = instance.email
|
instance.username = instance.email
|
||||||
|
|
||||||
|
|
||||||
def send_email_link(instance, **_):
|
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:
|
if instance.pk:
|
||||||
old_instance = User.objects.get(pk=instance.pk)
|
old_instance = User.objects.get(pk=instance.pk)
|
||||||
if old_instance.email != instance.email:
|
if old_instance.email != instance.email:
|
||||||
@ -25,5 +32,9 @@ def send_email_link(instance, **_):
|
|||||||
|
|
||||||
|
|
||||||
def create_admin_registration(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:
|
if instance.is_superuser:
|
||||||
AdminRegistration.objects.get_or_create(user=instance)
|
AdminRegistration.objects.get_or_create(user=instance)
|
||||||
|
@ -5,6 +5,9 @@ from .models import Registration
|
|||||||
|
|
||||||
|
|
||||||
class RegistrationTable(tables.Table):
|
class RegistrationTable(tables.Table):
|
||||||
|
"""
|
||||||
|
Table of all registrations.
|
||||||
|
"""
|
||||||
last_name = tables.LinkColumn(
|
last_name = tables.LinkColumn(
|
||||||
'registration:user_detail',
|
'registration:user_detail',
|
||||||
args=[tables.A("user_id")],
|
args=[tables.A("user_id")],
|
||||||
|
@ -10,6 +10,9 @@ from .models import CoachRegistration, Registration, StudentRegistration
|
|||||||
|
|
||||||
class TestIndexPage(TestCase):
|
class TestIndexPage(TestCase):
|
||||||
def test_index(self) -> None:
|
def test_index(self) -> None:
|
||||||
|
"""
|
||||||
|
Display the index page, without any right.
|
||||||
|
"""
|
||||||
response = self.client.get(reverse("index"))
|
response = self.client.get(reverse("index"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@ -29,6 +32,9 @@ class TestRegistration(TestCase):
|
|||||||
CoachRegistration.objects.create(user=self.coach, professional_activity="Teacher")
|
CoachRegistration.objects.create(user=self.coach, professional_activity="Teacher")
|
||||||
|
|
||||||
def test_admin_pages(self):
|
def test_admin_pages(self):
|
||||||
|
"""
|
||||||
|
Check that admin pages are rendering successfully.
|
||||||
|
"""
|
||||||
response = self.client.get(reverse("admin:index") + "registration/registration/")
|
response = self.client.get(reverse("admin:index") + "registration/registration/")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@ -45,6 +51,9 @@ class TestRegistration(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_registration(self):
|
def test_registration(self):
|
||||||
|
"""
|
||||||
|
Ensure that the signup form is working successfully.
|
||||||
|
"""
|
||||||
response = self.client.get(reverse("registration:signup"))
|
response = self.client.get(reverse("registration:signup"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@ -109,6 +118,9 @@ class TestRegistration(TestCase):
|
|||||||
self.assertRedirects(response, reverse("registration:email_validation_sent"), 302, 200)
|
self.assertRedirects(response, reverse("registration:email_validation_sent"), 302, 200)
|
||||||
|
|
||||||
def test_login(self):
|
def test_login(self):
|
||||||
|
"""
|
||||||
|
With a registered user, try to log in
|
||||||
|
"""
|
||||||
response = self.client.get(reverse("login"))
|
response = self.client.get(reverse("login"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
@ -127,6 +139,9 @@ class TestRegistration(TestCase):
|
|||||||
self.assertRedirects(response, reverse("index"), 302, 200)
|
self.assertRedirects(response, reverse("index"), 302, 200)
|
||||||
|
|
||||||
def test_user_detail(self):
|
def test_user_detail(self):
|
||||||
|
"""
|
||||||
|
Load a user detail page.
|
||||||
|
"""
|
||||||
response = self.client.get(reverse("registration:my_account_detail"))
|
response = self.client.get(reverse("registration:my_account_detail"))
|
||||||
self.assertRedirects(response, reverse("registration:user_detail", args=(self.user.pk,)))
|
self.assertRedirects(response, reverse("registration:user_detail", args=(self.user.pk,)))
|
||||||
|
|
||||||
@ -134,6 +149,9 @@ class TestRegistration(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_update_user(self):
|
def test_update_user(self):
|
||||||
|
"""
|
||||||
|
Update the user information, for each type of user.
|
||||||
|
"""
|
||||||
for user, data in [(self.user, dict(role="Bot")),
|
for user, data in [(self.user, dict(role="Bot")),
|
||||||
(self.student, dict(student_class=11, school="Sky")),
|
(self.student, dict(student_class=11, school="Sky")),
|
||||||
(self.coach, dict(professional_activity="God"))]:
|
(self.coach, dict(professional_activity="God"))]:
|
||||||
@ -162,6 +180,9 @@ class TestRegistration(TestCase):
|
|||||||
self.assertEqual(user.first_name, "Changed")
|
self.assertEqual(user.first_name, "Changed")
|
||||||
|
|
||||||
def test_upload_photo_authorization(self):
|
def test_upload_photo_authorization(self):
|
||||||
|
"""
|
||||||
|
Try to upload a photo authorization.
|
||||||
|
"""
|
||||||
response = self.client.get(reverse("registration:upload_user_photo_authorization",
|
response = self.client.get(reverse("registration:upload_user_photo_authorization",
|
||||||
args=(self.student.registration.pk,)))
|
args=(self.student.registration.pk,)))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
@ -19,6 +19,9 @@ from .models import StudentRegistration
|
|||||||
|
|
||||||
|
|
||||||
class SignupView(CreateView):
|
class SignupView(CreateView):
|
||||||
|
"""
|
||||||
|
Signup, as a participant or a coach.
|
||||||
|
"""
|
||||||
model = User
|
model = User
|
||||||
form_class = SignupForm
|
form_class = SignupForm
|
||||||
template_name = "registration/signup.html"
|
template_name = "registration/signup.html"
|
||||||
@ -126,23 +129,34 @@ class UserResendValidationEmailView(LoginRequiredMixin, DetailView):
|
|||||||
|
|
||||||
|
|
||||||
class MyAccountDetailView(LoginRequiredMixin, RedirectView):
|
class MyAccountDetailView(LoginRequiredMixin, RedirectView):
|
||||||
|
"""
|
||||||
|
Redirect to our own profile detail page.
|
||||||
|
"""
|
||||||
def get_redirect_url(self, *args, **kwargs):
|
def get_redirect_url(self, *args, **kwargs):
|
||||||
return reverse_lazy("registration:user_detail", args=(self.request.user.pk,))
|
return reverse_lazy("registration:user_detail", args=(self.request.user.pk,))
|
||||||
|
|
||||||
|
|
||||||
class UserDetailView(LoginRequiredMixin, DetailView):
|
class UserDetailView(LoginRequiredMixin, DetailView):
|
||||||
|
"""
|
||||||
|
Display the detail about a user.
|
||||||
|
"""
|
||||||
|
|
||||||
model = User
|
model = User
|
||||||
context_object_name = "user_object"
|
context_object_name = "user_object"
|
||||||
template_name = "registration/user_detail.html"
|
template_name = "registration/user_detail.html"
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
user = request.user
|
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"]:
|
if not user.registration.is_admin and user.pk != kwargs["pk"]:
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class UserUpdateView(LoginRequiredMixin, UpdateView):
|
class UserUpdateView(LoginRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
Update the detail about a user and its registration.
|
||||||
|
"""
|
||||||
model = User
|
model = User
|
||||||
form_class = UserForm
|
form_class = UserForm
|
||||||
template_name = "registration/update_user.html"
|
template_name = "registration/update_user.html"
|
||||||
@ -176,6 +190,9 @@ class UserUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
|
|
||||||
|
|
||||||
class UserUploadPhotoAuthorizationView(LoginRequiredMixin, UpdateView):
|
class UserUploadPhotoAuthorizationView(LoginRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
A participant can send its photo authorization.
|
||||||
|
"""
|
||||||
model = StudentRegistration
|
model = StudentRegistration
|
||||||
form_class = PhotoAuthorizationForm
|
form_class = PhotoAuthorizationForm
|
||||||
template_name = "registration/upload_photo_authorization.html"
|
template_name = "registration/upload_photo_authorization.html"
|
||||||
@ -198,6 +215,9 @@ class UserUploadPhotoAuthorizationView(LoginRequiredMixin, UpdateView):
|
|||||||
|
|
||||||
|
|
||||||
class PhotoAuthorizationView(LoginRequiredMixin, View):
|
class PhotoAuthorizationView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
Display the sent photo authorization.
|
||||||
|
"""
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
filename = kwargs["filename"]
|
filename = kwargs["filename"]
|
||||||
path = f"media/authorization/photo/{filename}"
|
path = f"media/authorization/photo/{filename}"
|
||||||
@ -207,18 +227,21 @@ class PhotoAuthorizationView(LoginRequiredMixin, View):
|
|||||||
user = request.user
|
user = request.user
|
||||||
if not user.registration.is_admin and user.pk != student.user.pk:
|
if not user.registration.is_admin and user.pk != student.user.pk:
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
# Guess mime type of the file
|
||||||
mime = Magic(mime=True)
|
mime = Magic(mime=True)
|
||||||
mime_type = mime.from_file(path)
|
mime_type = mime.from_file(path)
|
||||||
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
|
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)
|
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)
|
return FileResponse(open(path, "rb"), content_type=mime_type, filename=true_file_name)
|
||||||
|
|
||||||
|
|
||||||
class UserImpersonateView(LoginRequiredMixin, RedirectView):
|
class UserImpersonateView(LoginRequiredMixin, RedirectView):
|
||||||
def dispatch(self, request, *args, **kwargs):
|
|
||||||
"""
|
"""
|
||||||
An administrator can log in through this page as someone else, and act as this other person.
|
An administrator can log in through this page as someone else, and act as this other person.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
if self.request.user.registration.is_admin:
|
if self.request.user.registration.is_admin:
|
||||||
if not User.objects.filter(pk=kwargs["pk"]).exists():
|
if not User.objects.filter(pk=kwargs["pk"]).exists():
|
||||||
raise Http404
|
raise Http404
|
||||||
|
@ -6,11 +6,23 @@ from nio import *
|
|||||||
|
|
||||||
|
|
||||||
class Matrix:
|
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
|
_token: str = None
|
||||||
_device_id: str = None
|
_device_id: str = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _get_client(cls) -> AsyncClient:
|
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 = AsyncClient("https://correspondances-maths.fr", "@corres2mathbot:correspondances-maths.fr")
|
||||||
client.user_id = "@corres2mathbot:correspondances-maths.fr"
|
client.user_id = "@corres2mathbot:correspondances-maths.fr"
|
||||||
|
|
||||||
@ -35,12 +47,18 @@ class Matrix:
|
|||||||
@classmethod
|
@classmethod
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def set_display_name(cls, name: str) -> Union[ProfileSetDisplayNameResponse, ProfileSetDisplayNameError]:
|
async def set_display_name(cls, name: str) -> Union[ProfileSetDisplayNameResponse, ProfileSetDisplayNameError]:
|
||||||
|
"""
|
||||||
|
Set the display name of the bot account.
|
||||||
|
"""
|
||||||
client = await cls._get_client()
|
client = await cls._get_client()
|
||||||
return await client.set_displayname(name)
|
return await client.set_displayname(name)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def set_avatar(cls, avatar_url: str) -> Union[ProfileSetAvatarResponse, ProfileSetAvatarError]:
|
async def set_avatar(cls, avatar_url: str) -> Union[ProfileSetAvatarResponse, ProfileSetAvatarError]:
|
||||||
|
"""
|
||||||
|
Set the display avatar of the bot account.
|
||||||
|
"""
|
||||||
client = await cls._get_client()
|
client = await cls._get_client()
|
||||||
return await client.set_avatar(avatar_url)
|
return await client.set_avatar(avatar_url)
|
||||||
|
|
||||||
@ -55,6 +73,58 @@ class Matrix:
|
|||||||
monitor: Optional[TransferMonitor] = None,
|
monitor: Optional[TransferMonitor] = None,
|
||||||
filesize: Optional[int] = None,
|
filesize: Optional[int] = None,
|
||||||
) -> Tuple[Union[UploadResponse, UploadError], Optional[Dict[str, Any]]]:
|
) -> 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()
|
client = await cls._get_client()
|
||||||
return await client.upload(data_provider, content_type, filename, encrypt, monitor, filesize)
|
return await client.upload(data_provider, content_type, filename, encrypt, monitor, filesize)
|
||||||
|
|
||||||
@ -74,6 +144,61 @@ class Matrix:
|
|||||||
initial_state=(),
|
initial_state=(),
|
||||||
power_level_override: Optional[Dict[str, Any]] = None,
|
power_level_override: Optional[Dict[str, Any]] = None,
|
||||||
) -> Union[RoomCreateResponse, RoomCreateError]:
|
) -> 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()
|
client = await cls._get_client()
|
||||||
return await client.room_create(
|
return await client.room_create(
|
||||||
visibility, alias, name, topic, room_version, federate, is_direct, preset, invite, initial_state,
|
visibility, alias, name, topic, room_version, federate, is_direct, preset, invite, initial_state,
|
||||||
@ -81,6 +206,10 @@ class Matrix:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def resolve_room_alias(cls, room_alias: str) -> Optional[str]:
|
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()
|
client = await cls._get_client()
|
||||||
resp: RoomResolveAliasResponse = await client.room_resolve_alias(room_alias)
|
resp: RoomResolveAliasResponse = await client.room_resolve_alias(room_alias)
|
||||||
if isinstance(resp, RoomResolveAliasError):
|
if isinstance(resp, RoomResolveAliasError):
|
||||||
@ -90,6 +219,17 @@ class Matrix:
|
|||||||
@classmethod
|
@classmethod
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def invite(cls, room_id: str, user_id: str) -> Union[RoomInviteResponse, RoomInviteError]:
|
async def invite(cls, room_id: str, user_id: str) -> 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()
|
client = await cls._get_client()
|
||||||
if room_id.startswith("#"):
|
if room_id.startswith("#"):
|
||||||
room_id = await cls.resolve_room_alias(room_id)
|
room_id = await cls.resolve_room_alias(room_id)
|
||||||
@ -98,6 +238,21 @@ class Matrix:
|
|||||||
@classmethod
|
@classmethod
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def kick(cls, room_id: str, user_id: str, reason: str = None) -> Union[RoomKickResponse, RoomInviteError]:
|
async def kick(cls, room_id: str, user_id: str, reason: str = None) -> 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()
|
client = await cls._get_client()
|
||||||
if room_id.startswith("#"):
|
if room_id.startswith("#"):
|
||||||
room_id = await cls.resolve_room_alias(room_id)
|
room_id = await cls.resolve_room_alias(room_id)
|
||||||
@ -107,6 +262,19 @@ class Matrix:
|
|||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def set_room_power_level(cls, room_id: str, user_id: str, power_level: int)\
|
async def set_room_power_level(cls, room_id: str, user_id: str, power_level: int)\
|
||||||
-> Union[RoomPutStateResponse, RoomPutStateError]:
|
-> 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()
|
client = await cls._get_client()
|
||||||
if room_id.startswith("#"):
|
if room_id.startswith("#"):
|
||||||
room_id = await cls.resolve_room_alias(room_id)
|
room_id = await cls.resolve_room_alias(room_id)
|
||||||
@ -119,6 +287,20 @@ class Matrix:
|
|||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def set_room_power_level_event(cls, room_id: str, event: str, power_level: int)\
|
async def set_room_power_level_event(cls, room_id: str, event: str, power_level: int)\
|
||||||
-> Union[RoomPutStateResponse, RoomPutStateError]:
|
-> 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()
|
client = await cls._get_client()
|
||||||
if room_id.startswith("#"):
|
if room_id.startswith("#"):
|
||||||
room_id = await cls.resolve_room_alias(room_id)
|
room_id = await cls.resolve_room_alias(room_id)
|
||||||
@ -134,7 +316,19 @@ class Matrix:
|
|||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def set_room_avatar(cls, room_id: str, avatar_uri: str)\
|
async def set_room_avatar(cls, room_id: str, avatar_uri: str)\
|
||||||
-> Union[RoomPutStateResponse, RoomPutStateError]:
|
-> 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 = await cls._get_client()
|
||||||
|
client
|
||||||
if room_id.startswith("#"):
|
if room_id.startswith("#"):
|
||||||
room_id = await cls.resolve_room_alias(room_id)
|
room_id = await cls.resolve_room_alias(room_id)
|
||||||
return await client.room_put_state(room_id, "m.room.avatar", content={
|
return await client.room_put_state(room_id, "m.room.avatar", content={
|
||||||
|
Loading…
x
Reference in New Issue
Block a user