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..757dcac --- /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', 'category', 'read_access', 'write_access', 'tournament', 'private',) + list_filter = ('category', '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', 'users_read',) diff --git a/chat/apps.py b/chat/apps.py new file mode 100644 index 0000000..ce683bf --- /dev/null +++ b/chat/apps.py @@ -0,0 +1,16 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.apps import AppConfig +from django.db.models.signals import post_save + + +class ChatConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "chat" + + def ready(self): + from chat import signals + post_save.connect(signals.create_tournament_channels, "participation.Tournament") + post_save.connect(signals.create_pool_channels, "participation.Pool") + post_save.connect(signals.create_team_channel, "participation.Participation") diff --git a/chat/consumers.py b/chat/consumers.py new file mode 100644 index 0000000..168284e --- /dev/null +++ b/chat/consumers.py @@ -0,0 +1,251 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from channels.generic.websocket import AsyncJsonWebsocketConsumer +from django.contrib.auth.models import User +from django.db.models import Count, F, Q +from registration.models import Registration + +from .models import Channel, Message + + +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. + """ + 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'] + 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() + + self.read_channels = await Channel.get_accessible_channels(user, 'read') + self.write_channels = await Channel.get_accessible_channels(user, 'write') + + async for channel in self.read_channels.all(): + await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name) + await self.channel_layer.group_add(f"user-{user.id}", self.channel_name) + + 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 for channel in self.read_channels.all(): + await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name) + await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name) + + 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. + """ + match content['type']: + case 'fetch_channels': + await self.fetch_channels() + case 'send_message': + await self.receive_message(**content) + case 'edit_message': + await self.edit_message(**content) + case 'delete_message': + await self.delete_message(**content) + case 'fetch_messages': + await self.fetch_messages(**content) + case 'mark_read': + await self.mark_read(**content) + case 'start_private_chat': + await self.start_private_chat(**content) + case unknown: + print("Unknown message type:", unknown) + + async def fetch_channels(self) -> None: + user = self.scope['user'] + + message = { + 'type': 'fetch_channels', + 'channels': [ + { + 'id': channel.id, + 'name': channel.get_visible_name(user), + 'category': channel.category, + 'read_access': True, + 'write_access': await self.write_channels.acontains(channel), + 'unread_messages': channel.unread_messages, + } + async for channel in self.read_channels.prefetch_related('invited') + .annotate(total_messages=Count('messages', distinct=True)) + .annotate(read_messages=Count('messages', filter=Q(messages__users_read=user), distinct=True)) + .annotate(unread_messages=F('total_messages') - F('read_messages')).all() + ] + } + await self.send_json(message) + + async def receive_message(self, channel_id: int, content: str, **kwargs) -> None: + user = self.scope['user'] + channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \ + .aget(id=channel_id) + if not await self.write_channels.acontains(channel): + return + + message = await Message.objects.acreate( + author=user, + channel=channel, + content=content, + ) + + 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_id': message.author_id, + 'author': await message.aget_author_name(), + 'content': message.content, + }) + + async def edit_message(self, message_id: int, content: str, **kwargs) -> None: + message = await Message.objects.aget(id=message_id) + user = self.scope['user'] + if user.id != message.author_id and not user.is_superuser: + return + + message.content = content + await message.asave() + + await self.channel_layer.group_send(f'chat-{message.channel_id}', { + 'type': 'chat.edit_message', + 'id': message_id, + 'channel_id': message.channel_id, + 'content': content, + }) + + async def delete_message(self, message_id: int, **kwargs) -> None: + message = await Message.objects.aget(id=message_id) + user = self.scope['user'] + if user.id != message.author_id and not user.is_superuser: + return + + await message.adelete() + + await self.channel_layer.group_send(f'chat-{message.channel_id}', { + 'type': 'chat.delete_message', + 'id': message_id, + 'channel_id': message.channel_id, + }) + + async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None: + channel = await Channel.objects.aget(id=channel_id) + if not await self.read_channels.acontains(channel): + return + + limit = min(limit, 200) # Fetch only maximum 200 messages at the time + + messages = Message.objects \ + .filter(channel=channel) \ + .annotate(read=Count('users_read', filter=Q(users_read=self.scope['user']))) \ + .order_by('-created_at')[offset:offset + limit].all() + await self.send_json({ + 'type': 'fetch_messages', + 'channel_id': channel_id, + 'messages': list(reversed([ + { + 'id': message.id, + 'timestamp': message.created_at.isoformat(), + 'author_id': message.author_id, + 'author': await message.aget_author_name(), + 'content': message.content, + 'read': message.read > 0, + } + async for message in messages + ])) + }) + + async def mark_read(self, message_ids: list[int], **_kwargs) -> None: + messages = Message.objects.filter(id__in=message_ids) + async for message in messages.all(): + await message.users_read.aadd(self.scope['user']) + + unread_messages_by_channel = Message.objects.exclude(users_read=self.scope['user']).values('channel_id') \ + .annotate(unread_messages=Count('channel_id')) + + await self.send_json({ + 'type': 'mark_read', + 'messages': [{'id': message.id, 'channel_id': message.channel_id} async for message in messages.all()], + 'unread_messages': {group['channel_id']: group['unread_messages'] + async for group in unread_messages_by_channel.all()}, + }) + + async def start_private_chat(self, user_id: int, **kwargs) -> None: + user = self.scope['user'] + other_user = await User.objects.aget(id=user_id) + channel_qs = Channel.objects.filter(private=True).filter(invited=user).filter(invited=other_user) + if not await channel_qs.aexists(): + channel = await Channel.objects.acreate( + name=f"{user.first_name} {user.last_name}, {other_user.first_name} {other_user.last_name}", + category=Channel.ChannelCategory.PRIVATE, + private=True, + ) + await channel.invited.aset([user, other_user]) + + await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name) + + if user != other_user: + await self.channel_layer.group_send(f"user-{other_user.id}", { + 'type': 'chat.start_private_chat', + 'channel': { + 'id': channel.id, + 'name': f"{user.first_name} {user.last_name}", + 'category': channel.category, + 'read_access': True, + 'write_access': True, + } + }) + else: + channel = await channel_qs.afirst() + + await self.channel_layer.group_send(f"user-{user.id}", { + 'type': 'chat.start_private_chat', + 'channel': { + 'id': channel.id, + 'name': f"{other_user.first_name} {other_user.last_name}", + 'category': channel.category, + 'read_access': True, + 'write_access': True, + } + }) + + async def chat_send_message(self, message) -> None: + await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'], + 'timestamp': message['timestamp'], 'author': message['author'], + 'content': message['content']}) + + async def chat_edit_message(self, message) -> None: + await self.send_json({'type': 'edit_message', 'id': message['id'], 'channel_id': message['channel_id'], + 'content': message['content']}) + + async def chat_delete_message(self, message) -> None: + await self.send_json({'type': 'delete_message', 'id': message['id'], 'channel_id': message['channel_id']}) + + async def chat_start_private_chat(self, message) -> None: + await self.channel_layer.group_add(f"chat-{message['channel']['id']}", self.channel_name) + await self.send_json({'type': 'start_private_chat', 'channel': message['channel']}) diff --git a/chat/management/__init__.py b/chat/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/management/commands/__init__.py b/chat/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/management/commands/create_chat_channels.py b/chat/management/commands/create_chat_channels.py new file mode 100644 index 0000000..666ee3f --- /dev/null +++ b/chat/management/commands/create_chat_channels.py @@ -0,0 +1,143 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.core.management import BaseCommand +from django.utils.translation import activate +from participation.models import Team, Tournament +from tfjm.permissions import PermissionType + +from ...models import Channel + + +class Command(BaseCommand): + def handle(self, *args, **kwargs): + activate('fr') + + Channel.objects.update_or_create( + name="Annonces", + defaults=dict( + category=Channel.ChannelCategory.GENERAL, + read_access=PermissionType.AUTHENTICATED, + write_access=PermissionType.ADMIN, + ), + ) + + Channel.objects.update_or_create( + name="Aide jurys et orgas", + defaults=dict( + category=Channel.ChannelCategory.GENERAL, + read_access=PermissionType.VOLUNTEER, + write_access=PermissionType.VOLUNTEER, + ), + ) + + Channel.objects.update_or_create( + name="Général", + defaults=dict( + category=Channel.ChannelCategory.GENERAL, + read_access=PermissionType.AUTHENTICATED, + write_access=PermissionType.AUTHENTICATED, + ), + ) + + Channel.objects.update_or_create( + name="Je cherche une équipe", + defaults=dict( + category=Channel.ChannelCategory.GENERAL, + read_access=PermissionType.AUTHENTICATED, + write_access=PermissionType.AUTHENTICATED, + ), + ) + + Channel.objects.update_or_create( + name="Détente", + defaults=dict( + category=Channel.ChannelCategory.GENERAL, + read_access=PermissionType.AUTHENTICATED, + write_access=PermissionType.AUTHENTICATED, + ), + ) + + for tournament in Tournament.objects.all(): + Channel.objects.update_or_create( + name=f"{tournament.name} - Annonces", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.TOURNAMENT_MEMBER, + write_access=PermissionType.TOURNAMENT_ORGANIZER, + tournament=tournament, + ), + ) + + Channel.objects.update_or_create( + name=f"{tournament.name} - Général", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.TOURNAMENT_MEMBER, + write_access=PermissionType.TOURNAMENT_MEMBER, + tournament=tournament, + ), + ) + + Channel.objects.update_or_create( + name=f"{tournament.name} - Détente", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.TOURNAMENT_MEMBER, + write_access=PermissionType.TOURNAMENT_MEMBER, + tournament=tournament, + ), + ) + + Channel.objects.update_or_create( + name=f"{tournament.name} - Juré⋅es", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.JURY_MEMBER, + write_access=PermissionType.JURY_MEMBER, + tournament=tournament, + ), + ) + + if tournament.remote: + Channel.objects.update_or_create( + name=f"{tournament.name} - Président⋅es de jury", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.TOURNAMENT_JURY_PRESIDENT, + write_access=PermissionType.TOURNAMENT_JURY_PRESIDENT, + tournament=tournament, + ), + ) + + for pool in tournament.pools.all(): + Channel.objects.update_or_create( + name=f"{tournament.name} - Poule {pool.short_name}", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.POOL_MEMBER, + write_access=PermissionType.POOL_MEMBER, + pool=pool, + ), + ) + + Channel.objects.update_or_create( + name=f"{tournament.name} - Poule {pool.short_name} - Jury", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.JURY_MEMBER, + write_access=PermissionType.JURY_MEMBER, + pool=pool, + ), + ) + + for team in Team.objects.filter(participation__valid=True).all(): + Channel.objects.update_or_create( + name=f"Équipe {team.trigram}", + defaults=dict( + category=Channel.ChannelCategory.TEAM, + read_access=PermissionType.TEAM_MEMBER, + write_access=PermissionType.TEAM_MEMBER, + team=team, + ), + ) diff --git a/chat/migrations/0001_initial.py b/chat/migrations/0001_initial.py new file mode 100644 index 0000000..294bd7e --- /dev/null +++ b/chat/migrations/0001_initial.py @@ -0,0 +1,200 @@ +# Generated by Django 5.0.3 on 2024-04-27 07:00 + +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.CharField( + 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"), + ], + max_length=16, + verbose_name="read permission", + ), + ), + ( + "write_access", + models.CharField( + 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"), + ], + max_length=16, + 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/0002_alter_channel_options_channel_category.py b/chat/migrations/0002_alter_channel_options_channel_category.py new file mode 100644 index 0000000..82898dd --- /dev/null +++ b/chat/migrations/0002_alter_channel_options_channel_category.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.3 on 2024-04-28 11:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chat", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="channel", + options={ + "ordering": ("category", "name"), + "verbose_name": "channel", + "verbose_name_plural": "channels", + }, + ), + migrations.AddField( + model_name="channel", + name="category", + field=models.CharField( + choices=[ + ("general", "General channels"), + ("tournament", "Tournament channels"), + ("team", "Team channels"), + ("private", "Private channels"), + ], + default="general", + max_length=255, + verbose_name="category", + ), + ), + ] diff --git a/chat/migrations/0003_message_users_read.py b/chat/migrations/0003_message_users_read.py new file mode 100644 index 0000000..16f9434 --- /dev/null +++ b/chat/migrations/0003_message_users_read.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.3 on 2024-04-28 18:52 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chat", "0002_alter_channel_options_channel_category"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="message", + name="users_read", + field=models.ManyToManyField( + blank=True, + help_text="Users who have read the message.", + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="users read", + ), + ), + ] 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..833890f --- /dev/null +++ b/chat/models.py @@ -0,0 +1,273 @@ +# 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 Pool, Team, Tournament +from registration.models import ParticipantRegistration, Registration, VolunteerRegistration +from tfjm.permissions import PermissionType + + +class Channel(models.Model): + class ChannelCategory(models.TextChoices): + GENERAL = 'general', _("General channels") + TOURNAMENT = 'tournament', _("Tournament channels") + TEAM = 'team', _("Team channels") + PRIVATE = 'private', _("Private channels") + + name = models.CharField( + max_length=255, + verbose_name=_("name"), + ) + + category = models.CharField( + max_length=255, + verbose_name=_("category"), + choices=ChannelCategory, + default=ChannelCategory.GENERAL, + ) + + read_access = models.CharField( + max_length=16, + verbose_name=_("read permission"), + choices=PermissionType, + ) + + write_access = models.CharField( + max_length=16, + 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 get_visible_name(self, user: User) -> str: + if self.private: + users = [f"{u.first_name} {u.last_name}" for u in self.invited.all() if u != user] \ + or [f"{user.first_name} {user.last_name}"] + return ", ".join(users) + return self.name + + def __str__(self): + 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.prefetch_related('user').aget(user_id=user.id) + + if registration.is_admin: + return Channel.objects.prefetch_related('invited').exclude(~Q(invited=user) & Q(private=True)).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).prefetch_related('invited') + + return qs + + class Meta: + verbose_name = _("channel") + verbose_name_plural = _("channels") + ordering = ('category', '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"), + ) + + users_read = models.ManyToManyField( + 'auth.User', + verbose_name=_("users read"), + related_name='+', + blank=True, + help_text=_("Users who have read the message."), + ) + + 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") + ordering = ('created_at',) diff --git a/chat/signals.py b/chat/signals.py new file mode 100644 index 0000000..72056e3 --- /dev/null +++ b/chat/signals.py @@ -0,0 +1,100 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from chat.models import Channel +from participation.models import Participation, Pool, Tournament +from tfjm.permissions import PermissionType + + +def create_tournament_channels(instance: Tournament, **_kwargs): + tournament = instance + + Channel.objects.update_or_create( + name=f"{tournament.name} - Annonces", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.TOURNAMENT_MEMBER, + write_access=PermissionType.TOURNAMENT_ORGANIZER, + tournament=tournament, + ), + ) + + Channel.objects.update_or_create( + name=f"{tournament.name} - Général", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.TOURNAMENT_MEMBER, + write_access=PermissionType.TOURNAMENT_MEMBER, + tournament=tournament, + ), + ) + + Channel.objects.update_or_create( + name=f"{tournament.name} - Détente", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.TOURNAMENT_MEMBER, + write_access=PermissionType.TOURNAMENT_MEMBER, + tournament=tournament, + ), + ) + + Channel.objects.update_or_create( + name=f"{tournament.name} - Juré⋅es", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.JURY_MEMBER, + write_access=PermissionType.JURY_MEMBER, + tournament=tournament, + ), + ) + + if tournament.remote: + Channel.objects.update_or_create( + name=f"{tournament.name} - Président⋅es de jury", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.TOURNAMENT_JURY_PRESIDENT, + write_access=PermissionType.TOURNAMENT_JURY_PRESIDENT, + tournament=tournament, + ), + ) + + +def create_pool_channels(instance: Pool, **_kwargs): + pool = instance + tournament = pool.tournament + + if tournament.remote: + Channel.objects.update_or_create( + name=f"{tournament.name} - Poule {pool.short_name}", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.POOL_MEMBER, + write_access=PermissionType.POOL_MEMBER, + pool=pool, + ), + ) + + Channel.objects.update_or_create( + name=f"{tournament.name} - Poule {pool.short_name} - Jury", + defaults=dict( + category=Channel.ChannelCategory.TOURNAMENT, + read_access=PermissionType.JURY_MEMBER, + write_access=PermissionType.JURY_MEMBER, + pool=pool, + ), + ) + + +def create_team_channel(instance: Participation, **_kwargs): + if instance.valid: + Channel.objects.update_or_create( + name=f"Équipe {instance.team.trigram}", + defaults=dict( + category=Channel.ChannelCategory.TEAM, + read_access=PermissionType.TEAM_MEMBER, + write_access=PermissionType.TEAM_MEMBER, + team=instance.team, + ), + ) diff --git a/chat/static/chat.js b/chat/static/chat.js new file mode 100644 index 0000000..89ab947 --- /dev/null +++ b/chat/static/chat.js @@ -0,0 +1,576 @@ +(async () => { + // check notification permission + // This is useful to alert people that they should do something + await Notification.requestPermission() +})() + +const MAX_MESSAGES = 50 + +const channel_categories = ['general', 'tournament', 'team', 'private'] +let channels = {} +let messages = {} +let selected_channel_id = null + +/** + * 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) { + Notification.requestPermission().then((status) => { + if (status === 'granted') + new Notification(title, {'body': body, 'icon': "/static/tfjm-192.png"}) + }) +} + +function selectChannel(channel_id) { + let channel = channels[channel_id] + if (!channel) { + console.error('Channel not found:', channel_id) + return + } + + selected_channel_id = channel_id + localStorage.setItem('chat.last-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'] + + redrawMessages() +} + +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 = {} + let categoryLists = {} + for (let category of channel_categories) { + categoryLists[category] = document.getElementById(`nav-${category}-channels-tab`) + categoryLists[category].innerHTML = '' + categoryLists[category].parentElement.classList.add('d-none') + } + + for (let channel of new_channels) + addChannel(channel, categoryLists) + + if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) { + let last_channel_id = parseInt(localStorage.getItem('chat.last-channel-id')) + if (last_channel_id && channels[last_channel_id]) + selectChannel(last_channel_id) + else + selectChannel(Object.keys(channels)[0]) + } +} + +async function addChannel(channel, categoryLists) { + channels[channel['id']] = channel + if (!messages[channel['id']]) + messages[channel['id']] = new Map() + + let categoryList = categoryLists[channel['category']] + categoryList.parentElement.classList.remove('d-none') + + let navItem = document.createElement('li') + navItem.classList.add('list-group-item', 'tab-channel') + navItem.id = `tab-channel-${channel['id']}` + navItem.setAttribute('data-bs-dismiss', 'offcanvas') + navItem.onclick = () => selectChannel(channel['id']) + categoryList.appendChild(navItem) + + let channelButton = document.createElement('button') + channelButton.classList.add('nav-link') + channelButton.type = 'button' + channelButton.innerText = channel['name'] + navItem.appendChild(channelButton) + + let unreadBadge = document.createElement('span') + unreadBadge.classList.add('badge', 'rounded-pill', 'text-bg-light', 'ms-2') + unreadBadge.id = `unread-messages-${channel['id']}` + unreadBadge.innerText = channel.unread_messages || 0 + if (!channel.unread_messages) + unreadBadge.classList.add('d-none') + channelButton.appendChild(unreadBadge) + + if (document.getElementById('sort-by-unread-switch').checked) + navItem.style.order = `${-channel.unread_messages}` + + fetchMessages(channel['id']) +} + +function receiveMessage(message) { + let scrollableContent = document.getElementById('chat-messages') + let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1 + + messages[message['channel_id']].set(message['id'], message) + redrawMessages() + + // Scroll to bottom if the user was already at the bottom + if (isScrolledToBottom) + scrollableContent.scrollTop = scrollableContent.scrollHeight - scrollableContent.clientHeight + + if (message['content'].includes("@everyone")) + showNotification(channels[message['channel_id']]['name'], `${message['author']} : ${message['content']}`) +} + +function editMessage(data) { + messages[data['channel_id']].get(data['id'])['content'] = data['content'] + redrawMessages() +} + +function deleteMessage(data) { + messages[data['channel_id']].delete(data['id']) + redrawMessages() +} + +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) { + console.log(data) + let channel_id = data['channel_id'] + let new_messages = data['messages'] + + if (!messages[channel_id]) + messages[channel_id] = new Map() + + 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() +} + +function markMessageAsRead(data) { + for (let message of data['messages']) { + let stored_message = messages[message['channel_id']].get(message['id']) + if (stored_message) + stored_message['read'] = true + } + redrawMessages() + updateUnreadBadges(data['unread_messages']) +} + +function updateUnreadBadges(unreadMessages) { + const sortByUnread = document.getElementById('sort-by-unread-switch').checked + + for (let channel of Object.values(channels)) { + let unreadMessagesChannel = unreadMessages[channel['id']] || 0 + channel.unread_messages = unreadMessagesChannel + + let unreadBadge = document.getElementById(`unread-messages-${channel['id']}`) + unreadBadge.innerText = unreadMessagesChannel + if (unreadMessagesChannel) + unreadBadge.classList.remove('d-none') + else + unreadBadge.classList.add('d-none') + + if (sortByUnread) + document.getElementById(`tab-channel-${channel['id']}`).style.order = `${-unreadMessagesChannel}` + } +} + +function startPrivateChat(data) { + let channel = data['channel'] + if (!channel) { + console.error('Private chat not found:', data) + return + } + + if (!channels[channel['id']]) { + channels[channel['id']] = channel + messages[channel['id']] = new Map() + setChannels(Object.values(channels)) + } + + selectChannel(channel['id']) +} + +function redrawMessages() { + let messageList = document.getElementById('message-list') + messageList.innerHTML = '' + + let lastMessage = null + let lastContentDiv = null + + 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']) + if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) { + let messageContentDiv = document.createElement('div') + messageContentDiv.classList.add('message') + messageContentDiv.setAttribute('data-message-id', message['id']) + lastContentDiv.appendChild(messageContentDiv) + let messageContentSpan = document.createElement('span') + messageContentSpan.innerText = message['content'] + messageContentDiv.appendChild(messageContentSpan) + + registerMessageContextMenu(message, messageContentDiv, messageContentSpan) + continue + } + } + + 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) + + registerSendPrivateMessageContextMenu(message, authorDiv, 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.classList.add('message') + messageContentDiv.setAttribute('data-message-id', message['id']) + contentDiv.appendChild(messageContentDiv) + let messageContentSpan = document.createElement('span') + messageContentSpan.innerText = message['content'] + messageContentDiv.appendChild(messageContentSpan) + + registerMessageContextMenu(message, messageContentDiv, messageContentSpan) + + lastMessage = message + lastContentDiv = contentDiv + } + + let fetchMoreButton = document.getElementById('fetch-previous-messages') + if (!messages[selected_channel_id].size || messages[selected_channel_id].size % MAX_MESSAGES !== 0) + fetchMoreButton.classList.add('d-none') + else + fetchMoreButton.classList.remove('d-none') + + messageList.dispatchEvent(new CustomEvent('updatemessages')) +} + +function removeAllPopovers() { + for (let popover of document.querySelectorAll('*[aria-describedby*="popover"]')) { + let instance = bootstrap.Popover.getInstance(popover) + if (instance) + instance.dispose() + } +} + +function registerSendPrivateMessageContextMenu(message, div, span) { + div.addEventListener('contextmenu', (menu_event) => { + menu_event.preventDefault() + removeAllPopovers() + const popover = bootstrap.Popover.getOrCreateInstance(span, { + 'title': message['author'], + 'content': `Envoyer un message privé`, + 'html': true, + }) + popover.show() + + document.getElementById('send-private-message-link-' + message['id']).addEventListener('click', event => { + event.preventDefault() + popover.dispose() + socket.send(JSON.stringify({ + 'type': 'start_private_chat', + 'user_id': message['author_id'], + })) + }) + }) +} + +function registerMessageContextMenu(message, div, span) { + div.addEventListener('contextmenu', (menu_event) => { + menu_event.preventDefault() + removeAllPopovers() + let content = `Envoyer un message privé` + + let has_right_to_edit = message['author_id'] === USER_ID || IS_ADMIN + if (has_right_to_edit) { + content += `
` + content += `Modifier` + content += `Supprimer` + } + + const popover = bootstrap.Popover.getOrCreateInstance(span, { + 'content': content, + 'html': true, + 'placement': 'bottom', + }) + popover.show() + + document.getElementById('send-private-message-link-msg-' + message['id']).addEventListener('click', event => { + event.preventDefault() + popover.dispose() + socket.send(JSON.stringify({ + 'type': 'start_private_chat', + 'user_id': message['author_id'], + })) + }) + + if (has_right_to_edit) { + document.getElementById('edit-message-' + message['id']).addEventListener('click', event => { + event.preventDefault() + popover.dispose() + let new_message = prompt("Modifier le message", message['content']) + if (new_message) { + socket.send(JSON.stringify({ + 'type': 'edit_message', + 'message_id': message['id'], + 'content': new_message, + })) + } + }) + + document.getElementById('delete-message-' + message['id']).addEventListener('click', event => { + event.preventDefault() + popover.dispose() + if (confirm(`Supprimer le message ?\n${message['content']}`)) { + socket.send(JSON.stringify({ + 'type': 'delete_message', + 'message_id': message['id'], + })) + } + }) + } + }) +} + +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`) + } + else { + chatContainer.removeAttribute('data-fullscreen') + chatContainer.classList.remove('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3') + window.history.replaceState({}, null, `?fullscreen=0`) + } +} + +document.addEventListener('DOMContentLoaded', () => { + document.addEventListener('click', removeAllPopovers) + + document.getElementById('sort-by-unread-switch').addEventListener('change', event => { + const sortByUnread = event.target.checked + for (let channel of Object.values(channels)) { + let item = document.getElementById(`tab-channel-${channel['id']}`) + if (sortByUnread) + item.style.order = `${-channel.unread_messages}` + else + item.style.removeProperty('order') + } + + localStorage.setItem('chat.sort-by-unread', sortByUnread) + }) + + if (localStorage.getItem('chat.sort-by-unread') === 'true') { + document.getElementById('sort-by-unread-switch').checked = true + document.getElementById('sort-by-unread-switch').dispatchEvent(new Event('change')) + } + + /** + * Process the received data from the server. + * @param data The received message + */ + function processMessage(data) { + switch (data['type']) { + case 'fetch_channels': + setChannels(data['channels']) + break + case 'send_message': + receiveMessage(data) + break + case 'edit_message': + editMessage(data) + break + case 'delete_message': + deleteMessage(data) + break + case 'fetch_messages': + receiveFetchedMessages(data) + break + case 'mark_read': + markMessageAsRead(data) + break + case 'start_private_chat': + startPrivateChat(data) + break + default: + console.log(data) + console.error('Unknown message type:', data['type']) + break + } + } + + function setupSocket(nextDelay = 1000) { + // Open a global websocket + socket = new WebSocket( + (document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/' + ) + let socketOpen = false + + // 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(socketOpen ? 1000 : 2 * nextDelay), nextDelay) + }) + + socket.addEventListener('open', e => { + socketOpen = true + socket.send(JSON.stringify({ + 'type': 'fetch_channels', + })) + }) + } + + function setupSwipeOffscreen() { + const offcanvas = new bootstrap.Offcanvas(document.getElementById('channelSelector')) + + let lastX = null + document.addEventListener('touchstart', (event) => { + if (event.touches.length === 1) + lastX = event.touches[0].clientX + }) + document.addEventListener('touchmove', (event) => { + if (event.touches.length === 1 && lastX !== null) { + const diff = event.touches[0].clientX - lastX + if (diff > window.innerWidth / 10 && lastX < window.innerWidth / 4) { + offcanvas.show() + lastX = null + } + else if (diff < -window.innerWidth / 10) { + offcanvas.hide() + lastX = null + } + } + }) + document.addEventListener('touchend', () => { + lastX = null + }) + } + + function setupReadTracker() { + const scrollableContent = document.getElementById('chat-messages') + const messagesList = document.getElementById('message-list') + let markReadBuffer = [] + let markReadTimeout = null + + scrollableContent.addEventListener('scroll', () => { + if (scrollableContent.clientHeight - scrollableContent.scrollTop === scrollableContent.scrollHeight + && !document.getElementById('fetch-previous-messages').classList.contains('d-none')) { + // If the user is at the top of the chat, fetch previous messages + fetchPreviousMessages()} + + markVisibleMessagesAsRead() + }) + + messagesList.addEventListener('updatemessages', () => markVisibleMessagesAsRead()) + + function markVisibleMessagesAsRead() { + let viewport = scrollableContent.getBoundingClientRect() + + for (let item of messagesList.querySelectorAll('.message')) { + let message = messages[selected_channel_id].get(parseInt(item.getAttribute('data-message-id'))) + if (!message.read) { + let rect = item.getBoundingClientRect() + if (rect.top >= viewport.top && rect.bottom <= viewport.bottom) { + message.read = true + markReadBuffer.push(message['id']) + if (markReadTimeout) + clearTimeout(markReadTimeout) + markReadTimeout = setTimeout(() => { + socket.send(JSON.stringify({ + 'type': 'mark_read', + 'message_ids': markReadBuffer, + })) + markReadBuffer = [] + markReadTimeout = null + }, 3000) + } + } + } + } + + markVisibleMessagesAsRead() + } + + function setupPWAPrompt() { + let deferredPrompt = null + + window.addEventListener("beforeinstallprompt", (e) => { + e.preventDefault() + deferredPrompt = e + let btn = document.getElementById('install-app-home-screen') + let alert = document.getElementById('alert-download-chat-app') + btn.classList.remove('d-none') + alert.classList.remove('d-none') + btn.onclick = function () { + deferredPrompt.prompt() + deferredPrompt.userChoice.then((choiceResult) => { + if (choiceResult.outcome === 'accepted') { + deferredPrompt = null + btn.classList.add('d-none') + alert.classList.add('d-none') + } + }) + } + }) + } + + setupSocket() + setupSwipeOffscreen() + setupReadTracker() + setupPWAPrompt() +}) diff --git a/chat/static/chat.webmanifest b/chat/static/chat.webmanifest new file mode 100644 index 0000000..156d0a1 --- /dev/null +++ b/chat/static/chat.webmanifest @@ -0,0 +1,29 @@ +{ + "background_color": "white", + "description": "Chat pour le TFJM²", + "display": "standalone", + "icons": [ + { + "src": "tfjm-square.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "maskable" + }, + { + "src": "tfjm-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "tfjm-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + } + ], + "name": "Chat TFJM²", + "short_name": "Chat TFJM²", + "start_url": "/chat/fullscreen", + "theme_color": "black" +} diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html new file mode 100644 index 0000000..a7c5c45 --- /dev/null +++ b/chat/templates/chat/chat.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% load static %} +{% load i18n %} + +{% block extracss %} + +{% endblock %} + +{% block content-title %}{% endblock %} + +{% block content %} + {% include "chat/content.html" %} +{% endblock %} + +{% block extrajavascript %} + {# This script contains all data for the chat management #} + +{% endblock %} diff --git a/chat/templates/chat/content.html b/chat/templates/chat/content.html new file mode 100644 index 0000000..0bf517f --- /dev/null +++ b/chat/templates/chat/content.html @@ -0,0 +1,99 @@ +{% load i18n %} + + +
+
+

{% trans "Chat channels" %}

+ +
+
+
+ + +
+ +
+
+ +
+ {% trans "You can install a shortcut to the chat on your home screen using the download button on the header." %} +
+ +
+
+

+ {% if fullscreen %} + {# Logout button must be present in a form. The form must includes the whole line. #} +
+ {% csrf_token %} + {% endif %} + + + {% if not fullscreen %} + + {% else %} + + {% endif %} + + {% if fullscreen %} +
+ {% endif %} +

+
+
+ + +
+ +
+ + \ No newline at end of file diff --git a/chat/templates/chat/fullscreen.html b/chat/templates/chat/fullscreen.html new file mode 100644 index 0000000..8d5053a --- /dev/null +++ b/chat/templates/chat/fullscreen.html @@ -0,0 +1,34 @@ +{% load i18n static %} + + +{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} + + + + + + Chat du TFJM² + + + + {# Favicon #} + + + + {# Bootstrap CSS #} + + + + + {# Bootstrap JavaScript #} + + + + + +{% include "chat/content.html" with fullscreen=True %} + + + + + diff --git a/chat/templates/chat/login.html b/chat/templates/chat/login.html new file mode 100644 index 0000000..ee3e45e --- /dev/null +++ b/chat/templates/chat/login.html @@ -0,0 +1,36 @@ +{% load i18n static %} + + +{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} + + + + + + Chat du TFJM² - {% trans "Log in" %} + + + + {# Favicon #} + + + + {# Bootstrap CSS #} + + + + + {# Bootstrap JavaScript #} + + + + + +
+

{% trans "Log in" %}

+ {% include "registration/includes/login.html" %} +
+ + + + 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..9adf06e --- /dev/null +++ b/chat/urls.py @@ -0,0 +1,18 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.auth.views import LoginView, LogoutView +from django.urls import path +from django.utils.translation import gettext_lazy as _ +from tfjm.views import LoginRequiredTemplateView + +app_name = 'chat' + +urlpatterns = [ + path('', LoginRequiredTemplateView.as_view(template_name="chat/chat.html", + extra_context={'title': _("Chat")}), name='chat'), + path('fullscreen/', LoginRequiredTemplateView.as_view(template_name="chat/fullscreen.html", login_url='chat:login'), + name='fullscreen'), + path('login/', LoginView.as_view(template_name="chat/login.html"), name='login'), + path('logout/', LogoutView.as_view(next_page='chat:fullscreen'), name='logout'), +] 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'] 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/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/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 5043b78..831fcea 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-28 23:37+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,41 @@ 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:17 chat/templates/chat/content.html:18 +msgid "General channels" +msgstr "Canaux généraux" -#: 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:18 chat/templates/chat/content.html:22 +msgid "Tournament channels" +msgstr "Canaux de tournois" + +#: chat/models.py:19 chat/templates/chat/content.html:26 +msgid "Team channels" +msgstr "Canaux d'équipes" + +#: chat/models.py:20 chat/templates/chat/content.html:30 +msgid "Private channels" +msgstr "Messages privés" + +#: chat/models.py:24 participation/models.py:35 participation/models.py:263 +#: participation/tables.py:18 participation/tables.py:34 +msgid "name" +msgstr "nom" + +#: chat/models.py:29 +msgid "category" +msgstr "catégorie" + +#: chat/models.py:36 +msgid "read permission" +msgstr "permission de lecture" + +#: chat/models.py:42 +msgid "write permission" +msgstr "permission d'écriture" + +#: chat/models.py:52 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 +63,174 @@ msgstr "équipes" msgid "tournament" msgstr "tournoi" +#: chat/models.py:54 +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:63 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:65 +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:74 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:76 +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:80 +msgid "private" +msgstr "privé" + +#: chat/models.py:82 +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:87 +msgid "invited users" +msgstr "Utilisateur⋅rices invité" + +#: chat/models.py:90 +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:102 +#, python-brace-format +msgid "Channel {name}" +msgstr "Canal {name}" + +#: chat/models.py:168 chat/models.py:177 +msgid "channel" +msgstr "canal" + +#: chat/models.py:169 +msgid "channels" +msgstr "canaux" + +#: chat/models.py:183 +msgid "author" +msgstr "auteur⋅rice" + +#: chat/models.py:190 +msgid "created at" +msgstr "créé le" + +#: chat/models.py:195 +msgid "updated at" +msgstr "modifié le" + +#: chat/models.py:200 +msgid "content" +msgstr "contenu" + +#: chat/models.py:205 +msgid "users read" +msgstr "utilisateur⋅rices ayant lu" + +#: chat/models.py:208 +msgid "Users who have read the message." +msgstr "Utilisateur⋅rices qui ont lu le message." + +#: chat/models.py:271 +msgid "message" +msgstr "message" + +#: chat/models.py:272 +msgid "messages" +msgstr "messages" + +#: chat/templates/chat/content.html:4 +msgid "JavaScript must be enabled on your browser to access chat." +msgstr "JavaScript doit être activé sur votre navigateur pour accéder au chat." + +#: chat/templates/chat/content.html:8 +msgid "Chat channels" +msgstr "Canaux de chat" + +#: chat/templates/chat/content.html:14 +msgid "Sort by unread messages" +msgstr "Trier par messages non lus" + +#: chat/templates/chat/content.html:38 +msgid "" +"You can install a shortcut to the chat on your home screen using the " +"download button on the header." +msgstr "" +"Vous pouvez installer un raccourci vers le chat sur votre écran d'accueil en " +"utilisant le bouton de téléchargement dans l'en-tête." + +#: chat/templates/chat/content.html:56 +msgid "Toggle fullscreen mode" +msgstr "Inverse le mode plein écran" + +#: chat/templates/chat/content.html:60 tfjm/templates/navbar.html:117 +msgid "Log out" +msgstr "Déconnexion" + +#: chat/templates/chat/content.html:64 +msgid "Install app on home screen" +msgstr "Installer l'application sur l'écran d'accueil" + +#: chat/templates/chat/content.html:76 +msgid "Fetch previous messages…" +msgstr "Récupérer les messages précédents…" + +#: chat/templates/chat/content.html:87 +msgid "Send message…" +msgstr "Envoyer un message…" + +#: chat/templates/chat/login.html:10 chat/templates/chat/login.html:30 +#: registration/templates/registration/password_reset_complete.html:10 +#: tfjm/templates/base.html:84 tfjm/templates/base.html:85 +#: tfjm/templates/navbar.html:98 +#: tfjm/templates/registration/includes/login.html:22 +#: tfjm/templates/registration/login.html:7 +#: tfjm/templates/registration/login.html:8 +msgid "Log in" +msgstr "Connexion" + +#: chat/urls.py:13 tfjm/templates/navbar.html:66 +msgid "Chat" +msgstr "Chat" + +#: 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" @@ -45,68 +240,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." @@ -213,12 +408,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 +541,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 +769,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 +1394,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 @@ -2899,14 +3064,6 @@ msgstr "Changer mon mot de passe" msgid "Your password has been set. You may go ahead and log in now." msgstr "Votre mot de passe a été changé. Vous pouvez désormais vous connecter." -#: registration/templates/registration/password_reset_complete.html:10 -#: tfjm/templates/base.html:84 tfjm/templates/base.html:85 -#: tfjm/templates/navbar.html:98 tfjm/templates/registration/login.html:7 -#: tfjm/templates/registration/login.html:8 -#: tfjm/templates/registration/login.html:30 -msgid "Log in" -msgstr "Connexion" - #: registration/templates/registration/password_reset_confirm.html:9 msgid "" "Please enter your new password twice so we can verify you typed it in " @@ -3484,11 +3641,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:169 msgid "English" msgstr "Anglais" -#: tfjm/settings.py:168 +#: tfjm/settings.py:170 msgid "French" msgstr "Français" @@ -3577,10 +3778,6 @@ msgstr "Mon équipe" msgid "My participation" msgstr "Ma participation" -#: tfjm/templates/navbar.html:67 -msgid "Chat" -msgstr "Chat" - #: tfjm/templates/navbar.html:72 msgid "Administration" msgstr "Administration" @@ -3601,19 +3798,7 @@ msgstr "S'inscrire" msgid "My account" msgstr "Mon compte" -#: tfjm/templates/navbar.html:115 -msgid "Log out" -msgstr "Déconnexion" - -#: tfjm/templates/registration/logged_out.html:8 -msgid "Thanks for spending some quality time with the Web site today." -msgstr "Merci d'avoir utilisé la plateforme du TFJM²." - -#: tfjm/templates/registration/logged_out.html:9 -msgid "Log in again" -msgstr "Se reconnecter" - -#: tfjm/templates/registration/login.html:13 +#: tfjm/templates/registration/includes/login.html:5 #, python-format msgid "" "You are authenticated as %(user)s, but are not authorized to access this " @@ -3622,14 +3807,22 @@ msgstr "" "Vous êtes connecté⋅e en tant que %(user)s, mais n'êtes pas autorisé⋅e à " "accéder à cette page. Voulez-vous vous reconnecter avec un autre compte ?" -#: tfjm/templates/registration/login.html:25 +#: tfjm/templates/registration/includes/login.html:17 msgid "Your username is your e-mail address." msgstr "Votre identifiant est votre adresse e-mail." -#: tfjm/templates/registration/login.html:28 +#: tfjm/templates/registration/includes/login.html:20 msgid "Forgotten your password?" msgstr "Mot de passe oublié ?" +#: tfjm/templates/registration/logged_out.html:8 +msgid "Thanks for spending some quality time with the Web site today." +msgstr "Merci d'avoir utilisé la plateforme du TFJM²." + +#: tfjm/templates/registration/logged_out.html:9 +msgid "Log in again" +msgstr "Se reconnecter" + #: tfjm/templates/search/search.html:6 tfjm/templates/search/search.html:10 msgid "Search" msgstr "Chercher" @@ -3645,8 +3838,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/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/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/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/settings.py b/tfjm/settings.py index c1b98bb..4c6a0e0 100644 --- a/tfjm/settings.py +++ b/tfjm/settings.py @@ -68,6 +68,7 @@ INSTALLED_APPS = [ 'rest_framework.authtoken', 'api', + 'chat', 'draw', 'registration', 'participation', @@ -101,6 +102,7 @@ MIDDLEWARE = [ ROOT_URLCONF = 'tfjm.urls' LOGIN_REDIRECT_URL = "index" +LOGOUT_REDIRECT_URL = "login" TEMPLATES = [ { diff --git a/tfjm/settings_prod.py b/tfjm/settings_prod.py index d7f48b8..2024aeb 100644 --- a/tfjm/settings_prod.py +++ b/tfjm/settings_prod.py @@ -27,7 +27,7 @@ SESSION_COOKIE_SECURE = False CSRF_COOKIE_SECURE = False CSRF_COOKIE_HTTPONLY = False X_FRAME_OPTIONS = 'DENY' -SESSION_COOKIE_AGE = 60 * 60 * 3 +SESSION_COOKIE_AGE = 60 * 60 * 24 * 7 * 2 # 2 weeks CHANNEL_LAYERS = { "default": { diff --git a/tfjm/static/tfjm-192.png b/tfjm/static/tfjm-192.png new file mode 100644 index 0000000..a212e44 Binary files /dev/null and b/tfjm/static/tfjm-192.png differ diff --git a/tfjm/static/tfjm-512.png b/tfjm/static/tfjm-512.png new file mode 100644 index 0000000..5be044a Binary files /dev/null and b/tfjm/static/tfjm-512.png differ diff --git a/tfjm/static/tfjm-square.svg b/tfjm/static/tfjm-square.svg new file mode 100644 index 0000000..7e76588 --- /dev/null +++ b/tfjm/static/tfjm-square.svg @@ -0,0 +1,91 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + 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 %} diff --git a/tfjm/templates/navbar.html b/tfjm/templates/navbar.html index 8e0115d..e012871 100644 --- a/tfjm/templates/navbar.html +++ b/tfjm/templates/navbar.html @@ -61,12 +61,12 @@ {% endif %} + {% endif %} - {% if user.registration.is_admin %}
  • - - {% trans "Log out" %} - +
    + {% csrf_token %} + +
  • diff --git a/tfjm/templates/registration/includes/login.html b/tfjm/templates/registration/includes/login.html new file mode 100644 index 0000000..1ae3348 --- /dev/null +++ b/tfjm/templates/registration/includes/login.html @@ -0,0 +1,23 @@ +{% load i18n crispy_forms_filters %} + +{% if user.is_authenticated %} +

    + {% blocktrans trimmed %} + You are authenticated as {{ user }}, but are not authorized to + access this page. Would you like to login to a different account? + {% endblocktrans %} +

    +{% endif %} +
    +
    + {{ form|as_crispy_errors }} + {% csrf_token %} + {{ form.username|as_crispy_field }} +
    + {% trans "Your username is your e-mail address." %} +
    + {{ form.password|as_crispy_field }} + {% trans 'Forgotten your password?' %} +
    + +
    \ No newline at end of file diff --git a/tfjm/templates/registration/login.html b/tfjm/templates/registration/login.html index f033a0b..2695aa0 100644 --- a/tfjm/templates/registration/login.html +++ b/tfjm/templates/registration/login.html @@ -2,31 +2,11 @@ {% comment %} SPDX-License-Identifier: GPL-2.0-or-later {% endcomment %} -{% load i18n crispy_forms_filters %} +{% load i18n %} {% block title %}{% trans "Log in" %}{% endblock %} {% block content-title %}

    {% trans "Log in" %}

    {% endblock %} {% block content %} - {% if user.is_authenticated %} -

    - {% blocktrans trimmed %} - You are authenticated as {{ user }}, but are not authorized to - access this page. Would you like to login to a different account? - {% endblocktrans %} -

    - {% endif %} -
    -
    - {{ form|as_crispy_errors }} - {% csrf_token %} - {{ form.username|as_crispy_field }} -
    - {% trans "Your username is your e-mail address." %} -
    - {{ form.password|as_crispy_field }} - {% trans 'Forgotten your password?' %} -
    - -
    + {% include "registration/includes/login.html" %} {% endblock %} diff --git a/tfjm/urls.py b/tfjm/urls.py index 1185dbc..c506456 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')), diff --git a/tfjm/views.py b/tfjm/views.py index 6ccea34..64ae580 100644 --- a/tfjm/views.py +++ b/tfjm/views.py @@ -3,6 +3,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User +from django.views.generic import TemplateView from haystack.generic_views import SearchView @@ -40,5 +41,9 @@ class UserRegistrationMixin(LoginRequiredMixin): return super().dispatch(request, *args, **kwargs) +class LoginRequiredTemplateView(LoginRequiredMixin, TemplateView): + pass + + class AdminSearchView(AdminMixin, SearchView): pass diff --git a/tox.ini b/tox.ini index aa524c4..521eccb 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ deps = coverage commands = python manage.py compilemessages -i .tox -i venv - coverage run --source=api,draw,logs,participation,registration,tfjm ./manage.py test api/ draw/ logs/ participation/ registration/ tfjm/ + coverage run --source=api,draw,logs,participation,registration,tfjm ./manage.py test api/ chat/ draw/ logs/ participation/ registration/ tfjm/ coverage report -m [testenv:linters] @@ -26,7 +26,7 @@ deps = pep8-naming pyflakes commands = - flake8 api/ draw/ logs/ participation/ registration/ tfjm/ + flake8 api/ chat/ draw/ logs/ participation/ registration/ tfjm/ [flake8] exclude =