Merge branch 'chat' into 'dev'

Ajout d'un chat intégré

See merge request animath/si/plateforme-tfjm!45
This commit is contained in:
Emmy D'Anello 2024-04-28 22:39:42 +00:00
commit 2e3f2c244d
43 changed files with 2368 additions and 155 deletions

2
chat/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

22
chat/admin.py Normal file
View File

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

16
chat/apps.py Normal file
View File

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

251
chat/consumers.py Normal file
View File

@ -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']})

View File

View File

View File

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

View File

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

View File

@ -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",
),
),
]

View File

@ -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",
),
),
]

View File

@ -0,0 +1,2 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

273
chat/models.py Normal file
View File

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

100
chat/signals.py Normal file
View File

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

576
chat/static/chat.js Normal file
View File

@ -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': `<a id="send-private-message-link-${message['id']}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`,
'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 = `<a id="send-private-message-link-msg-${message['id']}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`
let has_right_to_edit = message['author_id'] === USER_ID || IS_ADMIN
if (has_right_to_edit) {
content += `<hr class="my-1">`
content += `<a id="edit-message-${message['id']}" class="nav-link" href="#" tabindex="0">Modifier</a>`
content += `<a id="delete-message-${message['id']}" class="nav-link" href="#" tabindex="0">Supprimer</a>`
}
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()
})

View File

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

View File

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block extracss %}
<link rel="manifest" href="{% static "chat.webmanifest" %}">
{% endblock %}
{% block content-title %}{% endblock %}
{% block content %}
{% include "chat/content.html" %}
{% endblock %}
{% block extrajavascript %}
{# This script contains all data for the chat management #}
<script src="{% static 'chat.js' %}"></script>
{% endblock %}

View File

@ -0,0 +1,99 @@
{% load i18n %}
<noscript>
{% trans "JavaScript must be enabled on your browser to access chat." %}
</noscript>
<div class="offcanvas offcanvas-start" tabindex="-1" id="channelSelector" aria-labelledby="offcanvasTitle">
<div class="offcanvas-header">
<h3 class="offcanvas-title" id="offcanvasTitle">{% trans "Chat channels" %}</h3>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<div class="form-switch form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="sort-by-unread-switch">
<label class="form-check-label" for="sort-by-unread-switch">{% trans "Sort by unread messages" %}</label>
</div>
<ul class="list-group list-group-flush" id="nav-channels-tab">
<li class="list-group-item d-none">
<h4>{% trans "General channels" %}</h4>
<ul class="list-group list-group-flush" id="nav-general-channels-tab"></ul>
</li>
<li class="list-group-item d-none">
<h4>{% trans "Tournament channels" %}</h4>
<ul class="list-group list-group-flush" id="nav-tournament-channels-tab"></ul>
</li>
<li class="list-group-item d-none">
<h4>{% trans "Team channels" %}</h4>
<ul class="list-group list-group-flush" id="nav-team-channels-tab"></ul>
</li>
<li class="list-group-item d-none">
<h4>{% trans "Private channels" %}</h4>
<ul class="list-group list-group-flush" id="nav-private-channels-tab"></ul>
</li>
</ul>
</div>
</div>
<div class="alert alert-info d-none" id="alert-download-chat-app">
{% trans "You can install a shortcut to the chat on your home screen using the download button on the header." %}
</div>
<div class="card tab-content w-100 mh-100{% if request.GET.fullscreen == '1' or fullscreen %} position-absolute top-0 start-0 vh-100 z-3{% endif %}"
style="height: 95vh" id="chat-container">
<div class="card-header">
<h3>
{% if fullscreen %}
{# Logout button must be present in a form. The form must includes the whole line. #}
<form action="{% url 'chat:logout' %}" method="post">
{% csrf_token %}
{% endif %}
<button class="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#channelSelector"
aria-controls="channelSelector" aria-expanded="false" aria-label="Toggle channel selector">
<span class="navbar-toggler-icon"></span>
</button>
<span id="channel-title"></span>
{% if not fullscreen %}
<button class="btn float-end" type="button" onclick="toggleFullscreen()" title="{% trans "Toggle fullscreen mode" %}">
<i class="fas fa-expand"></i>
</button>
{% else %}
<button class="btn float-end" title="{% trans "Log out" %}">
<i class="fas fa-sign-out-alt"></i>
</button>
{% endif %}
<button class="btn float-end d-none" type="button" id="install-app-home-screen" title="{% trans "Install app on home screen" %}">
<i class="fas fa-download"></i>
</button>
{% if fullscreen %}
</form>
{% endif %}
</h3>
</div>
<div class="card-body d-flex flex-column-reverse flex-grow-0 overflow-y-scroll" id="chat-messages">
<ul class="list-group list-group-flush" id="message-list"></ul>
<div class="text-center d-none" id="fetch-previous-messages">
<a href="#" class="nav-link" onclick="event.preventDefault(); fetchPreviousMessages()">
{% trans "Fetch previous messages…" %}
</a>
<hr>
</div>
</div>
<div class="card-footer mt-auto">
<form onsubmit="event.preventDefault(); sendMessage()">
<div class="input-group">
<label for="input-message" class="input-group-text">
<i class="fas fa-comment"></i>
</label>
<input type="text" class="form-control" id="input-message" placeholder="{% trans "Send message" %}" autofocus autocomplete="off">
<button class="input-group-text btn btn-success" type="submit">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</form>
</div>
</div>
<script>
const USER_ID = {{ request.user.id }}
const IS_ADMIN = {{ request.user.registration.is_admin|yesno:"true,false" }}
</script>

View File

@ -0,0 +1,34 @@
{% load i18n static %}
<!DOCTYPE html>
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
<html lang="{{ LANGUAGE_CODE|default:"fr" }}" {% if LANGUAGE_BIDI %}dir="rtl"{% endif %}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>
Chat du TFJM²
</title>
<meta name="description" content="Chat du TFJM²">
{# Favicon #}
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
<meta name="theme-color" content="#ffffff">
{# Bootstrap CSS #}
<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}">
{# Bootstrap JavaScript #}
<script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
<link rel="manifest" href="{% static "chat.webmanifest" %}">
</head>
<body class="d-flex w-100 h-100 flex-column">
{% include "chat/content.html" with fullscreen=True %}
<script src="{% static 'theme.js' %}"></script>
<script src="{% static 'chat.js' %}"></script>
</body>
</html>

View File

@ -0,0 +1,36 @@
{% load i18n static %}
<!DOCTYPE html>
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
<html lang="{{ LANGUAGE_CODE|default:"fr" }}" {% if LANGUAGE_BIDI %}dir="rtl"{% endif %}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>
Chat du TFJM² - {% trans "Log in" %}
</title>
<meta name="description" content="Chat du TFJM²">
{# Favicon #}
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
<meta name="theme-color" content="#ffffff">
{# Bootstrap CSS #}
<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}">
{# Bootstrap JavaScript #}
<script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
<link rel="manifest" href="{% static "chat.webmanifest" %}">
</head>
<body class="d-flex w-100 h-100 flex-column">
<div class="container">
<h1>{% trans "Log in" %}</h1>
{% include "registration/includes/login.html" %}
</div>
<script src="{% static 'theme.js' %}"></script>
</body>
</html>

2
chat/tests.py Normal file
View File

@ -0,0 +1,2 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

18
chat/urls.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <emmy.danello@animath.fr>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 ?"

View File

@ -1,13 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="alert alert-warning">
{% 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 %}
</div>
{% endblock %}

View File

@ -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/<int:pk>/update/", PassageUpdateView.as_view(), name="passage_update"),
path("pools/passages/<int:pk>/solution/", SynthesisUploadView.as_view(), name="upload_synthesis"),
path("pools/passages/notes/<int:pk>/", NoteUpdateView.as_view(), name="update_notes"),
path("chat/", TemplateView.as_view(template_name="participation/chat.html"), name="chat")
]

View File

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

19
tfjm/permissions.py Normal file
View File

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

11
tfjm/routing.py Normal file
View File

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

View File

@ -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 = [
{

View File

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

BIN
tfjm/static/tfjm-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
tfjm/static/tfjm-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 32 32"
version="1.1"
id="svg27"
sodipodi:docname="logo.svg"
width="32"
height="32"
inkscape:version="0.92.2 2405546, 2018-03-11">
<style>
path {
fill: black;
}
</style>
<metadata
id="metadata31">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs15">
<path
id="b"
d="m 2.58,-3.347 c 0.409,0 1.405,0.02 1.485,1.135 0.01,0.12 0.02,0.25 0.18,0.25 0.168,0 0.168,-0.14 0.168,-0.32 v -2.7 c 0,-0.159 0,-0.318 -0.169,-0.318 -0.13,0 -0.17,0.1 -0.18,0.21 -0.059,1.155 -0.756,1.354 -1.484,1.384 v -2.102 c 0,-0.668 0.19,-0.668 0.429,-0.668 h 0.468 c 1.275,0 1.923,0.688 1.983,1.375 0.01,0.08 0.02,0.23 0.179,0.23 0.17,0 0.17,-0.16 0.17,-0.33 v -1.295 c 0,-0.308 -0.02,-0.328 -0.33,-0.328 h -5 c -0.18,0 -0.34,0 -0.34,0.179 0,0.17 0.19,0.17 0.27,0.17 0.567,0 0.607,0.079 0.607,0.567 v 4.991 c 0,0.469 -0.03,0.568 -0.558,0.568 -0.15,0 -0.319,0 -0.319,0.17 C 0.14,0 0.3,0 0.48,0 h 2.878 c 0.18,0 0.33,0 0.33,-0.18 0,-0.169 -0.17,-0.169 -0.3,-0.169 -0.767,0 -0.807,-0.07 -0.807,-0.597 v -2.401 z m 2.88,-3.129 v 0.469 A 2.557,2.557 0 0 0 4.922,-6.476 Z M 4.065,-3.158 A 1.51,1.51 0 0 0 3.537,-3.547 c 0.189,-0.09 0.388,-0.249 0.528,-0.418 z m -2.7,-2.77 c 0,-0.12 0,-0.368 -0.08,-0.548 h 1.056 c -0.11,0.23 -0.11,0.558 -0.11,0.648 v 4.901 c 0,0.15 0,0.389 0.1,0.578 H 1.285 c 0.08,-0.179 0.08,-0.428 0.08,-0.548 v -5.03 z" />
<path
id="c"
d="m 1.564,-6.824 c -0.18,0 -0.339,0 -0.339,0.179 0,0.17 0.18,0.17 0.29,0.17 0.687,0 0.727,0.069 0.727,0.577 v 5.59 c 0,0.169 0,0.358 -0.17,0.527 -0.08,0.07 -0.239,0.18 -0.478,0.18 -0.07,0 -0.369,0 -0.369,-0.11 0,-0.08 0.04,-0.12 0.09,-0.17 A 0.704,0.704 0 0 0 0.777,-1.057 0.704,0.704 0 0 0 0.06,-0.359 c 0,0.629 0.637,1.106 1.604,1.106 1.106,0 2.042,-0.387 2.192,-1.614 0.01,-0.09 0.01,-0.647 0.01,-0.966 v -4.184 c 0,-0.449 0.139,-0.449 0.707,-0.459 0.09,0 0.17,-0.08 0.17,-0.17 0,-0.178 -0.15,-0.178 -0.33,-0.178 z M 0.867,0.239 C 0.767,0.19 0.408,0.02 0.408,-0.349 c 0,-0.259 0.22,-0.358 0.37,-0.358 0.168,0 0.368,0.12 0.368,0.348 0,0.15 -0.08,0.24 -0.12,0.27 -0.04,0.04 -0.13,0.139 -0.16,0.328 z M 2.59,-5.918 c 0,-0.11 0,-0.378 -0.09,-0.558 h 1.097 c -0.08,0.18 -0.08,0.369 -0.08,0.708 v 4.015 c 0,0.298 0,0.797 -0.01,0.896 C 3.427,-0.349 3.198,0.11 2.44,0.31 2.59,0.08 2.59,-0.109 2.59,-0.288 v -5.629 z" />
<path
id="d"
d="M 4.643,-2.092 2.74,-6.625 c -0.08,-0.2 -0.09,-0.2 -0.359,-0.2 H 0.528 c -0.18,0 -0.329,0 -0.329,0.18 0,0.17 0.18,0.17 0.23,0.17 0.119,0 0.388,0.02 0.607,0.099 v 5.32 c 0,0.21 0,0.648 -0.677,0.707 -0.19,0.02 -0.19,0.16 -0.19,0.17 C 0.17,0 0.33,0 0.51,0 h 1.543 c 0.18,0 0.33,0 0.33,-0.18 0,-0.089 -0.08,-0.159 -0.16,-0.169 -0.767,-0.06 -0.767,-0.478 -0.767,-0.707 v -4.961 l 0.01,-0.01 2.429,5.817 c 0.08,0.18 0.15,0.209 0.21,0.209 0.12,0 0.149,-0.08 0.199,-0.2 l 2.44,-5.827 0.01,0.01 v 4.961 c 0,0.21 0,0.648 -0.677,0.707 -0.19,0.02 -0.19,0.16 -0.19,0.17 0,0.179 0.16,0.179 0.34,0.179 h 2.66 c 0.179,0 0.328,0 0.328,-0.18 C 9.215,-0.27 9.135,-0.34 9.056,-0.35 8.289,-0.41 8.289,-0.828 8.289,-1.057 v -4.712 c 0,-0.21 0,-0.648 0.677,-0.708 0.1,-0.01 0.19,-0.06 0.19,-0.17 0,-0.178 -0.15,-0.178 -0.33,-0.178 H 6.905 c -0.259,0 -0.279,0 -0.369,0.209 z m -0.3,0.18 c 0.08,0.169 0.09,0.178 0.21,0.218 L 4.115,-0.638 H 4.095 L 1.823,-6.058 C 1.773,-6.187 1.693,-6.356 1.554,-6.476 h 0.867 l 1.923,4.563 z M 1.336,-0.35 h -0.17 c 0.02,-0.03 0.04,-0.06 0.06,-0.08 0.01,-0.01 0.01,-0.02 0.02,-0.03 z M 7.104,-6.477 H 8.16 c -0.219,0.25 -0.219,0.508 -0.219,0.688 v 4.752 c 0,0.18 0,0.438 0.23,0.687 H 6.883 c 0.22,-0.249 0.22,-0.508 0.22,-0.687 v -5.44 z" />
<path
id="a"
d="m 4.135,-6.466 c 1.305,0.07 1.793,0.917 1.833,1.385 0.01,0.15 0.02,0.299 0.179,0.299 0.18,0 0.18,-0.17 0.18,-0.359 v -1.325 c 0,-0.348 -0.04,-0.358 -0.34,-0.358 H 0.658 c -0.308,0 -0.328,0.02 -0.328,0.318 V -5.1 c 0,0.16 0,0.319 0.17,0.319 0.17,0 0.178,-0.18 0.178,-0.2 0.04,-0.826 0.788,-1.424 1.834,-1.484 v 5.54 c 0,0.498 -0.04,0.577 -0.668,0.577 -0.12,0 -0.299,0 -0.299,0.17 0,0.179 0.16,0.179 0.339,0.179 h 2.89 C 4.95,0 5.1,0 5.1,-0.18 c 0,-0.169 -0.17,-0.169 -0.28,-0.169 -0.647,0 -0.686,-0.07 -0.686,-0.578 v -5.539 z m -3.458,-0.01 h 0.598 c -0.249,0.15 -0.458,0.349 -0.598,0.518 z m 5.3,0 v 0.528 A 2.606,2.606 0 0 0 5.37,-6.476 H 5.978 Z M 2.77,-0.349 c 0.09,-0.179 0.09,-0.428 0.09,-0.558 v -5.569 h 0.926 v 5.57 c 0,0.129 0,0.378 0.09,0.557 H 2.77 Z" />
<path
id="e"
d="M 3.522,-1.27 H 3.285 c -0.021,0.154 -0.091,0.566 -0.182,0.635 -0.055,0.042 -0.592,0.042 -0.69,0.042 H 1.13 c 0.732,-0.648 0.976,-0.844 1.395,-1.171 0.516,-0.412 0.997,-0.844 0.997,-1.507 0,-0.844 -0.74,-1.36 -1.632,-1.36 -0.865,0 -1.45,0.607 -1.45,1.249 0,0.355 0.3,0.39 0.369,0.39 0.167,0 0.37,-0.118 0.37,-0.37 0,-0.125 -0.05,-0.369 -0.412,-0.369 0.216,-0.495 0.69,-0.649 1.018,-0.649 0.698,0 1.06,0.544 1.06,1.11 0,0.606 -0.432,1.087 -0.655,1.338 l -1.68,1.66 C 0.44,-0.209 0.44,-0.195 0.44,0 h 2.873 z" />
</defs>
<rect width="100%" height="100%"
rx="10px" ry="10px" stroke-linejoin="round"
style="fill: white;" />
<use
x="0.5"
y="19.5"
xlink:href="#a"
id="use17"
width="100%"
height="100%" />
<use
x="7.5"
y="19.5"
xlink:href="#b"
id="use19"
width="100%"
height="100%" />
<use
x="13"
y="19.5"
xlink:href="#c"
id="use21"
width="100%"
height="100%" />
<use
x="18"
y="19.5"
xlink:href="#d"
id="use23"
width="100%"
height="100%" />
<use
x="27"
y="14"
xlink:href="#e"
id="use25"
width="100%"
height="100%" />
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -40,18 +40,18 @@
<body class="d-flex w-100 h-100 flex-column">
{% include "navbar.html" %}
<div id="body-wrapper" class="row w-100 my-3">
<div id="body-wrapper" class="row w-100 my-3 flex-grow-1">
<aside class="col-lg-2 px-2">
{% include "sidebar.html" %}
</aside>
<main class="col d-flex flex-column">
<div class="container">
<main class="col d-flex flex-column flex-grow-1">
<div class="container d-flex flex-column flex-grow-1">
{% block content-title %}<h1 id="content-title">{{ title }}</h1>{% endblock %}
{% include "messages.html" %}
<div id="content">
<div id="content" class="d-flex flex-column flex-grow-1">
{% block content %}
<p>Default content...</p>
{% endblock content %}

View File

@ -61,12 +61,12 @@
</a>
</li>
{% endif %}
{% endif %}
<li class="nav-item active d-none">
<a class="nav-link" href="{% url "participation:chat" %}">
<li class="nav-item active">
<a class="nav-link" href="{% url "chat:chat" %}">
<i class="fas fa-comments"></i> {% trans "Chat" %}
</a>
</li>
{% endif %}
{% if user.registration.is_admin %}
<li class="nav-item active">
<a class="nav-link" href="{% url "admin:index" %}"><i class="fas fa-cog"></i> {% trans "Administration" %}</a>
@ -111,9 +111,12 @@
</a>
</li>
<li>
<a class="dropdown-item" href="{% url "logout" %}">
<form action="{% url 'logout' %}" method="post">
{% csrf_token %}
<button class="dropdown-item">
<i class="fas fa-sign-out-alt"></i> {% trans "Log out" %}
</a>
</button>
</form>
</li>
</ul>
</li>

View File

@ -0,0 +1,23 @@
{% load i18n crispy_forms_filters %}
{% if user.is_authenticated %}
<p class="errornote">
{% 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 %}
</p>
{% endif %}
<form method="post" id="login-form">
<div id="form-content">
{{ form|as_crispy_errors }}
{% csrf_token %}
{{ form.username|as_crispy_field }}
<div class="form-text mb-3">
<i class="fas fa-info-circle"></i> {% trans "Your username is your e-mail address." %}
</div>
{{ form.password|as_crispy_field }}
<a href="{% url 'password_reset' %}" class="badge text-bg-warning">{% trans 'Forgotten your password?' %}</a>
</div>
<input type="submit" value="{% trans 'Log in' %}" class="btn btn-primary">
</form>

View File

@ -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 %}<h1>{% trans "Log in" %}</h1>{% endblock %}
{% block content %}
{% if user.is_authenticated %}
<p class="errornote">
{% 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 %}
</p>
{% endif %}
<form method="post" id="login-form">
<div id="form-content">
{{ form|as_crispy_errors }}
{% csrf_token %}
{{ form.username|as_crispy_field }}
<div class="form-text mb-3">
<i class="fas fa-info-circle"></i> {% trans "Your username is your e-mail address." %}
</div>
{{ form.password|as_crispy_field }}
<a href="{% url 'password_reset' %}" class="badge text-bg-warning">{% trans 'Forgotten your password?' %}</a>
</div>
<input type="submit" value="{% trans 'Log in' %}" class="btn btn-primary">
</form>
{% include "registration/includes/login.html" %}
{% endblock %}

View File

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

View File

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

View File

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