From d9bb0a0860798b4e8d6e722b63c20c94777964c2 Mon Sep 17 00:00:00 2001 From: Emmy D'Anello Date: Tue, 23 Apr 2024 00:22:18 +0200 Subject: [PATCH 01/35] Prepare models for new chat feature Signed-off-by: Emmy D'Anello --- chat/__init__.py | 2 + chat/admin.py | 22 ++ chat/apps.py | 9 + chat/migrations/0001_initial.py | 198 ++++++++++++++++ chat/migrations/__init__.py | 2 + chat/models.py | 116 ++++++++++ chat/tests.py | 2 + chat/urls.py | 2 + chat/views.py | 2 + .../migrations/0003_alter_teamdraw_options.py | 28 +++ locale/fr/LC_MESSAGES/django.po | 212 ++++++++++++++---- tfjm/permissions.py | 19 ++ tfjm/settings.py | 1 + tfjm/urls.py | 1 + 14 files changed, 571 insertions(+), 45 deletions(-) create mode 100644 chat/__init__.py create mode 100644 chat/admin.py create mode 100644 chat/apps.py create mode 100644 chat/migrations/0001_initial.py create mode 100644 chat/migrations/__init__.py create mode 100644 chat/models.py create mode 100644 chat/tests.py create mode 100644 chat/urls.py create mode 100644 chat/views.py create mode 100644 draw/migrations/0003_alter_teamdraw_options.py create mode 100644 tfjm/permissions.py diff --git a/chat/__init__.py b/chat/__init__.py new file mode 100644 index 0000000..80ea069 --- /dev/null +++ b/chat/__init__.py @@ -0,0 +1,2 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/chat/admin.py b/chat/admin.py new file mode 100644 index 0000000..2c467d4 --- /dev/null +++ b/chat/admin.py @@ -0,0 +1,22 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib import admin + +from .models import Channel, Message + + +@admin.register(Channel) +class ChannelAdmin(admin.ModelAdmin): + list_display = ('name', 'read_access', 'write_access', 'tournament', 'pool', 'team', 'private',) + list_filter = ('read_access', 'write_access', 'tournament', 'private',) + search_fields = ('name', 'tournament__name', 'team__name', 'team__trigram',) + autocomplete_fields = ('tournament', 'pool', 'team', 'invited', ) + + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + list_display = ('channel', 'author', 'created_at', 'updated_at', 'content',) + list_filter = ('channel', 'created_at', 'updated_at',) + search_fields = ('author__username', 'author__first_name', 'author__last_name', 'content',) + autocomplete_fields = ('channel', 'author',) diff --git a/chat/apps.py b/chat/apps.py new file mode 100644 index 0000000..a17b8f5 --- /dev/null +++ b/chat/apps.py @@ -0,0 +1,9 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "chat" diff --git a/chat/migrations/0001_initial.py b/chat/migrations/0001_initial.py new file mode 100644 index 0000000..75f8f15 --- /dev/null +++ b/chat/migrations/0001_initial.py @@ -0,0 +1,198 @@ +# Generated by Django 5.0.3 on 2024-04-27 06:48 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("participation", "0013_alter_pool_options_pool_room"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Channel", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, verbose_name="name")), + ( + "read_access", + models.PositiveSmallIntegerField( + choices=[ + ("anonymous", "Everyone, including anonymous users"), + ("authenticated", "Authenticated users"), + ("volunteer", "All volunteers"), + ("tournament", "All members of a given tournament"), + ("organizer", "Tournament organizers only"), + ( + "jury_president", + "Tournament organizers and jury presidents of the tournament", + ), + ("jury", "Jury members of the pool"), + ("pool", "Jury members and participants of the pool"), + ( + "team", + "Members of the team and organizers of concerned tournaments", + ), + ( + "private", + "Private, reserved to explicit authorized users", + ), + ("admin", "Admin users"), + ], + verbose_name="read permission", + ), + ), + ( + "write_access", + models.PositiveSmallIntegerField( + choices=[ + ("anonymous", "Everyone, including anonymous users"), + ("authenticated", "Authenticated users"), + ("volunteer", "All volunteers"), + ("tournament", "All members of a given tournament"), + ("organizer", "Tournament organizers only"), + ( + "jury_president", + "Tournament organizers and jury presidents of the tournament", + ), + ("jury", "Jury members of the pool"), + ("pool", "Jury members and participants of the pool"), + ( + "team", + "Members of the team and organizers of concerned tournaments", + ), + ( + "private", + "Private, reserved to explicit authorized users", + ), + ("admin", "Admin users"), + ], + verbose_name="write permission", + ), + ), + ( + "private", + models.BooleanField( + default=False, + help_text="If checked, only users who have been explicitly added to the channel will be able to access it.", + verbose_name="private", + ), + ), + ( + "invited", + models.ManyToManyField( + blank=True, + help_text="Extra users who have been invited to the channel, in addition to the permitted group of the channel.", + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="invited users", + ), + ), + ( + "pool", + models.ForeignKey( + blank=True, + default=None, + help_text="For a permission that concerns a pool, indicates what is the concerned pool.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="chat_channels", + to="participation.pool", + verbose_name="pool", + ), + ), + ( + "team", + models.ForeignKey( + blank=True, + default=None, + help_text="For a permission that concerns a team, indicates what is the concerned team.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="chat_channels", + to="participation.team", + verbose_name="team", + ), + ), + ( + "tournament", + models.ForeignKey( + blank=True, + default=None, + help_text="For a permission that concerns a tournament, indicates what is the concerned tournament.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="chat_channels", + to="participation.tournament", + verbose_name="tournament", + ), + ), + ], + options={ + "verbose_name": "channel", + "verbose_name_plural": "channels", + "ordering": ("name",), + }, + ), + migrations.CreateModel( + name="Message", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="created at"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="updated at"), + ), + ("content", models.TextField(verbose_name="content")), + ( + "author", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="chat_messages", + to=settings.AUTH_USER_MODEL, + verbose_name="author", + ), + ), + ( + "channel", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="chat.channel", + verbose_name="channel", + ), + ), + ], + options={ + "verbose_name": "message", + "verbose_name_plural": "messages", + "ordering": ("created_at",), + }, + ), + ] diff --git a/chat/migrations/__init__.py b/chat/migrations/__init__.py new file mode 100644 index 0000000..80ea069 --- /dev/null +++ b/chat/migrations/__init__.py @@ -0,0 +1,2 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/chat/models.py b/chat/models.py new file mode 100644 index 0000000..b5a35e7 --- /dev/null +++ b/chat/models.py @@ -0,0 +1,116 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.db import models +from django.utils.text import format_lazy +from django.utils.translation import gettext_lazy as _ +from tfjm.permissions import PermissionType + + +class Channel(models.Model): + name = models.CharField( + max_length=255, + verbose_name=_("name"), + ) + + read_access = models.PositiveSmallIntegerField( + verbose_name=_("read permission"), + choices=PermissionType, + ) + + write_access = models.PositiveSmallIntegerField( + verbose_name=_("write permission"), + choices=PermissionType, + ) + + tournament = models.ForeignKey( + 'participation.Tournament', + on_delete=models.CASCADE, + blank=True, + null=True, + default=None, + verbose_name=_("tournament"), + related_name='chat_channels', + help_text=_("For a permission that concerns a tournament, indicates what is the concerned tournament."), + ) + + pool = models.ForeignKey( + 'participation.Pool', + on_delete=models.CASCADE, + blank=True, + null=True, + default=None, + verbose_name=_("pool"), + related_name='chat_channels', + help_text=_("For a permission that concerns a pool, indicates what is the concerned pool."), + ) + + team = models.ForeignKey( + 'participation.Team', + on_delete=models.CASCADE, + blank=True, + null=True, + default=None, + verbose_name=_("team"), + related_name='chat_channels', + help_text=_("For a permission that concerns a team, indicates what is the concerned team."), + ) + + private = models.BooleanField( + verbose_name=_("private"), + default=False, + help_text=_("If checked, only users who have been explicitly added to the channel will be able to access it."), + ) + + invited = models.ManyToManyField( + 'auth.User', + verbose_name=_("invited users"), + related_name='+', + blank=True, + help_text=_("Extra users who have been invited to the channel, " + "in addition to the permitted group of the channel."), + ) + + def __str__(self): + return format_lazy(_("Channel {name}"), name=self.name) + + class Meta: + verbose_name = _("channel") + verbose_name_plural = _("channels") + ordering = ('name',) + + +class Message(models.Model): + channel = models.ForeignKey( + Channel, + on_delete=models.CASCADE, + verbose_name=_("channel"), + related_name='messages', + ) + + author = models.ForeignKey( + 'auth.User', + verbose_name=_("author"), + on_delete=models.SET_NULL, + null=True, + related_name='chat_messages', + ) + + created_at = models.DateTimeField( + verbose_name=_("created at"), + auto_now_add=True, + ) + + updated_at = models.DateTimeField( + verbose_name=_("updated at"), + auto_now=True, + ) + + content = models.TextField( + verbose_name=_("content"), + ) + + class Meta: + verbose_name = _("message") + verbose_name_plural = _("messages") + ordering = ('created_at',) diff --git a/chat/tests.py b/chat/tests.py new file mode 100644 index 0000000..80ea069 --- /dev/null +++ b/chat/tests.py @@ -0,0 +1,2 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/chat/urls.py b/chat/urls.py new file mode 100644 index 0000000..80ea069 --- /dev/null +++ b/chat/urls.py @@ -0,0 +1,2 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/chat/views.py b/chat/views.py new file mode 100644 index 0000000..80ea069 --- /dev/null +++ b/chat/views.py @@ -0,0 +1,2 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/draw/migrations/0003_alter_teamdraw_options.py b/draw/migrations/0003_alter_teamdraw_options.py new file mode 100644 index 0000000..8725ba9 --- /dev/null +++ b/draw/migrations/0003_alter_teamdraw_options.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.3 on 2024-04-22 22:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("draw", "0002_alter_teamdraw_purposed"), + ] + + operations = [ + migrations.AlterModelOptions( + name="teamdraw", + options={ + "ordering": ( + "round__draw__tournament__name", + "round__number", + "pool__letter", + "passage_index", + "choice_dice", + "passage_dice", + ), + "verbose_name": "team draw", + "verbose_name_plural": "team draws", + }, + ), + ] diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 5043b78..13cd29e 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: TFJM\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-22 23:36+0200\n" +"POT-Creation-Date: 2024-04-27 08:46+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Emmy D'Anello \n" "Language-Team: LANGUAGE \n" @@ -21,14 +21,21 @@ msgstr "" msgid "API" msgstr "API" -#: draw/admin.py:39 draw/admin.py:57 draw/admin.py:75 -#: participation/admin.py:109 participation/models.py:253 -#: participation/tables.py:88 -msgid "teams" -msgstr "équipes" +#: chat/models.py:13 participation/models.py:35 participation/models.py:263 +#: participation/tables.py:18 participation/tables.py:34 +msgid "name" +msgstr "nom" -#: draw/admin.py:53 draw/admin.py:71 draw/admin.py:88 draw/models.py:26 -#: participation/admin.py:79 participation/admin.py:140 +#: chat/models.py:17 +msgid "read permission" +msgstr "permission de lecture" + +#: chat/models.py:22 +msgid "write permission" +msgstr "permission d'écriture" + +#: chat/models.py:32 draw/admin.py:53 draw/admin.py:71 draw/admin.py:88 +#: draw/models.py:26 participation/admin.py:79 participation/admin.py:140 #: participation/admin.py:171 participation/models.py:693 #: participation/models.py:717 participation/models.py:935 #: registration/models.py:756 @@ -36,6 +43,112 @@ msgstr "équipes" msgid "tournament" msgstr "tournoi" +#: chat/models.py:34 +msgid "" +"For a permission that concerns a tournament, indicates what is the concerned " +"tournament." +msgstr "" +"Pour une permission qui concerne un tournoi, indique quel est le tournoi " +"concerné." + +#: chat/models.py:43 draw/models.py:429 draw/models.py:456 +#: participation/admin.py:136 participation/admin.py:155 +#: participation/models.py:1434 participation/models.py:1443 +#: participation/tables.py:84 +msgid "pool" +msgstr "poule" + +#: chat/models.py:45 +msgid "" +"For a permission that concerns a pool, indicates what is the concerned pool." +msgstr "" +"Pour une permission qui concerne une poule, indique quelle est la poule " +"concernée." + +#: chat/models.py:54 draw/templates/draw/tournament_content.html:277 +#: participation/admin.py:167 participation/models.py:252 +#: participation/models.py:708 +#: participation/templates/participation/tournament_harmonize.html:15 +#: registration/models.py:157 registration/models.py:747 +#: registration/tables.py:39 +#: registration/templates/registration/payment_form.html:52 +msgid "team" +msgstr "équipe" + +#: chat/models.py:56 +msgid "" +"For a permission that concerns a team, indicates what is the concerned team." +msgstr "" +"Pour une permission qui concerne une équipe, indique quelle est l'équipe " +"concernée." + +#: chat/models.py:60 +msgid "private" +msgstr "privé" + +#: chat/models.py:62 +msgid "" +"If checked, only users who have been explicitly added to the channel will be " +"able to access it." +msgstr "" +"Si sélectionné, seul⋅es les utilisateur⋅rices qui ont été explicitement " +"ajouté⋅es au canal pourront y accéder." + +#: chat/models.py:67 +msgid "invited users" +msgstr "Utilisateur⋅rices invité" + +#: chat/models.py:70 +msgid "" +"Extra users who have been invited to the channel, in addition to the " +"permitted group of the channel." +msgstr "" +"Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du " +"groupe autorisé du canal." + +#: chat/models.py:75 +#, python-brace-format +msgid "Channel {name}" +msgstr "Canal {name}" + +#: chat/models.py:78 chat/models.py:87 +msgid "channel" +msgstr "canal" + +#: chat/models.py:79 +msgid "channels" +msgstr "canaux" + +#: chat/models.py:93 +msgid "author" +msgstr "auteur⋅rice" + +#: chat/models.py:100 +msgid "created at" +msgstr "créé le" + +#: chat/models.py:105 +msgid "updated at" +msgstr "modifié le" + +#: chat/models.py:110 +msgid "content" +msgstr "contenu" + +#: chat/models.py:114 +msgid "message" +msgstr "message" + +#: chat/models.py:115 +msgid "messages" +msgstr "messages" + +#: draw/admin.py:39 draw/admin.py:57 draw/admin.py:75 +#: participation/admin.py:109 participation/models.py:253 +#: participation/tables.py:88 +msgid "teams" +msgstr "équipes" + #: draw/admin.py:92 draw/models.py:234 draw/models.py:448 #: participation/models.py:939 msgid "round" @@ -213,12 +326,6 @@ msgstr "L'instance complète de la poule." msgid "Pool {letter}{number}" msgstr "Poule {letter}{number}" -#: draw/models.py:429 draw/models.py:456 participation/admin.py:136 -#: participation/admin.py:155 participation/models.py:1434 -#: participation/models.py:1443 participation/tables.py:84 -msgid "pool" -msgstr "poule" - #: draw/models.py:430 participation/models.py:1435 msgid "pools" msgstr "poules" @@ -352,15 +459,6 @@ msgstr "Tirer un problème pour" msgid "Pb." msgstr "Pb." -#: draw/templates/draw/tournament_content.html:277 participation/admin.py:167 -#: participation/models.py:252 participation/models.py:708 -#: participation/templates/participation/tournament_harmonize.html:15 -#: registration/models.py:157 registration/models.py:747 -#: registration/tables.py:39 -#: registration/templates/registration/payment_form.html:52 -msgid "team" -msgstr "équipe" - #: draw/templates/draw/tournament_content.html:287 #: draw/templates/draw/tournament_content.html:288 #: draw/templates/draw/tournament_content.html:289 @@ -589,11 +687,6 @@ msgstr "Ce⋅tte défenseur⋅se ne travaille pas sur ce problème." msgid "The PDF file must not have more than 2 pages." msgstr "Le fichier PDF ne doit pas avoir plus de 2 pages." -#: participation/models.py:35 participation/models.py:263 -#: participation/tables.py:18 participation/tables.py:34 -msgid "name" -msgstr "nom" - #: participation/models.py:41 participation/tables.py:39 msgid "trigram" msgstr "trigramme" @@ -1219,16 +1312,6 @@ msgstr "Pas d'équipe définie" msgid "Update" msgstr "Modifier" -#: participation/templates/participation/chat.html:7 -msgid "" -"The chat feature is now out of usage. If you feel that having a chat feature " -"between participants is important, for example to build a team, please " -"contact us." -msgstr "" -"La fonctionnalité de chat est désormais hors-service. Si vous pensez " -"qu'avoir un chat entre les participant⋅es est important, par exemple pour " -"former une équipe, merci de nous contacter." - #: participation/templates/participation/create_team.html:11 #: participation/templates/participation/tournament_form.html:14 #: tfjm/templates/base.html:80 @@ -3484,11 +3567,55 @@ msgstr "Autorisation parentale de {student}.{ext}" msgid "Payment receipt of {registrations}.{ext}" msgstr "Justificatif de paiement de {registrations}.{ext}" -#: tfjm/settings.py:167 +#: tfjm/permissions.py:9 +msgid "Everyone, including anonymous users" +msgstr "Tout le monde, incluant les utilisateur⋅rices anonymes" + +#: tfjm/permissions.py:10 +msgid "Authenticated users" +msgstr "Utilisateur⋅rices connecté⋅es" + +#: tfjm/permissions.py:11 +msgid "All volunteers" +msgstr "Toustes les bénévoles" + +#: tfjm/permissions.py:12 +msgid "All members of a given tournament" +msgstr "Toustes les membres d'un tournoi donné" + +#: tfjm/permissions.py:13 +msgid "Tournament organizers only" +msgstr "Organisateur⋅rices du tournoi seulement" + +#: tfjm/permissions.py:14 +msgid "Tournament organizers and jury presidents of the tournament" +msgstr "Organisateur⋅rices du tournoi et président⋅es de jury du tournoi" + +#: tfjm/permissions.py:15 +msgid "Jury members of the pool" +msgstr "Membres du jury de la poule" + +#: tfjm/permissions.py:16 +msgid "Jury members and participants of the pool" +msgstr "Membre du jury et participant⋅es de la poule" + +#: tfjm/permissions.py:17 +msgid "Members of the team and organizers of concerned tournaments" +msgstr "Membres de l'équipe et organisateur⋅rices des tournois concernés" + +#: tfjm/permissions.py:18 +msgid "Private, reserved to explicit authorized users" +msgstr "Privé, réservé aux utilisateur⋅rices explicitement autorisé⋅es" + +#: tfjm/permissions.py:19 +msgid "Admin users" +msgstr "Administrateur⋅rices" + +#: tfjm/settings.py:168 msgid "English" msgstr "Anglais" -#: tfjm/settings.py:168 +#: tfjm/settings.py:169 msgid "French" msgstr "Français" @@ -3645,8 +3772,3 @@ msgstr "Aucun résultat." #: tfjm/templates/sidebar.html:10 tfjm/templates/sidebar.html:21 msgid "Informations" msgstr "Informations" - -#~ msgid "Can't determine the pool size. Are you sure your file is correct?" -#~ msgstr "" -#~ "Impossible de déterminer la taille de la poule. Êtes-vous sûr⋅e que le " -#~ "fichier est correct ?" diff --git a/tfjm/permissions.py b/tfjm/permissions.py new file mode 100644 index 0000000..f29d5c6 --- /dev/null +++ b/tfjm/permissions.py @@ -0,0 +1,19 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class PermissionType(models.TextChoices): + ANONYMOUS = 'anonymous', _("Everyone, including anonymous users") + AUTHENTICATED = 'authenticated', _("Authenticated users") + VOLUNTEER = 'volunteer', _("All volunteers") + TOURNAMENT_MEMBER = 'tournament', _("All members of a given tournament") + TOURNAMENT_ORGANIZER = 'organizer', _("Tournament organizers only") + TOURNAMENT_JURY_PRESIDENT = 'jury_president', _("Tournament organizers and jury presidents of the tournament") + JURY_MEMBER = 'jury', _("Jury members of the pool") + POOL_MEMBER = 'pool', _("Jury members and participants of the pool") + TEAM_MEMBER = 'team', _("Members of the team and organizers of concerned tournaments") + PRIVATE = 'private', _("Private, reserved to explicit authorized users") + ADMIN = 'admin', _("Admin users") diff --git a/tfjm/settings.py b/tfjm/settings.py index c1b98bb..eb1c53f 100644 --- a/tfjm/settings.py +++ b/tfjm/settings.py @@ -68,6 +68,7 @@ INSTALLED_APPS = [ 'rest_framework.authtoken', 'api', + 'chat', 'draw', 'registration', 'participation', diff --git a/tfjm/urls.py b/tfjm/urls.py index 1185dbc..a397f07 100644 --- a/tfjm/urls.py +++ b/tfjm/urls.py @@ -37,6 +37,7 @@ urlpatterns = [ path('search/', AdminSearchView.as_view(), name="haystack_search"), path('api/', include('api.urls')), + # path('chat/', include('chat.urls')), path('draw/', include('draw.urls')), path('participation/', include('participation.urls')), path('registration/', include('registration.urls')), From ea8007aa07ece08d7dc0e5a80e9309fbd4e06556 Mon Sep 17 00:00:00 2001 From: Emmy D'Anello Date: Sat, 27 Apr 2024 08:57:01 +0200 Subject: [PATCH 02/35] Initialize chat interface Signed-off-by: Emmy D'Anello --- chat/consumers.py | 46 ++++++++++++++++ chat/static/chat.js | 52 +++++++++++++++++++ chat/templates/chat/chat.html | 12 +++++ chat/urls.py | 11 ++++ chat/views.py | 11 ++++ draw/routing.py | 10 ---- draw/tests.py | 4 +- .../templates/participation/chat.html | 13 ----- participation/urls.py | 2 - tfjm/asgi.py | 4 +- tfjm/routing.py | 11 ++++ tfjm/templates/navbar.html | 4 +- tfjm/urls.py | 2 +- 13 files changed, 150 insertions(+), 32 deletions(-) create mode 100644 chat/consumers.py create mode 100644 chat/static/chat.js create mode 100644 chat/templates/chat/chat.html delete mode 100644 draw/routing.py delete mode 100644 participation/templates/participation/chat.html create mode 100644 tfjm/routing.py diff --git a/chat/consumers.py b/chat/consumers.py new file mode 100644 index 0000000..7af3987 --- /dev/null +++ b/chat/consumers.py @@ -0,0 +1,46 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from channels.generic.websocket import AsyncJsonWebsocketConsumer +from registration.models import Registration + + +class ChatConsumer(AsyncJsonWebsocketConsumer): + """ + This consumer manages the websocket of the chat interface. + """ + async def connect(self) -> None: + """ + This function is called when a new websocket is trying to connect to the server. + We accept only if this is a user of a team of the associated tournament, or a volunteer + of the tournament. + """ + + # Fetch the registration of the current user + user = self.scope['user'] + if user.is_anonymous: + # User is not authenticated + await self.close() + return + + reg = await Registration.objects.aget(user_id=user.id) + self.registration = reg + + # Accept the connection + await self.accept() + + async def disconnect(self, close_code) -> None: + """ + Called when the websocket got disconnected, for any reason. + :param close_code: The error code. + """ + if self.scope['user'].is_anonymous: + # User is not authenticated + return + + async def receive_json(self, content, **kwargs): + """ + Called when the client sends us some data, parsed as JSON. + :param content: The sent data, decoded from JSON text. Must content a `type` field. + """ + # TODO Process chat protocol diff --git a/chat/static/chat.js b/chat/static/chat.js new file mode 100644 index 0000000..5a45a83 --- /dev/null +++ b/chat/static/chat.js @@ -0,0 +1,52 @@ +(async () => { + // check notification permission + // This is useful to alert people that they should do something + await Notification.requestPermission() +})() + +/** + * Display a new notification with the given title and the given body. + * @param title The title of the notification + * @param body The body of the notification + * @param timeout The time (in milliseconds) after that the notification automatically closes. 0 to make indefinite. Default to 5000 ms. + * @return Notification + */ +function showNotification(title, body, timeout = 5000) { + let notif = new Notification(title, {'body': body, 'icon': "/static/tfjm.svg"}) + if (timeout) + setTimeout(() => notif.close(), timeout) + return notif +} + +document.addEventListener('DOMContentLoaded', () => { + /** + * Process the received data from the server. + * @param data The received message + */ + function processMessage(data) { + // TODO Implement chat protocol + } + + function setupSocket(nextDelay = 1000) { + // Open a global websocket + socket = new WebSocket( + (document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/' + ) + + // Listen on websockets and process messages from the server + socket.addEventListener('message', e => { + // Parse received data as JSON + const data = JSON.parse(e.data) + + processMessage(data) + }) + + // Manage errors + socket.addEventListener('close', e => { + console.error('Chat socket closed unexpectedly, restarting…') + setTimeout(() => setupSocket(2 * nextDelay), nextDelay) + }) + } + + setupSocket() +}) diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html new file mode 100644 index 0000000..cf7de49 --- /dev/null +++ b/chat/templates/chat/chat.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% load static %} +{% load i18n %} + +{% block content %} +{% endblock %} + +{% block extrajavascript %} + {# This script contains all data for the chat management #} + +{% endblock %} diff --git a/chat/urls.py b/chat/urls.py index 80ea069..77d52c2 100644 --- a/chat/urls.py +++ b/chat/urls.py @@ -1,2 +1,13 @@ # Copyright (C) 2024 by Animath # SPDX-License-Identifier: GPL-3.0-or-later + +from django.urls import path + +from .views import ChatView + + +app_name = 'chat' + +urlpatterns = [ + path('', ChatView.as_view(), name='chat'), +] diff --git a/chat/views.py b/chat/views.py index 80ea069..8c4ef96 100644 --- a/chat/views.py +++ b/chat/views.py @@ -1,2 +1,13 @@ # Copyright (C) 2024 by Animath # SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import TemplateView + + +class ChatView(LoginRequiredMixin, TemplateView): + """ + This view is the main interface of the chat system, which is working + with Javascript and websockets. + """ + template_name = "chat/chat.html" diff --git a/draw/routing.py b/draw/routing.py deleted file mode 100644 index 8ce6085..0000000 --- a/draw/routing.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (C) 2023 by Animath -# SPDX-License-Identifier: GPL-3.0-or-later - -from django.urls import path - -from . import consumers - -websocket_urlpatterns = [ - path("ws/draw/", consumers.DrawConsumer.as_asgi()), -] diff --git a/draw/tests.py b/draw/tests.py index 9f3ec6a..bbb209a 100644 --- a/draw/tests.py +++ b/draw/tests.py @@ -14,8 +14,8 @@ from django.contrib.sites.models import Site from django.test import TestCase from django.urls import reverse from participation.models import Team, Tournament +from tfjm import routing as websocket_routing -from . import routing from .models import Draw, Pool, Round, TeamDraw @@ -55,7 +55,7 @@ class TestDraw(TestCase): # Connect to Websocket headers = [(b'cookie', self.async_client.cookies.output(header='', sep='; ').encode())] - communicator = WebsocketCommunicator(AuthMiddlewareStack(URLRouter(routing.websocket_urlpatterns)), + communicator = WebsocketCommunicator(AuthMiddlewareStack(URLRouter(websocket_routing.websocket_urlpatterns)), "/ws/draw/", headers) connected, subprotocol = await communicator.connect() self.assertTrue(connected) diff --git a/participation/templates/participation/chat.html b/participation/templates/participation/chat.html deleted file mode 100644 index bf47704..0000000 --- a/participation/templates/participation/chat.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} - -{% load i18n %} - -{% block content %} -
- {% blocktrans trimmed %} - The chat feature is now out of usage. If you feel that having a chat - feature between participants is important, for example to build a - team, please contact us. - {% endblocktrans %} -
-{% endblock %} diff --git a/participation/urls.py b/participation/urls.py index 4125a4c..2c97587 100644 --- a/participation/urls.py +++ b/participation/urls.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django.urls import path -from django.views.generic import TemplateView from .views import CreateTeamView, FinalNotationSheetTemplateView, GSheetNotificationsView, JoinTeamView, \ MyParticipationDetailView, MyTeamDetailView, NotationSheetsArchiveView, NoteUpdateView, ParticipationDetailView, \ @@ -74,5 +73,4 @@ urlpatterns = [ path("pools/passages//update/", PassageUpdateView.as_view(), name="passage_update"), path("pools/passages//solution/", SynthesisUploadView.as_view(), name="upload_synthesis"), path("pools/passages/notes//", NoteUpdateView.as_view(), name="update_notes"), - path("chat/", TemplateView.as_view(template_name="participation/chat.html"), name="chat") ] diff --git a/tfjm/asgi.py b/tfjm/asgi.py index 3bec7e0..417d54e 100644 --- a/tfjm/asgi.py +++ b/tfjm/asgi.py @@ -22,13 +22,13 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tfjm.settings') django_asgi_app = get_asgi_application() # useful since the import must be done after the application initialization -import draw.routing # noqa: E402, I202 +import tfjm.routing # noqa: E402, I202 application = ProtocolTypeRouter( { "http": django_asgi_app, "websocket": AllowedHostsOriginValidator( - AuthMiddlewareStack(URLRouter(draw.routing.websocket_urlpatterns)) + AuthMiddlewareStack(URLRouter(tfjm.routing.websocket_urlpatterns)) ), } ) diff --git a/tfjm/routing.py b/tfjm/routing.py new file mode 100644 index 0000000..08c54d8 --- /dev/null +++ b/tfjm/routing.py @@ -0,0 +1,11 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +import chat.consumers +from django.urls import path +import draw.consumers + +websocket_urlpatterns = [ + path("ws/chat/", chat.consumers.ChatConsumer.as_asgi()), + path("ws/draw/", draw.consumers.DrawConsumer.as_asgi()), +] diff --git a/tfjm/templates/navbar.html b/tfjm/templates/navbar.html index 8e0115d..ecdb4dc 100644 --- a/tfjm/templates/navbar.html +++ b/tfjm/templates/navbar.html @@ -62,8 +62,8 @@ {% endif %} {% endif %} - diff --git a/tfjm/urls.py b/tfjm/urls.py index a397f07..c506456 100644 --- a/tfjm/urls.py +++ b/tfjm/urls.py @@ -37,7 +37,7 @@ urlpatterns = [ path('search/', AdminSearchView.as_view(), name="haystack_search"), path('api/', include('api.urls')), - # path('chat/', include('chat.urls')), + path('chat/', include('chat.urls')), path('draw/', include('draw.urls')), path('participation/', include('participation.urls')), path('registration/', include('registration.urls')), From 7498677bbd6590dd9131be20c9dfb87681e150bc Mon Sep 17 00:00:00 2001 From: Emmy D'Anello Date: Sat, 27 Apr 2024 09:00:23 +0200 Subject: [PATCH 03/35] Permissions are strings, not integers Signed-off-by: Emmy D'Anello --- chat/migrations/0001_initial.py | 8 +++++--- chat/models.py | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/chat/migrations/0001_initial.py b/chat/migrations/0001_initial.py index 75f8f15..294bd7e 100644 --- a/chat/migrations/0001_initial.py +++ b/chat/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.3 on 2024-04-27 06:48 +# Generated by Django 5.0.3 on 2024-04-27 07:00 import django.db.models.deletion from django.conf import settings @@ -30,7 +30,7 @@ class Migration(migrations.Migration): ("name", models.CharField(max_length=255, verbose_name="name")), ( "read_access", - models.PositiveSmallIntegerField( + models.CharField( choices=[ ("anonymous", "Everyone, including anonymous users"), ("authenticated", "Authenticated users"), @@ -53,12 +53,13 @@ class Migration(migrations.Migration): ), ("admin", "Admin users"), ], + max_length=16, verbose_name="read permission", ), ), ( "write_access", - models.PositiveSmallIntegerField( + models.CharField( choices=[ ("anonymous", "Everyone, including anonymous users"), ("authenticated", "Authenticated users"), @@ -81,6 +82,7 @@ class Migration(migrations.Migration): ), ("admin", "Admin users"), ], + max_length=16, verbose_name="write permission", ), ), diff --git a/chat/models.py b/chat/models.py index b5a35e7..66e0f93 100644 --- a/chat/models.py +++ b/chat/models.py @@ -13,12 +13,14 @@ class Channel(models.Model): verbose_name=_("name"), ) - read_access = models.PositiveSmallIntegerField( + read_access = models.CharField( + max_length=16, verbose_name=_("read permission"), choices=PermissionType, ) - write_access = models.PositiveSmallIntegerField( + write_access = models.CharField( + max_length=16, verbose_name=_("write permission"), choices=PermissionType, ) From b464e7df1d3fc1d3c3a236539d54c26c48eb1c18 Mon Sep 17 00:00:00 2001 From: Emmy D'Anello Date: Sat, 27 Apr 2024 09:53:55 +0200 Subject: [PATCH 04/35] Manage channels permissions Signed-off-by: Emmy D'Anello --- chat/consumers.py | 31 ++++++++++++++++++- chat/models.py | 72 ++++++++++++++++++++++++++++++++++++++++++++- chat/static/chat.js | 7 +++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/chat/consumers.py b/chat/consumers.py index 7af3987..75afd75 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -2,8 +2,11 @@ # SPDX-License-Identifier: GPL-3.0-or-later from channels.generic.websocket import AsyncJsonWebsocketConsumer +from django.contrib.auth.models import User from registration.models import Registration +from .models import Channel + class ChatConsumer(AsyncJsonWebsocketConsumer): """ @@ -15,6 +18,8 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): We accept only if this is a user of a team of the associated tournament, or a volunteer of the tournament. """ + if '_fake_user_id' in self.scope['session']: + self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id']) # Fetch the registration of the current user user = self.scope['user'] @@ -43,4 +48,28 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): Called when the client sends us some data, parsed as JSON. :param content: The sent data, decoded from JSON text. Must content a `type` field. """ - # TODO Process chat protocol + match content['type']: + case 'fetch_channels': + await self.fetch_channels() + case unknown: + print("Unknown message type:", unknown) + + async def fetch_channels(self) -> None: + user = self.scope['user'] + + read_channels = await Channel.get_accessible_channels(user, 'read') + write_channels = await Channel.get_accessible_channels(user, 'write') + print([channel async for channel in write_channels.all()]) + message = { + 'type': 'fetch_channels', + 'channels': [ + { + 'id': channel.id, + 'name': channel.name, + 'read_access': True, + 'write_access': await write_channels.acontains(channel), + } + async for channel in read_channels.all() + ] + } + await self.send_json(message) diff --git a/chat/models.py b/chat/models.py index 66e0f93..0e4b078 100644 --- a/chat/models.py +++ b/chat/models.py @@ -1,9 +1,13 @@ # Copyright (C) 2024 by Animath # SPDX-License-Identifier: GPL-3.0-or-later +from django.contrib.auth.models import User from django.db import models +from django.db.models import Q, QuerySet from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ +from participation.models import Tournament +from registration.models import ParticipantRegistration, Registration, VolunteerRegistration from tfjm.permissions import PermissionType @@ -74,7 +78,73 @@ class Channel(models.Model): ) def __str__(self): - return format_lazy(_("Channel {name}"), name=self.name) + return str(format_lazy(_("Channel {name}"), name=self.name)) + + @staticmethod + async def get_accessible_channels(user: User, permission_type: str = 'read') -> QuerySet["Channel"]: + permission_type = 'write_access' if 'write' in permission_type.lower() else 'read_access' + + qs = Channel.objects.none() + if user.is_anonymous: + return Channel.objects.filter(**{permission_type: PermissionType.ANONYMOUS}) + + qs |= Channel.objects.filter(**{permission_type: PermissionType.AUTHENTICATED}) + registration = await Registration.objects.aget(user_id=user.id) + + if registration.is_admin: + return Channel.objects.all() + + if registration.is_volunteer: + registration = await VolunteerRegistration.objects \ + .prefetch_related('jury_in__tournament', 'organized_tournaments').aget(user_id=user.id) + + qs |= Channel.objects.filter(**{permission_type: PermissionType.VOLUNTEER}) + + qs |= Channel.objects.filter(Q(tournament__in=registration.interesting_tournaments), + **{permission_type: PermissionType.TOURNAMENT_MEMBER}) + + qs |= Channel.objects.filter(Q(tournament__in=registration.organized_tournaments.all()), + **{permission_type: PermissionType.TOURNAMENT_ORGANIZER}) + + qs |= Channel.objects.filter(Q(tournament__pools__in=registration.pools_presided.all()) + | Q(tournament__in=registration.organized_tournaments.all()), + **{permission_type: PermissionType.TOURNAMENT_JURY_PRESIDENT}) + + qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all()) + | Q(pool__tournament__in=registration.organized_tournaments.all()) + | Q(pool__tournament__pools__in=registration.pools_presided.all()), + **{permission_type: PermissionType.JURY_MEMBER}) + + qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all()) + | Q(pool__tournament__in=registration.organized_tournaments.all()) + | Q(pool__tournament__pools__in=registration.pools_presided.all()), + **{permission_type: PermissionType.POOL_MEMBER}) + else: + registration = await ParticipantRegistration.objects \ + .prefetch_related('team__participation__pools', 'team__participation__tournament').aget(user_id=user.id) + + team = registration.team + tournaments = [] + if team.participation.valid: + tournaments.append(team.participation.tournament) + if team.participation.final: + tournaments.append(await Tournament.objects.aget(final=True)) + + qs |= Channel.objects.filter(Q(tournament__in=tournaments), + **{permission_type: PermissionType.TOURNAMENT_MEMBER}) + + qs |= Channel.objects.filter(Q(pool__in=team.participation.pools.all()), + **{permission_type: PermissionType.POOL_MEMBER}) + + qs |= Channel.objects.filter(Q(team=team), + **{permission_type: PermissionType.TEAM_MEMBER}) + + qs |= Channel.objects.filter(invited=user) + + print(user) + print(qs.query) + + return qs class Meta: verbose_name = _("channel") diff --git a/chat/static/chat.js b/chat/static/chat.js index 5a45a83..8d5063a 100644 --- a/chat/static/chat.js +++ b/chat/static/chat.js @@ -25,6 +25,7 @@ document.addEventListener('DOMContentLoaded', () => { */ function processMessage(data) { // TODO Implement chat protocol + console.log(data) } function setupSocket(nextDelay = 1000) { @@ -46,6 +47,12 @@ document.addEventListener('DOMContentLoaded', () => { console.error('Chat socket closed unexpectedly, restarting…') setTimeout(() => setupSocket(2 * nextDelay), nextDelay) }) + + socket.addEventListener('open', e => { + socket.send(JSON.stringify({ + 'type': 'fetch_channels', + })) + }) } setupSocket() From 46fc5f39c82d67df10940572f804ad543dd1b522 Mon Sep 17 00:00:00 2001 From: Emmy D'Anello Date: Sat, 27 Apr 2024 09:55:33 +0200 Subject: [PATCH 05/35] Allow to impersonate user on draw interface Signed-off-by: Emmy D'Anello --- draw/consumers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/draw/consumers.py b/draw/consumers.py index d64c888..7719b8d 100644 --- a/draw/consumers.py +++ b/draw/consumers.py @@ -9,6 +9,7 @@ from random import randint, shuffle from asgiref.sync import sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.conf import settings +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.utils import translation from django.utils.translation import gettext_lazy as _ @@ -44,6 +45,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): We accept only if this is a user of a team of the associated tournament, or a volunteer of the tournament. """ + if '_fake_user_id' in self.scope['session']: + self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id']) # Fetch the registration of the current user user = self.scope['user'] From f3a4a99b78bc55b5dc8307523d092e09c5da6189 Mon Sep 17 00:00:00 2001 From: Emmy D'Anello Date: Sat, 27 Apr 2024 12:08:10 +0200 Subject: [PATCH 06/35] Setup chat UI Signed-off-by: Emmy D'Anello --- chat/static/chat.js | 40 +++++++++++++- chat/templates/chat/chat.html | 58 +++++++++++++++++++++ chat/views.py | 8 +++ locale/fr/LC_MESSAGES/django.po | 92 +++++++++++++++++++-------------- tfjm/templates/base.html | 8 +-- 5 files changed, 161 insertions(+), 45 deletions(-) diff --git a/chat/static/chat.js b/chat/static/chat.js index 8d5063a..8e51aec 100644 --- a/chat/static/chat.js +++ b/chat/static/chat.js @@ -4,6 +4,9 @@ await Notification.requestPermission() })() +let channels = {} +let selected_channel_id = null + /** * Display a new notification with the given title and the given body. * @param title The title of the notification @@ -18,6 +21,33 @@ function showNotification(title, body, timeout = 5000) { return notif } +function selectChannel(channel_id) { + let channel = channels[channel_id] + if (!channel) { + console.error('Channel not found:', channel_id) + return + } + + selected_channel_id = channel_id + + let channelTitle = document.getElementById('channel-title') + channelTitle.innerText = channel['name'] + + let messageInput = document.getElementById('input-message') + messageInput.disabled = !channel['write_access'] +} + +function setChannels(new_channels) { + channels = {} + for (let channel of new_channels) { + channels[channel['id']] = channel + } + + if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) { + selectChannel(Object.keys(channels)[0]) + } +} + document.addEventListener('DOMContentLoaded', () => { /** * Process the received data from the server. @@ -25,7 +55,15 @@ document.addEventListener('DOMContentLoaded', () => { */ function processMessage(data) { // TODO Implement chat protocol - console.log(data) + switch (data['type']) { + case 'fetch_channels': + setChannels(data['channels']) + break + default: + console.log(data) + console.error('Unknown message type:', data['type']) + break + } } function setupSocket(nextDelay = 1000) { diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html index cf7de49..860147b 100644 --- a/chat/templates/chat/chat.html +++ b/chat/templates/chat/chat.html @@ -4,6 +4,64 @@ {% load i18n %} {% block content %} + +
+
+

{% trans "Chat channels" %}

+ +
+
+ +
+
+ + {% endblock %} {% block extrajavascript %} diff --git a/chat/views.py b/chat/views.py index 8c4ef96..30c40e9 100644 --- a/chat/views.py +++ b/chat/views.py @@ -4,6 +4,8 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic import TemplateView +from chat.models import Channel + class ChatView(LoginRequiredMixin, TemplateView): """ @@ -11,3 +13,9 @@ class ChatView(LoginRequiredMixin, TemplateView): with Javascript and websockets. """ template_name = "chat/chat.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + from asgiref.sync import async_to_sync + context['channels'] = async_to_sync(Channel.get_accessible_channels)(self.request.user, 'read') + return context diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 13cd29e..c70abdc 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: TFJM\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-04-27 08:46+0200\n" +"POT-Creation-Date: 2024-04-27 11:02+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Emmy D'Anello \n" "Language-Team: LANGUAGE \n" @@ -21,20 +21,20 @@ msgstr "" msgid "API" msgstr "API" -#: chat/models.py:13 participation/models.py:35 participation/models.py:263 +#: chat/models.py:17 participation/models.py:35 participation/models.py:263 #: participation/tables.py:18 participation/tables.py:34 msgid "name" msgstr "nom" -#: chat/models.py:17 +#: chat/models.py:22 msgid "read permission" msgstr "permission de lecture" -#: chat/models.py:22 +#: chat/models.py:28 msgid "write permission" msgstr "permission d'écriture" -#: chat/models.py:32 draw/admin.py:53 draw/admin.py:71 draw/admin.py:88 +#: chat/models.py:38 draw/admin.py:53 draw/admin.py:71 draw/admin.py:88 #: draw/models.py:26 participation/admin.py:79 participation/admin.py:140 #: participation/admin.py:171 participation/models.py:693 #: participation/models.py:717 participation/models.py:935 @@ -43,7 +43,7 @@ msgstr "permission d'écriture" msgid "tournament" msgstr "tournoi" -#: chat/models.py:34 +#: chat/models.py:40 msgid "" "For a permission that concerns a tournament, indicates what is the concerned " "tournament." @@ -51,21 +51,21 @@ msgstr "" "Pour une permission qui concerne un tournoi, indique quel est le tournoi " "concerné." -#: chat/models.py:43 draw/models.py:429 draw/models.py:456 +#: chat/models.py:49 draw/models.py:429 draw/models.py:456 #: participation/admin.py:136 participation/admin.py:155 #: participation/models.py:1434 participation/models.py:1443 #: participation/tables.py:84 msgid "pool" msgstr "poule" -#: chat/models.py:45 +#: chat/models.py:51 msgid "" "For a permission that concerns a pool, indicates what is the concerned pool." msgstr "" "Pour une permission qui concerne une poule, indique quelle est la poule " "concernée." -#: chat/models.py:54 draw/templates/draw/tournament_content.html:277 +#: chat/models.py:60 draw/templates/draw/tournament_content.html:277 #: participation/admin.py:167 participation/models.py:252 #: participation/models.py:708 #: participation/templates/participation/tournament_harmonize.html:15 @@ -75,18 +75,18 @@ msgstr "" msgid "team" msgstr "équipe" -#: chat/models.py:56 +#: chat/models.py:62 msgid "" "For a permission that concerns a team, indicates what is the concerned team." msgstr "" "Pour une permission qui concerne une équipe, indique quelle est l'équipe " "concernée." -#: chat/models.py:60 +#: chat/models.py:66 msgid "private" msgstr "privé" -#: chat/models.py:62 +#: chat/models.py:68 msgid "" "If checked, only users who have been explicitly added to the channel will be " "able to access it." @@ -94,11 +94,11 @@ msgstr "" "Si sélectionné, seul⋅es les utilisateur⋅rices qui ont été explicitement " "ajouté⋅es au canal pourront y accéder." -#: chat/models.py:67 +#: chat/models.py:73 msgid "invited users" msgstr "Utilisateur⋅rices invité" -#: chat/models.py:70 +#: chat/models.py:76 msgid "" "Extra users who have been invited to the channel, in addition to the " "permitted group of the channel." @@ -106,43 +106,55 @@ msgstr "" "Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du " "groupe autorisé du canal." -#: chat/models.py:75 +#: chat/models.py:81 #, python-brace-format msgid "Channel {name}" msgstr "Canal {name}" -#: chat/models.py:78 chat/models.py:87 +#: chat/models.py:150 chat/models.py:159 msgid "channel" msgstr "canal" -#: chat/models.py:79 +#: chat/models.py:151 msgid "channels" msgstr "canaux" -#: chat/models.py:93 +#: chat/models.py:165 msgid "author" msgstr "auteur⋅rice" -#: chat/models.py:100 +#: chat/models.py:172 msgid "created at" msgstr "créé le" -#: chat/models.py:105 +#: chat/models.py:177 msgid "updated at" msgstr "modifié le" -#: chat/models.py:110 +#: chat/models.py:182 msgid "content" msgstr "contenu" -#: chat/models.py:114 +#: chat/models.py:186 msgid "message" msgstr "message" -#: chat/models.py:115 +#: chat/models.py:187 msgid "messages" msgstr "messages" +#: chat/templates/chat/chat.html:8 +msgid "JavaScript must be enabled on your browser to access chat." +msgstr "" + +#: chat/templates/chat/chat.html:12 +msgid "Chat channels" +msgstr "Canaux de chat" + +#: chat/templates/chat/chat.html:43 +msgid "Send message…" +msgstr "Envoyer un message…" + #: draw/admin.py:39 draw/admin.py:57 draw/admin.py:75 #: participation/admin.py:109 participation/models.py:253 #: participation/tables.py:88 @@ -158,68 +170,68 @@ msgstr "tour" msgid "Draw" msgstr "Tirage au sort" -#: draw/consumers.py:30 +#: draw/consumers.py:31 msgid "You are not an organizer." msgstr "Vous n'êtes pas un⋅e organisateur⋅rice." -#: draw/consumers.py:162 +#: draw/consumers.py:165 msgid "The draw is already started." msgstr "Le tirage a déjà commencé." -#: draw/consumers.py:168 +#: draw/consumers.py:171 msgid "Invalid format" msgstr "Format invalide" -#: draw/consumers.py:173 +#: draw/consumers.py:176 #, python-brace-format msgid "The sum must be equal to the number of teams: expected {len}, got {sum}" msgstr "" "La somme doit être égale au nombre d'équipes : attendu {len}, obtenu {sum}" -#: draw/consumers.py:178 +#: draw/consumers.py:181 msgid "There can be at most one pool with 5 teams." msgstr "Il ne peut y avoir au plus qu'une seule poule de 5 équipes." -#: draw/consumers.py:218 +#: draw/consumers.py:221 msgid "Draw started!" msgstr "Le tirage a commencé !" -#: draw/consumers.py:240 +#: draw/consumers.py:243 #, python-brace-format msgid "The draw for the tournament {tournament} will start." msgstr "Le tirage au sort du tournoi {tournament} va commencer." -#: draw/consumers.py:251 draw/consumers.py:277 draw/consumers.py:687 -#: draw/consumers.py:904 draw/consumers.py:993 draw/consumers.py:1015 -#: draw/consumers.py:1106 draw/templates/draw/tournament_content.html:5 +#: draw/consumers.py:254 draw/consumers.py:280 draw/consumers.py:690 +#: draw/consumers.py:907 draw/consumers.py:996 draw/consumers.py:1018 +#: draw/consumers.py:1109 draw/templates/draw/tournament_content.html:5 msgid "The draw has not started yet." msgstr "Le tirage au sort n'a pas encore commencé." -#: draw/consumers.py:264 +#: draw/consumers.py:267 #, python-brace-format msgid "The draw for the tournament {tournament} is aborted." msgstr "Le tirage au sort du tournoi {tournament} est annulé." -#: draw/consumers.py:304 draw/consumers.py:325 draw/consumers.py:621 -#: draw/consumers.py:692 draw/consumers.py:909 +#: draw/consumers.py:307 draw/consumers.py:328 draw/consumers.py:624 +#: draw/consumers.py:695 draw/consumers.py:912 msgid "This is not the time for this." msgstr "Ce n'est pas le moment pour cela." -#: draw/consumers.py:317 draw/consumers.py:320 +#: draw/consumers.py:320 draw/consumers.py:323 msgid "You've already launched the dice." msgstr "Vous avez déjà lancé le dé." -#: draw/consumers.py:323 +#: draw/consumers.py:326 msgid "It is not your turn." msgstr "Ce n'est pas votre tour." -#: draw/consumers.py:410 +#: draw/consumers.py:413 #, python-brace-format msgid "Dices from teams {teams} are identical. Please relaunch your dices." msgstr "" "Les dés des équipes {teams} sont identiques. Merci de relancer vos dés." -#: draw/consumers.py:1018 +#: draw/consumers.py:1021 msgid "This is only available for the final tournament." msgstr "Cela n'est possible que pour la finale." diff --git a/tfjm/templates/base.html b/tfjm/templates/base.html index f272bd7..da1cff5 100644 --- a/tfjm/templates/base.html +++ b/tfjm/templates/base.html @@ -40,18 +40,18 @@ {% include "navbar.html" %} -
+
-
-
+
+
{% block content-title %}

{{ title }}

{% endblock %} {% include "messages.html" %} -
+
{% block content %}

Default content...

{% endblock content %} From 4a78e8039914b6888419d05924175189ccea8bb8 Mon Sep 17 00:00:00 2001 From: Emmy D'Anello Date: Sat, 27 Apr 2024 12:59:50 +0200 Subject: [PATCH 07/35] Send messages Signed-off-by: Emmy D'Anello --- chat/consumers.py | 40 +++++++++++++++++++-- chat/models.py | 65 ++++++++++++++++++++++++++++++++--- chat/static/chat.js | 36 +++++++++++++++++++ chat/templates/chat/chat.html | 35 +++++++------------ 4 files changed, 147 insertions(+), 29 deletions(-) diff --git a/chat/consumers.py b/chat/consumers.py index 75afd75..c356089 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -3,9 +3,10 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.contrib.auth.models import User +from participation.models import Team, Pool, Tournament from registration.models import Registration -from .models import Channel +from .models import Channel, Message class ChatConsumer(AsyncJsonWebsocketConsumer): @@ -34,6 +35,10 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): # Accept the connection await self.accept() + channels = await Channel.get_accessible_channels(user, 'read') + async for channel in channels.all(): + await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name) + async def disconnect(self, close_code) -> None: """ Called when the websocket got disconnected, for any reason. @@ -43,6 +48,10 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): # User is not authenticated return + channels = await Channel.get_accessible_channels(self.scope['user'], 'read') + async for channel in channels.all(): + await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name) + async def receive_json(self, content, **kwargs): """ Called when the client sends us some data, parsed as JSON. @@ -51,6 +60,8 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): match content['type']: case 'fetch_channels': await self.fetch_channels() + case 'send_message': + await self.receive_message(content) case unknown: print("Unknown message type:", unknown) @@ -59,7 +70,6 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): read_channels = await Channel.get_accessible_channels(user, 'read') write_channels = await Channel.get_accessible_channels(user, 'write') - print([channel async for channel in write_channels.all()]) message = { 'type': 'fetch_channels', 'channels': [ @@ -73,3 +83,29 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): ] } await self.send_json(message) + + async def receive_message(self, message: dict) -> None: + user = self.scope['user'] + channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \ + .aget(id=message['channel_id']) + write_channels = await Channel.get_accessible_channels(user, 'write') + if not await write_channels.acontains(channel): + return + + message = await Message.objects.acreate( + author=user, + channel=channel, + content=message['content'], + ) + + await self.channel_layer.group_send(f'chat-{channel.id}', { + 'type': 'chat.send_message', + 'id': message.id, + 'timestamp': message.created_at.isoformat(), + 'author': await message.aget_author_name(), + 'content': message.content, + }) + + async def chat_send_message(self, message) -> None: + await self.send_json({'type': 'send_message', 'id': message['id'], 'timestamp': message['timestamp'], + 'author': message['author'], 'content': message['content']}) diff --git a/chat/models.py b/chat/models.py index 0e4b078..913d8b8 100644 --- a/chat/models.py +++ b/chat/models.py @@ -1,12 +1,13 @@ # Copyright (C) 2024 by Animath # SPDX-License-Identifier: GPL-3.0-or-later +from asgiref.sync import sync_to_async from django.contrib.auth.models import User from django.db import models from django.db.models import Q, QuerySet from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ -from participation.models import Tournament +from participation.models import Pool, Team, Tournament from registration.models import ParticipantRegistration, Registration, VolunteerRegistration from tfjm.permissions import PermissionType @@ -141,9 +142,6 @@ class Channel(models.Model): qs |= Channel.objects.filter(invited=user) - print(user) - print(qs.query) - return qs class Meta: @@ -182,6 +180,65 @@ class Message(models.Model): verbose_name=_("content"), ) + def get_author_name(self): + registration = self.author.registration + + author_name = f"{self.author.first_name} {self.author.last_name}" + if registration.is_volunteer: + if registration.is_admin: + author_name += " (CNO)" + + if self.channel.pool: + if registration == self.channel.pool.jury_president: + author_name += " (P. jury)" + elif registration in self.channel.pool.juries.all(): + author_name += " (Juré⋅e)" + elif registration in self.channel.pool.tournament.organizers.all(): + author_name += " (CRO)" + else: + author_name += " (Bénévole)" + elif self.channel.tournament: + if registration in self.channel.tournament.organizers.all(): + author_name += " (CRO)" + elif any([registration.id == pool.jury_president + for pool in self.channel.tournament.pools.all()]): + pools = ", ".join([pool.short_name + for pool in self.channel.tournament.pools.all() + if pool.jury_president == registration]) + author_name += f" (P. jury {pools})" + elif any([pool.juries.contains(registration) + for pool in self.channel.tournament.pools.all()]): + pools = ", ".join([pool.short_name + for pool in self.channel.tournament.pools.all() + if pool.juries.acontains(registration)]) + author_name += f" (Juré⋅e {pools})" + else: + author_name += " (Bénévole)" + else: + if registration.organized_tournaments.exists(): + tournaments = ", ".join([tournament.name + for tournament in registration.organized_tournaments.all()]) + author_name += f" (CRO {tournaments})" + if Pool.objects.filter(jury_president=registration).exists(): + tournaments = Tournament.objects.filter(pools__jury_president=registration).distinct() + tournaments = ", ".join([tournament.name for tournament in tournaments]) + author_name += f" (P. jury {tournaments})" + elif registration.jury_in.exists(): + tournaments = Tournament.objects.filter(pools__juries=registration).distinct() + tournaments = ", ".join([tournament.name for tournament in tournaments]) + author_name += f" (Juré⋅e {tournaments})" + else: + if registration.team_id: + team = Team.objects.get(id=registration.team_id) + author_name += f" ({team.trigram})" + else: + author_name += " (sans équipe)" + + return author_name + + async def aget_author_name(self): + return await sync_to_async(self.get_author_name)() + class Meta: verbose_name = _("message") verbose_name_plural = _("messages") diff --git a/chat/static/chat.js b/chat/static/chat.js index 8e51aec..2265508 100644 --- a/chat/static/chat.js +++ b/chat/static/chat.js @@ -37,6 +37,22 @@ function selectChannel(channel_id) { messageInput.disabled = !channel['write_access'] } +function sendMessage() { + let messageInput = document.getElementById('input-message') + let message = messageInput.value + messageInput.value = '' + + if (!message) { + return + } + + socket.send(JSON.stringify({ + 'type': 'send_message', + 'channel_id': selected_channel_id, + 'content': message, + })) +} + function setChannels(new_channels) { channels = {} for (let channel of new_channels) { @@ -48,6 +64,23 @@ function setChannels(new_channels) { } } +function receiveMessage(message) { + let messageList = document.getElementById('message-list') + + let messageElement = document.createElement('li') + messageElement.classList.add('list-group-item') + messageList.appendChild(messageElement) + + let authorDiv = document.createElement('div') + authorDiv.classList.add('text-muted', 'fw-bold') + authorDiv.innerText = message['author'] + messageElement.appendChild(authorDiv) + + let contentDiv = document.createElement('div') + contentDiv.innerText = message['content'] + messageElement.appendChild(contentDiv) +} + document.addEventListener('DOMContentLoaded', () => { /** * Process the received data from the server. @@ -59,6 +92,9 @@ document.addEventListener('DOMContentLoaded', () => { case 'fetch_channels': setChannels(data['channels']) break + case 'send_message': + receiveMessage(data) + break default: console.log(data) console.error('Unknown message type:', data['type']) diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html index 860147b..b3d293a 100644 --- a/chat/templates/chat/chat.html +++ b/chat/templates/chat/chat.html @@ -35,31 +35,20 @@
-
    -
  • -
    Emmy D'Anello (CNO)
    - Message 1 -
  • -
  • -
    Emmy D'Anello (CNO)
    - Message 2 -
  • -
  • -
    Emmy D'Anello (CNO)
    - Message 3 -
  • -
+
    {% endblock %} From d59bb75dcec9bbe493342477080fe2b87209b5ca Mon Sep 17 00:00:00 2001 From: Emmy D'Anello Date: Sat, 27 Apr 2024 13:27:27 +0200 Subject: [PATCH 08/35] Fetching last messages is working Signed-off-by: Emmy D'Anello --- chat/consumers.py | 29 +++++++++++++-- chat/static/chat.js | 87 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 101 insertions(+), 15 deletions(-) diff --git a/chat/consumers.py b/chat/consumers.py index c356089..c4e7bb6 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -62,6 +62,8 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): await self.fetch_channels() case 'send_message': await self.receive_message(content) + case 'fetch_messages': + await self.fetch_messages(content['channel_id']) case unknown: print("Unknown message type:", unknown) @@ -101,11 +103,34 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): await self.channel_layer.group_send(f'chat-{channel.id}', { 'type': 'chat.send_message', 'id': message.id, + 'channel_id': channel.id, 'timestamp': message.created_at.isoformat(), 'author': await message.aget_author_name(), 'content': message.content, }) + async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50) -> None: + channel = await Channel.objects.aget(id=channel_id) + read_channels = await Channel.get_accessible_channels(self.scope['user'], 'read') + if not await read_channels.acontains(channel): + return + + messages = Message.objects.filter(channel=channel).order_by('created_at')[offset:offset + limit].all() + await self.send_json({ + 'type': 'fetch_messages', + 'channel_id': channel_id, + 'messages': [ + { + 'id': message.id, + 'timestamp': message.created_at.isoformat(), + 'author': await message.aget_author_name(), + 'content': message.content, + } + async for message in messages + ] + }) + async def chat_send_message(self, message) -> None: - await self.send_json({'type': 'send_message', 'id': message['id'], 'timestamp': message['timestamp'], - 'author': message['author'], 'content': message['content']}) + await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'], + 'timestamp': message['timestamp'], 'author': message['author'], + 'content': message['content']}) diff --git a/chat/static/chat.js b/chat/static/chat.js index 2265508..8557008 100644 --- a/chat/static/chat.js +++ b/chat/static/chat.js @@ -5,6 +5,7 @@ })() let channels = {} +let messages = {} let selected_channel_id = null /** @@ -35,6 +36,8 @@ function selectChannel(channel_id) { let messageInput = document.getElementById('input-message') messageInput.disabled = !channel['write_access'] + + redrawMessages() } function sendMessage() { @@ -57,28 +60,84 @@ function setChannels(new_channels) { channels = {} for (let channel of new_channels) { channels[channel['id']] = channel + if (!messages[channel['id']]) + messages[channel['id']] = [] + + socket.send(JSON.stringify({ + 'type': 'fetch_messages', + 'channel_id': channel['id'], + })) } - if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) { + if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) selectChannel(Object.keys(channels)[0]) - } } function receiveMessage(message) { + messages[message['channel_id']].push(message) + redrawMessages() +} + +function fetchMessages(data) { + let channel_id = data['channel_id'] + let new_messages = data['messages'] + + if (!messages[channel_id]) + messages[channel_id] = [] + + for (let message of new_messages) { + messages[channel_id].push(message) + } + + redrawMessages() +} + +function redrawMessages() { let messageList = document.getElementById('message-list') + messageList.innerHTML = '' - let messageElement = document.createElement('li') - messageElement.classList.add('list-group-item') - messageList.appendChild(messageElement) + let lastMessage = null + let lastContentDiv = null - let authorDiv = document.createElement('div') - authorDiv.classList.add('text-muted', 'fw-bold') - authorDiv.innerText = message['author'] - messageElement.appendChild(authorDiv) + for (let message of messages[selected_channel_id]) { + if (lastMessage && lastMessage['author'] === message['author']) { + let lastTimestamp = new Date(lastMessage['timestamp']) + let newTimestamp = new Date(message['timestamp']) + if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) { + let messageContentDiv = document.createElement('div') + messageContentDiv.innerText = message['content'] + lastContentDiv.appendChild(messageContentDiv) + continue + } + } - let contentDiv = document.createElement('div') - contentDiv.innerText = message['content'] - messageElement.appendChild(contentDiv) + let messageElement = document.createElement('li') + messageElement.classList.add('list-group-item') + messageList.appendChild(messageElement) + + let authorDiv = document.createElement('div') + messageElement.appendChild(authorDiv) + + let authorSpan = document.createElement('span') + authorSpan.classList.add('text-muted', 'fw-bold') + authorSpan.innerText = message['author'] + authorDiv.appendChild(authorSpan) + + let dateSpan = document.createElement('span') + dateSpan.classList.add('text-muted', 'float-end') + dateSpan.innerText = new Date(message['timestamp']).toLocaleString() + authorDiv.appendChild(dateSpan) + + let contentDiv = document.createElement('div') + messageElement.appendChild(contentDiv) + + let messageContentDiv = document.createElement('div') + messageContentDiv.innerText = message['content'] + contentDiv.appendChild(messageContentDiv) + + lastMessage = message + lastContentDiv = contentDiv + } } document.addEventListener('DOMContentLoaded', () => { @@ -87,7 +146,6 @@ document.addEventListener('DOMContentLoaded', () => { * @param data The received message */ function processMessage(data) { - // TODO Implement chat protocol switch (data['type']) { case 'fetch_channels': setChannels(data['channels']) @@ -95,6 +153,9 @@ document.addEventListener('DOMContentLoaded', () => { case 'send_message': receiveMessage(data) break + case 'fetch_messages': + fetchMessages(data) + break default: console.log(data) console.error('Unknown message type:', data['type']) From d617dd77c1efed93b713092f3d5cd7527234ccaf Mon Sep 17 00:00:00 2001 From: Emmy D'Anello Date: Sat, 27 Apr 2024 14:12:08 +0200 Subject: [PATCH 09/35] Properly sort messages and add fetch previous messages ability Signed-off-by: Emmy D'Anello --- chat/consumers.py | 12 +++++--- chat/static/chat.js | 48 +++++++++++++++++++++-------- chat/templates/chat/chat.html | 6 ++++ locale/fr/LC_MESSAGES/django.po | 54 ++++++++++++++++++--------------- 4 files changed, 78 insertions(+), 42 deletions(-) diff --git a/chat/consumers.py b/chat/consumers.py index c4e7bb6..1f7441c 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -63,7 +63,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): case 'send_message': await self.receive_message(content) case 'fetch_messages': - await self.fetch_messages(content['channel_id']) + await self.fetch_messages(**content) case unknown: print("Unknown message type:", unknown) @@ -109,17 +109,19 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): 'content': message.content, }) - async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50) -> None: + async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None: channel = await Channel.objects.aget(id=channel_id) read_channels = await Channel.get_accessible_channels(self.scope['user'], 'read') if not await read_channels.acontains(channel): return - messages = Message.objects.filter(channel=channel).order_by('created_at')[offset:offset + limit].all() + limit = min(limit, 200) # Fetch only maximum 200 messages at the time + + messages = Message.objects.filter(channel=channel).order_by('-created_at')[offset:offset + limit].all() await self.send_json({ 'type': 'fetch_messages', 'channel_id': channel_id, - 'messages': [ + 'messages': list(reversed([ { 'id': message.id, 'timestamp': message.created_at.isoformat(), @@ -127,7 +129,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): 'content': message.content, } async for message in messages - ] + ])) }) async def chat_send_message(self, message) -> None: diff --git a/chat/static/chat.js b/chat/static/chat.js index 8557008..68d0c04 100644 --- a/chat/static/chat.js +++ b/chat/static/chat.js @@ -4,6 +4,8 @@ await Notification.requestPermission() })() +const MAX_MESSAGES = 50 + let channels = {} let messages = {} let selected_channel_id = null @@ -61,12 +63,9 @@ function setChannels(new_channels) { for (let channel of new_channels) { channels[channel['id']] = channel if (!messages[channel['id']]) - messages[channel['id']] = [] + messages[channel['id']] = new Map() - socket.send(JSON.stringify({ - 'type': 'fetch_messages', - 'channel_id': channel['id'], - })) + fetchMessages(channel['id']) } if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) @@ -78,16 +77,35 @@ function receiveMessage(message) { redrawMessages() } -function fetchMessages(data) { +function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) { + socket.send(JSON.stringify({ + 'type': 'fetch_messages', + 'channel_id': channel_id, + 'offset': offset, + 'limit': limit, + })) +} + +function fetchPreviousMessages() { + let channel_id = selected_channel_id + let offset = messages[channel_id].size + fetchMessages(channel_id, offset, MAX_MESSAGES) +} + +function receiveFetchedMessages(data) { let channel_id = data['channel_id'] let new_messages = data['messages'] if (!messages[channel_id]) - messages[channel_id] = [] + messages[channel_id] = new Map() - for (let message of new_messages) { - messages[channel_id].push(message) - } + for (let message of new_messages) + messages[channel_id].set(message['id'], message) + + // Sort messages by timestamp + messages[channel_id] = new Map([...messages[channel_id].values()] + .sort((a, b) => new Date(a['timestamp']) - new Date(b['timestamp'])) + .map(message => [message['id'], message])) redrawMessages() } @@ -99,7 +117,7 @@ function redrawMessages() { let lastMessage = null let lastContentDiv = null - for (let message of messages[selected_channel_id]) { + for (let message of messages[selected_channel_id].values()) { if (lastMessage && lastMessage['author'] === message['author']) { let lastTimestamp = new Date(lastMessage['timestamp']) let newTimestamp = new Date(message['timestamp']) @@ -138,6 +156,12 @@ function redrawMessages() { lastMessage = message lastContentDiv = contentDiv } + + let fetchMoreButton = document.getElementById('fetch-previous-messages') + if (!messages[selected_channel_id] || messages[selected_channel_id].size % MAX_MESSAGES !== 0) + fetchMoreButton.classList.add('d-none') + else + fetchMoreButton.classList.remove('d-none') } document.addEventListener('DOMContentLoaded', () => { @@ -154,7 +178,7 @@ document.addEventListener('DOMContentLoaded', () => { receiveMessage(data) break case 'fetch_messages': - fetchMessages(data) + receiveFetchedMessages(data) break default: console.log(data) diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html index b3d293a..4318f36 100644 --- a/chat/templates/chat/chat.html +++ b/chat/templates/chat/chat.html @@ -35,6 +35,12 @@
    - +
    From 2d706b2b81dc64957f32518a8986a0428505eec0 Mon Sep 17 00:00:00 2001 From: Emmy D'Anello Date: Sat, 27 Apr 2024 16:16:57 +0200 Subject: [PATCH 11/35] Add fullscreen mode for chat Signed-off-by: Emmy D'Anello --- chat/static/chat.js | 24 ++++++++++++++++++++++-- chat/templates/chat/chat.html | 8 +++++++- chat/views.py | 10 ++-------- locale/fr/LC_MESSAGES/django.po | 22 +++++++++++++--------- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/chat/static/chat.js b/chat/static/chat.js index 9172c97..af028d2 100644 --- a/chat/static/chat.js +++ b/chat/static/chat.js @@ -33,6 +33,8 @@ function selectChannel(channel_id) { selected_channel_id = channel_id + window.history.replaceState({}, null, `#channel-${channel['id']}`) + let channelTitle = document.getElementById('channel-title') channelTitle.innerText = channel['name'] @@ -84,8 +86,12 @@ function setChannels(new_channels) { fetchMessages(channel['id']) } - if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) - selectChannel(Object.keys(channels)[0]) + if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) { + if (window.location.hash) + selectChannel(window.location.hash.substring(9)) + else + selectChannel(Object.keys(channels)[0]) + } } function receiveMessage(message) { @@ -180,6 +186,20 @@ function redrawMessages() { fetchMoreButton.classList.remove('d-none') } +function toggleFullscreen() { + let chatContainer = document.getElementById('chat-container') + if (!chatContainer.getAttribute('data-fullscreen')) { + chatContainer.setAttribute('data-fullscreen', 'true') + chatContainer.classList.add('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3') + window.history.replaceState({}, null, `?fullscreen=1#channel-${selected_channel_id}`) + } + else { + chatContainer.removeAttribute('data-fullscreen') + chatContainer.classList.remove('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3') + window.history.replaceState({}, null, `?fullscreen=0#channel-${selected_channel_id}`) + } +} + document.addEventListener('DOMContentLoaded', () => { /** * Process the received data from the server. diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html index 81e5c21..a57bb63 100644 --- a/chat/templates/chat/chat.html +++ b/chat/templates/chat/chat.html @@ -3,6 +3,8 @@ {% load static %} {% load i18n %} +{% block content-title %}{% endblock %} + {% block content %}
    -