mirror of
https://gitlab.com/animath/si/plateforme.git
synced 2025-05-16 22:52:46 +00:00
Compare commits
9 Commits
af9dcebca9
...
b820306e2e
Author | SHA1 | Date | |
---|---|---|---|
|
b820306e2e | ||
|
756f2074b3 | ||
|
6385e53425 | ||
|
32b816a4de | ||
|
41ccbcb277 | ||
|
2438bb9bcc | ||
|
ddd2280ae4 | ||
|
4958628e40 | ||
|
731d309305 |
@ -8,8 +8,8 @@ from .models import Channel, Message
|
||||
|
||||
@admin.register(Channel)
|
||||
class ChannelAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'read_access', 'write_access', 'tournament', 'pool', 'team', 'private',)
|
||||
list_filter = ('read_access', 'write_access', 'tournament', 'private',)
|
||||
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', )
|
||||
|
||||
|
@ -2,8 +2,15 @@
|
||||
# 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")
|
||||
|
@ -3,7 +3,6 @@
|
||||
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from django.contrib.auth.models import User
|
||||
from participation.models import Team, Pool, Tournament
|
||||
from registration.models import Registration
|
||||
|
||||
from .models import Channel, Message
|
||||
@ -38,6 +37,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
channels = await Channel.get_accessible_channels(user, 'read')
|
||||
async for channel in channels.all():
|
||||
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
|
||||
await self.channel_layer.group_add(f"user-{user.id}", self.channel_name)
|
||||
|
||||
async def disconnect(self, close_code) -> None:
|
||||
"""
|
||||
@ -51,6 +51,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
channels = await Channel.get_accessible_channels(self.scope['user'], 'read')
|
||||
async for channel in channels.all():
|
||||
await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name)
|
||||
await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name)
|
||||
|
||||
async def receive_json(self, content, **kwargs):
|
||||
"""
|
||||
@ -61,9 +62,15 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
case 'fetch_channels':
|
||||
await self.fetch_channels()
|
||||
case 'send_message':
|
||||
await self.receive_message(content)
|
||||
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 'start_private_chat':
|
||||
await self.start_private_chat(**content)
|
||||
case unknown:
|
||||
print("Unknown message type:", unknown)
|
||||
|
||||
@ -77,19 +84,20 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
'channels': [
|
||||
{
|
||||
'id': channel.id,
|
||||
'name': channel.name,
|
||||
'name': channel.get_visible_name(user),
|
||||
'category': channel.category,
|
||||
'read_access': True,
|
||||
'write_access': await write_channels.acontains(channel),
|
||||
}
|
||||
async for channel in read_channels.all()
|
||||
async for channel in read_channels.prefetch_related('invited').all()
|
||||
]
|
||||
}
|
||||
await self.send_json(message)
|
||||
|
||||
async def receive_message(self, message: dict) -> None:
|
||||
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=message['channel_id'])
|
||||
.aget(id=channel_id)
|
||||
write_channels = await Channel.get_accessible_channels(user, 'write')
|
||||
if not await write_channels.acontains(channel):
|
||||
return
|
||||
@ -97,7 +105,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
message = await Message.objects.acreate(
|
||||
author=user,
|
||||
channel=channel,
|
||||
content=message['content'],
|
||||
content=content,
|
||||
)
|
||||
|
||||
await self.channel_layer.group_send(f'chat-{channel.id}', {
|
||||
@ -105,10 +113,41 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
'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)
|
||||
read_channels = await Channel.get_accessible_channels(self.scope['user'], 'read')
|
||||
@ -125,6 +164,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
{
|
||||
'id': message.id,
|
||||
'timestamp': message.created_at.isoformat(),
|
||||
'author_id': message.author_id,
|
||||
'author': await message.aget_author_name(),
|
||||
'content': message.content,
|
||||
}
|
||||
@ -132,7 +172,59 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
]))
|
||||
})
|
||||
|
||||
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 channel.asave()
|
||||
|
||||
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:
|
||||
print(message)
|
||||
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']})
|
||||
|
0
chat/management/__init__.py
Normal file
0
chat/management/__init__.py
Normal file
0
chat/management/commands/__init__.py
Normal file
0
chat/management/commands/__init__.py
Normal file
143
chat/management/commands/create_chat_channels.py
Normal file
143
chat/management/commands/create_chat_channels.py
Normal 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,
|
||||
),
|
||||
)
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
@ -13,11 +13,24 @@ 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"),
|
||||
@ -78,6 +91,13 @@ class Channel(models.Model):
|
||||
"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))
|
||||
|
||||
@ -90,10 +110,10 @@ class Channel(models.Model):
|
||||
return Channel.objects.filter(**{permission_type: PermissionType.ANONYMOUS})
|
||||
|
||||
qs |= Channel.objects.filter(**{permission_type: PermissionType.AUTHENTICATED})
|
||||
registration = await Registration.objects.aget(user_id=user.id)
|
||||
registration = await Registration.objects.prefetch_related('user').aget(user_id=user.id)
|
||||
|
||||
if registration.is_admin:
|
||||
return Channel.objects.all()
|
||||
return Channel.objects.prefetch_related('invited').exclude(~Q(invited=user) & Q(private=True)).all()
|
||||
|
||||
if registration.is_volunteer:
|
||||
registration = await VolunteerRegistration.objects \
|
||||
@ -140,14 +160,14 @@ class Channel(models.Model):
|
||||
qs |= Channel.objects.filter(Q(team=team),
|
||||
**{permission_type: PermissionType.TEAM_MEMBER})
|
||||
|
||||
qs |= Channel.objects.filter(invited=user)
|
||||
qs |= Channel.objects.filter(invited=user).prefetch_related('invited')
|
||||
|
||||
return qs
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("channel")
|
||||
verbose_name_plural = _("channels")
|
||||
ordering = ('name',)
|
||||
ordering = ('category', 'name',)
|
||||
|
||||
|
||||
class Message(models.Model):
|
||||
|
100
chat/signals.py
Normal file
100
chat/signals.py
Normal 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,
|
||||
),
|
||||
)
|
@ -6,6 +6,7 @@
|
||||
|
||||
const MAX_MESSAGES = 50
|
||||
|
||||
const channel_categories = ['general', 'tournament', 'team', 'private']
|
||||
let channels = {}
|
||||
let messages = {}
|
||||
let selected_channel_id = null
|
||||
@ -62,20 +63,27 @@ function sendMessage() {
|
||||
|
||||
function setChannels(new_channels) {
|
||||
channels = {}
|
||||
let navTab = document.getElementById('nav-channels-tab')
|
||||
navTab.innerHTML = ''
|
||||
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) {
|
||||
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')
|
||||
navItem.id = `tab-channel-${channel['id']}`
|
||||
navItem.setAttribute('data-bs-dismiss', 'offcanvas')
|
||||
navItem.onclick = () => selectChannel(channel['id'])
|
||||
navTab.appendChild(navItem)
|
||||
categoryList.appendChild(navItem)
|
||||
|
||||
let channelButton = document.createElement('button')
|
||||
channelButton.classList.add('nav-link')
|
||||
@ -87,8 +95,13 @@ function setChannels(new_channels) {
|
||||
}
|
||||
|
||||
if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) {
|
||||
if (window.location.hash)
|
||||
selectChannel(window.location.hash.substring(9))
|
||||
if (window.location.hash) {
|
||||
let channel_id = parseInt(window.location.hash.substring(9))
|
||||
if (channels[channel_id])
|
||||
selectChannel(channel_id)
|
||||
else
|
||||
selectChannel(Object.keys(channels)[0])
|
||||
}
|
||||
else
|
||||
selectChannel(Object.keys(channels)[0])
|
||||
}
|
||||
@ -109,6 +122,16 @@ function receiveMessage(message) {
|
||||
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',
|
||||
@ -142,6 +165,22 @@ function receiveFetchedMessages(data) {
|
||||
redrawMessages()
|
||||
}
|
||||
|
||||
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 = ''
|
||||
@ -155,8 +194,12 @@ function redrawMessages() {
|
||||
let newTimestamp = new Date(message['timestamp'])
|
||||
if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) {
|
||||
let messageContentDiv = document.createElement('div')
|
||||
messageContentDiv.innerText = message['content']
|
||||
lastContentDiv.appendChild(messageContentDiv)
|
||||
let messageContentSpan = document.createElement('span')
|
||||
messageContentSpan.innerText = message['content']
|
||||
messageContentDiv.appendChild(messageContentSpan)
|
||||
|
||||
registerMessageContextMenu(message, messageContentSpan)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@ -173,6 +216,8 @@ function redrawMessages() {
|
||||
authorSpan.innerText = message['author']
|
||||
authorDiv.appendChild(authorSpan)
|
||||
|
||||
registerSendPrivateMessageContextMenu(message, authorSpan)
|
||||
|
||||
let dateSpan = document.createElement('span')
|
||||
dateSpan.classList.add('text-muted', 'float-end')
|
||||
dateSpan.innerText = new Date(message['timestamp']).toLocaleString()
|
||||
@ -182,8 +227,12 @@ function redrawMessages() {
|
||||
messageElement.appendChild(contentDiv)
|
||||
|
||||
let messageContentDiv = document.createElement('div')
|
||||
messageContentDiv.innerText = message['content']
|
||||
contentDiv.appendChild(messageContentDiv)
|
||||
let messageContentSpan = document.createElement('span')
|
||||
messageContentSpan.innerText = message['content']
|
||||
messageContentDiv.appendChild(messageContentSpan)
|
||||
|
||||
registerMessageContextMenu(message, messageContentSpan)
|
||||
|
||||
lastMessage = message
|
||||
lastContentDiv = contentDiv
|
||||
@ -196,6 +245,93 @@ function redrawMessages() {
|
||||
fetchMoreButton.classList.remove('d-none')
|
||||
}
|
||||
|
||||
function removeAllPopovers() {
|
||||
for (let popover of document.querySelectorAll('*[aria-describedby*="popover"]')) {
|
||||
let instance = bootstrap.Popover.getInstance(popover)
|
||||
if (instance)
|
||||
instance.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function registerSendPrivateMessageContextMenu(message, element) {
|
||||
element.addEventListener('contextmenu', (menu_event) => {
|
||||
menu_event.preventDefault()
|
||||
removeAllPopovers()
|
||||
const popover = bootstrap.Popover.getOrCreateInstance(element, {
|
||||
'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, element) {
|
||||
element.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(element, {
|
||||
'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 ?")) {
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'delete_message',
|
||||
'message_id': message['id'],
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
let chatContainer = document.getElementById('chat-container')
|
||||
if (!chatContainer.getAttribute('data-fullscreen')) {
|
||||
@ -211,6 +347,8 @@ function toggleFullscreen() {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('click', removeAllPopovers)
|
||||
|
||||
/**
|
||||
* Process the received data from the server.
|
||||
* @param data The received message
|
||||
@ -223,9 +361,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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 'start_private_chat':
|
||||
startPrivateChat(data)
|
||||
break
|
||||
default:
|
||||
console.log(data)
|
||||
console.error('Unknown message type:', data['type'])
|
||||
@ -238,6 +385,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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 => {
|
||||
@ -250,10 +398,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Manage errors
|
||||
socket.addEventListener('close', e => {
|
||||
console.error('Chat socket closed unexpectedly, restarting…')
|
||||
setTimeout(() => setupSocket(2 * nextDelay), nextDelay)
|
||||
setTimeout(() => setupSocket(socketOpen ? 1000 : 2 * nextDelay), nextDelay)
|
||||
})
|
||||
|
||||
socket.addEventListener('open', e => {
|
||||
socketOpen = true
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'fetch_channels',
|
||||
}))
|
||||
|
@ -3,13 +3,30 @@
|
||||
<noscript>
|
||||
{% trans "JavaScript must be enabled on your browser to access chat." %}
|
||||
</noscript>
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="channelSelector" aria-labelledby="offcanvasExampleLabel">
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="channelSelector" aria-labelledby="offcanvasTitle">
|
||||
<div class="offcanvas-header">
|
||||
<h4 class="offcanvas-title" id="offcanvasExampleLabel">{% trans "Chat channels" %}</h4>
|
||||
<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">
|
||||
<ul class="list-group list-group-flush" id="nav-channels-tab"></ul>
|
||||
<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>
|
||||
|
||||
@ -70,4 +87,9 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const USER_ID = {{ request.user.id }}
|
||||
const IS_ADMIN = {{ request.user.registration.is_admin|yesno:"true,false" }}
|
||||
</script>
|
@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: TFJM\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-04-28 11:56+0200\n"
|
||||
"POT-Creation-Date: 2024-04-28 13:08+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,20 +21,40 @@ msgstr ""
|
||||
msgid "API"
|
||||
msgstr "API"
|
||||
|
||||
#: chat/models.py:18 participation/models.py:35 participation/models.py:263
|
||||
#: chat/models.py:17
|
||||
msgid "General channels"
|
||||
msgstr "Canaux généraux"
|
||||
|
||||
#: chat/models.py:18
|
||||
msgid "Tournament channels"
|
||||
msgstr "Canaux de tournois"
|
||||
|
||||
#: chat/models.py:19
|
||||
msgid "Team channels"
|
||||
msgstr "Canaux d'équipes"
|
||||
|
||||
#: chat/models.py:20
|
||||
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:23
|
||||
#: chat/models.py:29
|
||||
msgid "category"
|
||||
msgstr "catégorie"
|
||||
|
||||
#: chat/models.py:36
|
||||
msgid "read permission"
|
||||
msgstr "permission de lecture"
|
||||
|
||||
#: chat/models.py:29
|
||||
#: chat/models.py:42
|
||||
msgid "write permission"
|
||||
msgstr "permission d'écriture"
|
||||
|
||||
#: chat/models.py:39 draw/admin.py:53 draw/admin.py:71 draw/admin.py:88
|
||||
#: 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
|
||||
@ -43,7 +63,7 @@ msgstr "permission d'écriture"
|
||||
msgid "tournament"
|
||||
msgstr "tournoi"
|
||||
|
||||
#: chat/models.py:41
|
||||
#: chat/models.py:54
|
||||
msgid ""
|
||||
"For a permission that concerns a tournament, indicates what is the concerned "
|
||||
"tournament."
|
||||
@ -51,21 +71,21 @@ msgstr ""
|
||||
"Pour une permission qui concerne un tournoi, indique quel est le tournoi "
|
||||
"concerné."
|
||||
|
||||
#: chat/models.py:50 draw/models.py:429 draw/models.py:456
|
||||
#: 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:52
|
||||
#: 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:61 draw/templates/draw/tournament_content.html:277
|
||||
#: 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
|
||||
@ -75,18 +95,18 @@ msgstr ""
|
||||
msgid "team"
|
||||
msgstr "équipe"
|
||||
|
||||
#: chat/models.py:63
|
||||
#: 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:67
|
||||
#: chat/models.py:80
|
||||
msgid "private"
|
||||
msgstr "privé"
|
||||
|
||||
#: chat/models.py:69
|
||||
#: chat/models.py:82
|
||||
msgid ""
|
||||
"If checked, only users who have been explicitly added to the channel will be "
|
||||
"able to access it."
|
||||
@ -94,11 +114,11 @@ msgstr ""
|
||||
"Si sélectionné, seul⋅es les utilisateur⋅rices qui ont été explicitement "
|
||||
"ajouté⋅es au canal pourront y accéder."
|
||||
|
||||
#: chat/models.py:74
|
||||
#: chat/models.py:87
|
||||
msgid "invited users"
|
||||
msgstr "Utilisateur⋅rices invité"
|
||||
|
||||
#: chat/models.py:77
|
||||
#: chat/models.py:90
|
||||
msgid ""
|
||||
"Extra users who have been invited to the channel, in addition to the "
|
||||
"permitted group of the channel."
|
||||
@ -106,40 +126,40 @@ msgstr ""
|
||||
"Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du "
|
||||
"groupe autorisé du canal."
|
||||
|
||||
#: chat/models.py:82
|
||||
#: chat/models.py:95
|
||||
#, python-brace-format
|
||||
msgid "Channel {name}"
|
||||
msgstr "Canal {name}"
|
||||
|
||||
#: chat/models.py:148 chat/models.py:157
|
||||
#: chat/models.py:161 chat/models.py:170
|
||||
msgid "channel"
|
||||
msgstr "canal"
|
||||
|
||||
#: chat/models.py:149
|
||||
#: chat/models.py:162
|
||||
msgid "channels"
|
||||
msgstr "canaux"
|
||||
|
||||
#: chat/models.py:163
|
||||
#: chat/models.py:176
|
||||
msgid "author"
|
||||
msgstr "auteur⋅rice"
|
||||
|
||||
#: chat/models.py:170
|
||||
#: chat/models.py:183
|
||||
msgid "created at"
|
||||
msgstr "créé le"
|
||||
|
||||
#: chat/models.py:175
|
||||
#: chat/models.py:188
|
||||
msgid "updated at"
|
||||
msgstr "modifié le"
|
||||
|
||||
#: chat/models.py:180
|
||||
#: chat/models.py:193
|
||||
msgid "content"
|
||||
msgstr "contenu"
|
||||
|
||||
#: chat/models.py:243
|
||||
#: chat/models.py:256
|
||||
msgid "message"
|
||||
msgstr "message"
|
||||
|
||||
#: chat/models.py:244
|
||||
#: chat/models.py:257
|
||||
msgid "messages"
|
||||
msgstr "messages"
|
||||
|
||||
@ -171,7 +191,7 @@ msgstr "Déconnexion"
|
||||
msgid "Install app on home screen"
|
||||
msgstr "Installer l'application sur l'écran d'accueil"
|
||||
|
||||
#: chat/templates/chat/content.html:54
|
||||
#: chat/templates/chat/content.html:55
|
||||
msgid "Fetch previous messages…"
|
||||
msgstr "Récupérer les messages précédents…"
|
||||
|
||||
|
@ -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": {
|
||||
|
4
tox.ini
4
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 =
|
||||
|
Loading…
x
Reference in New Issue
Block a user