1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-05-17 16:52:48 +00:00

Compare commits

..

5 Commits

Author SHA1 Message Date
Emmy D'Anello
bb137509e1
Properly sort messages and add fetch previous messages ability
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-27 14:12:08 +02:00
Emmy D'Anello
727aa8b6d6
Fetching last messages is working
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-27 13:27:27 +02:00
Emmy D'Anello
ee15ea04d5
Send messages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-27 12:59:50 +02:00
Emmy D'Anello
c20554e01a
Setup chat UI
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-27 12:08:10 +02:00
Emmy D'Anello
4026fe53c3
Allow to impersonate user on draw interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-27 09:55:33 +02:00
8 changed files with 411 additions and 52 deletions

View File

@ -3,9 +3,10 @@
from channels.generic.websocket import AsyncJsonWebsocketConsumer from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.contrib.auth.models import User from django.contrib.auth.models import User
from participation.models import Team, Pool, Tournament
from registration.models import Registration from registration.models import Registration
from .models import Channel from .models import Channel, Message
class ChatConsumer(AsyncJsonWebsocketConsumer): class ChatConsumer(AsyncJsonWebsocketConsumer):
@ -34,6 +35,10 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
# Accept the connection # Accept the connection
await self.accept() await self.accept()
channels = await Channel.get_accessible_channels(user, 'read')
async for channel in channels.all():
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
async def disconnect(self, close_code) -> None: async def disconnect(self, close_code) -> None:
""" """
Called when the websocket got disconnected, for any reason. Called when the websocket got disconnected, for any reason.
@ -43,6 +48,10 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
# User is not authenticated # User is not authenticated
return return
channels = await Channel.get_accessible_channels(self.scope['user'], 'read')
async for channel in channels.all():
await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name)
async def receive_json(self, content, **kwargs): async def receive_json(self, content, **kwargs):
""" """
Called when the client sends us some data, parsed as JSON. Called when the client sends us some data, parsed as JSON.
@ -51,6 +60,10 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
match content['type']: match content['type']:
case 'fetch_channels': case 'fetch_channels':
await self.fetch_channels() await self.fetch_channels()
case 'send_message':
await self.receive_message(content)
case 'fetch_messages':
await self.fetch_messages(**content)
case unknown: case unknown:
print("Unknown message type:", unknown) print("Unknown message type:", unknown)
@ -59,7 +72,6 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
read_channels = await Channel.get_accessible_channels(user, 'read') read_channels = await Channel.get_accessible_channels(user, 'read')
write_channels = await Channel.get_accessible_channels(user, 'write') write_channels = await Channel.get_accessible_channels(user, 'write')
print([channel async for channel in write_channels.all()])
message = { message = {
'type': 'fetch_channels', 'type': 'fetch_channels',
'channels': [ 'channels': [
@ -73,3 +85,54 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
] ]
} }
await self.send_json(message) await self.send_json(message)
async def receive_message(self, message: dict) -> None:
user = self.scope['user']
channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \
.aget(id=message['channel_id'])
write_channels = await Channel.get_accessible_channels(user, 'write')
if not await write_channels.acontains(channel):
return
message = await Message.objects.acreate(
author=user,
channel=channel,
content=message['content'],
)
await self.channel_layer.group_send(f'chat-{channel.id}', {
'type': 'chat.send_message',
'id': message.id,
'channel_id': channel.id,
'timestamp': message.created_at.isoformat(),
'author': await message.aget_author_name(),
'content': message.content,
})
async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None:
channel = await Channel.objects.aget(id=channel_id)
read_channels = await Channel.get_accessible_channels(self.scope['user'], 'read')
if not await read_channels.acontains(channel):
return
limit = min(limit, 200) # Fetch only maximum 200 messages at the time
messages = Message.objects.filter(channel=channel).order_by('-created_at')[offset:offset + limit].all()
await self.send_json({
'type': 'fetch_messages',
'channel_id': channel_id,
'messages': list(reversed([
{
'id': message.id,
'timestamp': message.created_at.isoformat(),
'author': await message.aget_author_name(),
'content': message.content,
}
async for message in messages
]))
})
async def chat_send_message(self, message) -> None:
await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'],
'timestamp': message['timestamp'], 'author': message['author'],
'content': message['content']})

View File

@ -1,12 +1,13 @@
# Copyright (C) 2024 by Animath # Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from asgiref.sync import sync_to_async
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.db.models import Q, QuerySet from django.db.models import Q, QuerySet
from django.utils.text import format_lazy from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from participation.models import Tournament from participation.models import Pool, Team, Tournament
from registration.models import ParticipantRegistration, Registration, VolunteerRegistration from registration.models import ParticipantRegistration, Registration, VolunteerRegistration
from tfjm.permissions import PermissionType from tfjm.permissions import PermissionType
@ -141,9 +142,6 @@ class Channel(models.Model):
qs |= Channel.objects.filter(invited=user) qs |= Channel.objects.filter(invited=user)
print(user)
print(qs.query)
return qs return qs
class Meta: class Meta:
@ -182,6 +180,65 @@ class Message(models.Model):
verbose_name=_("content"), verbose_name=_("content"),
) )
def get_author_name(self):
registration = self.author.registration
author_name = f"{self.author.first_name} {self.author.last_name}"
if registration.is_volunteer:
if registration.is_admin:
author_name += " (CNO)"
if self.channel.pool:
if registration == self.channel.pool.jury_president:
author_name += " (P. jury)"
elif registration in self.channel.pool.juries.all():
author_name += " (Juré⋅e)"
elif registration in self.channel.pool.tournament.organizers.all():
author_name += " (CRO)"
else:
author_name += " (Bénévole)"
elif self.channel.tournament:
if registration in self.channel.tournament.organizers.all():
author_name += " (CRO)"
elif any([registration.id == pool.jury_president
for pool in self.channel.tournament.pools.all()]):
pools = ", ".join([pool.short_name
for pool in self.channel.tournament.pools.all()
if pool.jury_president == registration])
author_name += f" (P. jury {pools})"
elif any([pool.juries.contains(registration)
for pool in self.channel.tournament.pools.all()]):
pools = ", ".join([pool.short_name
for pool in self.channel.tournament.pools.all()
if pool.juries.acontains(registration)])
author_name += f" (Juré⋅e {pools})"
else:
author_name += " (Bénévole)"
else:
if registration.organized_tournaments.exists():
tournaments = ", ".join([tournament.name
for tournament in registration.organized_tournaments.all()])
author_name += f" (CRO {tournaments})"
if Pool.objects.filter(jury_president=registration).exists():
tournaments = Tournament.objects.filter(pools__jury_president=registration).distinct()
tournaments = ", ".join([tournament.name for tournament in tournaments])
author_name += f" (P. jury {tournaments})"
elif registration.jury_in.exists():
tournaments = Tournament.objects.filter(pools__juries=registration).distinct()
tournaments = ", ".join([tournament.name for tournament in tournaments])
author_name += f" (Juré⋅e {tournaments})"
else:
if registration.team_id:
team = Team.objects.get(id=registration.team_id)
author_name += f" ({team.trigram})"
else:
author_name += " (sans équipe)"
return author_name
async def aget_author_name(self):
return await sync_to_async(self.get_author_name)()
class Meta: class Meta:
verbose_name = _("message") verbose_name = _("message")
verbose_name_plural = _("messages") verbose_name_plural = _("messages")

View File

@ -4,6 +4,12 @@
await Notification.requestPermission() await Notification.requestPermission()
})() })()
const MAX_MESSAGES = 50
let channels = {}
let messages = {}
let selected_channel_id = null
/** /**
* Display a new notification with the given title and the given body. * Display a new notification with the given title and the given body.
* @param title The title of the notification * @param title The title of the notification
@ -18,14 +24,167 @@ function showNotification(title, body, timeout = 5000) {
return notif return notif
} }
function selectChannel(channel_id) {
let channel = channels[channel_id]
if (!channel) {
console.error('Channel not found:', channel_id)
return
}
selected_channel_id = channel_id
let channelTitle = document.getElementById('channel-title')
channelTitle.innerText = channel['name']
let messageInput = document.getElementById('input-message')
messageInput.disabled = !channel['write_access']
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 = {}
for (let channel of new_channels) {
channels[channel['id']] = channel
if (!messages[channel['id']])
messages[channel['id']] = new Map()
fetchMessages(channel['id'])
}
if (new_channels && (!selected_channel_id || !channels[selected_channel_id]))
selectChannel(Object.keys(channels)[0])
}
function receiveMessage(message) {
messages[message['channel_id']].push(message)
redrawMessages()
}
function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) {
socket.send(JSON.stringify({
'type': 'fetch_messages',
'channel_id': channel_id,
'offset': offset,
'limit': limit,
}))
}
function fetchPreviousMessages() {
let channel_id = selected_channel_id
let offset = messages[channel_id].size
fetchMessages(channel_id, offset, MAX_MESSAGES)
}
function receiveFetchedMessages(data) {
let channel_id = data['channel_id']
let new_messages = data['messages']
if (!messages[channel_id])
messages[channel_id] = 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 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.innerText = message['content']
lastContentDiv.appendChild(messageContentDiv)
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)
let dateSpan = document.createElement('span')
dateSpan.classList.add('text-muted', 'float-end')
dateSpan.innerText = new Date(message['timestamp']).toLocaleString()
authorDiv.appendChild(dateSpan)
let contentDiv = document.createElement('div')
messageElement.appendChild(contentDiv)
let messageContentDiv = document.createElement('div')
messageContentDiv.innerText = message['content']
contentDiv.appendChild(messageContentDiv)
lastMessage = message
lastContentDiv = contentDiv
}
let fetchMoreButton = document.getElementById('fetch-previous-messages')
if (!messages[selected_channel_id] || messages[selected_channel_id].size % MAX_MESSAGES !== 0)
fetchMoreButton.classList.add('d-none')
else
fetchMoreButton.classList.remove('d-none')
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
/** /**
* Process the received data from the server. * Process the received data from the server.
* @param data The received message * @param data The received message
*/ */
function processMessage(data) { function processMessage(data) {
// TODO Implement chat protocol switch (data['type']) {
case 'fetch_channels':
setChannels(data['channels'])
break
case 'send_message':
receiveMessage(data)
break
case 'fetch_messages':
receiveFetchedMessages(data)
break
default:
console.log(data) console.log(data)
console.error('Unknown message type:', data['type'])
break
}
} }
function setupSocket(nextDelay = 1000) { function setupSocket(nextDelay = 1000) {

View File

@ -4,6 +4,59 @@
{% load i18n %} {% load i18n %}
{% block content %} {% block content %}
<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-header">
<h4 class="offcanvas-title" id="offcanvasExampleLabel">{% trans "Chat channels" %}</h4>
<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">
{% for channel in channels %}
<li class="list-group-item" id="tab-channel-{{ channel.id }}" data-bs-dismiss="offcanvas"
onclick="selectChannel({{ channel.id }})">
<button class="nav-link">{{ channel.name }}</button>
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="card tab-content w-100 mh-100" style="height: 95vh" id="nav-channels-content">
<div class="card-header">
<h3>
<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>
</h3>
</div>
<div class="card-body overflow-y-scroll mw-100 h-100 flex-grow-0" id="chat-messages">
<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>
<ul class="list-group list-group-flush" id="message-list"></ul>
</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" %}" autocomplete="off">
<button class="input-group-text btn btn-success" type="submit">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</form>
</div>
</div>
{% endblock %} {% endblock %}
{% block extrajavascript %} {% block extrajavascript %}

View File

@ -4,6 +4,8 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView from django.views.generic import TemplateView
from chat.models import Channel
class ChatView(LoginRequiredMixin, TemplateView): class ChatView(LoginRequiredMixin, TemplateView):
""" """
@ -11,3 +13,9 @@ class ChatView(LoginRequiredMixin, TemplateView):
with Javascript and websockets. with Javascript and websockets.
""" """
template_name = "chat/chat.html" template_name = "chat/chat.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
from asgiref.sync import async_to_sync
context['channels'] = async_to_sync(Channel.get_accessible_channels)(self.request.user, 'read')
return context

View File

@ -9,6 +9,7 @@ from random import randint, shuffle
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils import translation from django.utils import translation
from django.utils.translation import gettext_lazy as _ 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 We accept only if this is a user of a team of the associated tournament, or a volunteer
of the tournament. 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 # Fetch the registration of the current user
user = self.scope['user'] user = self.scope['user']

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: TFJM\n" "Project-Id-Version: TFJM\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-27 08:46+0200\n" "POT-Creation-Date: 2024-04-27 14:10+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Emmy D'Anello <emmy.danello@animath.fr>\n" "Last-Translator: Emmy D'Anello <emmy.danello@animath.fr>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -21,20 +21,20 @@ msgstr ""
msgid "API" msgid "API"
msgstr "API" msgstr "API"
#: chat/models.py:13 participation/models.py:35 participation/models.py:263 #: chat/models.py:18 participation/models.py:35 participation/models.py:263
#: participation/tables.py:18 participation/tables.py:34 #: participation/tables.py:18 participation/tables.py:34
msgid "name" msgid "name"
msgstr "nom" msgstr "nom"
#: chat/models.py:17 #: chat/models.py:23
msgid "read permission" msgid "read permission"
msgstr "permission de lecture" msgstr "permission de lecture"
#: chat/models.py:22 #: chat/models.py:29
msgid "write permission" msgid "write permission"
msgstr "permission d'écriture" msgstr "permission d'écriture"
#: chat/models.py:32 draw/admin.py:53 draw/admin.py:71 draw/admin.py:88 #: chat/models.py:39 draw/admin.py:53 draw/admin.py:71 draw/admin.py:88
#: draw/models.py:26 participation/admin.py:79 participation/admin.py:140 #: draw/models.py:26 participation/admin.py:79 participation/admin.py:140
#: participation/admin.py:171 participation/models.py:693 #: participation/admin.py:171 participation/models.py:693
#: participation/models.py:717 participation/models.py:935 #: participation/models.py:717 participation/models.py:935
@ -43,7 +43,7 @@ msgstr "permission d'écriture"
msgid "tournament" msgid "tournament"
msgstr "tournoi" msgstr "tournoi"
#: chat/models.py:34 #: chat/models.py:41
msgid "" msgid ""
"For a permission that concerns a tournament, indicates what is the concerned " "For a permission that concerns a tournament, indicates what is the concerned "
"tournament." "tournament."
@ -51,21 +51,21 @@ msgstr ""
"Pour une permission qui concerne un tournoi, indique quel est le tournoi " "Pour une permission qui concerne un tournoi, indique quel est le tournoi "
"concerné." "concerné."
#: chat/models.py:43 draw/models.py:429 draw/models.py:456 #: chat/models.py:50 draw/models.py:429 draw/models.py:456
#: participation/admin.py:136 participation/admin.py:155 #: participation/admin.py:136 participation/admin.py:155
#: participation/models.py:1434 participation/models.py:1443 #: participation/models.py:1434 participation/models.py:1443
#: participation/tables.py:84 #: participation/tables.py:84
msgid "pool" msgid "pool"
msgstr "poule" msgstr "poule"
#: chat/models.py:45 #: chat/models.py:52
msgid "" msgid ""
"For a permission that concerns a pool, indicates what is the concerned pool." "For a permission that concerns a pool, indicates what is the concerned pool."
msgstr "" msgstr ""
"Pour une permission qui concerne une poule, indique quelle est la poule " "Pour une permission qui concerne une poule, indique quelle est la poule "
"concernée." "concernée."
#: chat/models.py:54 draw/templates/draw/tournament_content.html:277 #: chat/models.py:61 draw/templates/draw/tournament_content.html:277
#: participation/admin.py:167 participation/models.py:252 #: participation/admin.py:167 participation/models.py:252
#: participation/models.py:708 #: participation/models.py:708
#: participation/templates/participation/tournament_harmonize.html:15 #: participation/templates/participation/tournament_harmonize.html:15
@ -75,18 +75,18 @@ msgstr ""
msgid "team" msgid "team"
msgstr "équipe" msgstr "équipe"
#: chat/models.py:56 #: chat/models.py:63
msgid "" msgid ""
"For a permission that concerns a team, indicates what is the concerned team." "For a permission that concerns a team, indicates what is the concerned team."
msgstr "" msgstr ""
"Pour une permission qui concerne une équipe, indique quelle est l'équipe " "Pour une permission qui concerne une équipe, indique quelle est l'équipe "
"concernée." "concernée."
#: chat/models.py:60 #: chat/models.py:67
msgid "private" msgid "private"
msgstr "privé" msgstr "privé"
#: chat/models.py:62 #: chat/models.py:69
msgid "" msgid ""
"If checked, only users who have been explicitly added to the channel will be " "If checked, only users who have been explicitly added to the channel will be "
"able to access it." "able to access it."
@ -94,11 +94,11 @@ msgstr ""
"Si sélectionné, seul⋅es les utilisateur⋅rices qui ont été explicitement " "Si sélectionné, seul⋅es les utilisateur⋅rices qui ont été explicitement "
"ajouté⋅es au canal pourront y accéder." "ajouté⋅es au canal pourront y accéder."
#: chat/models.py:67 #: chat/models.py:74
msgid "invited users" msgid "invited users"
msgstr "Utilisateur⋅rices invité" msgstr "Utilisateur⋅rices invité"
#: chat/models.py:70 #: chat/models.py:77
msgid "" msgid ""
"Extra users who have been invited to the channel, in addition to the " "Extra users who have been invited to the channel, in addition to the "
"permitted group of the channel." "permitted group of the channel."
@ -106,43 +106,59 @@ msgstr ""
"Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du " "Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du "
"groupe autorisé du canal." "groupe autorisé du canal."
#: chat/models.py:75 #: chat/models.py:82
#, python-brace-format #, python-brace-format
msgid "Channel {name}" msgid "Channel {name}"
msgstr "Canal {name}" msgstr "Canal {name}"
#: chat/models.py:78 chat/models.py:87 #: chat/models.py:148 chat/models.py:157
msgid "channel" msgid "channel"
msgstr "canal" msgstr "canal"
#: chat/models.py:79 #: chat/models.py:149
msgid "channels" msgid "channels"
msgstr "canaux" msgstr "canaux"
#: chat/models.py:93 #: chat/models.py:163
msgid "author" msgid "author"
msgstr "auteur⋅rice" msgstr "auteur⋅rice"
#: chat/models.py:100 #: chat/models.py:170
msgid "created at" msgid "created at"
msgstr "créé le" msgstr "créé le"
#: chat/models.py:105 #: chat/models.py:175
msgid "updated at" msgid "updated at"
msgstr "modifié le" msgstr "modifié le"
#: chat/models.py:110 #: chat/models.py:180
msgid "content" msgid "content"
msgstr "contenu" msgstr "contenu"
#: chat/models.py:114 #: chat/models.py:243
msgid "message" msgid "message"
msgstr "message" msgstr "message"
#: chat/models.py:115 #: chat/models.py:244
msgid "messages" msgid "messages"
msgstr "messages" msgstr "messages"
#: chat/templates/chat/chat.html:8
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/chat.html:12
msgid "Chat channels"
msgstr "Canaux de chat"
#: chat/templates/chat/chat.html:40
msgid "Fetch previous messages…"
msgstr "Récupérer les messages précédents…"
#: chat/templates/chat/chat.html:52
msgid "Send message…"
msgstr "Envoyer un message…"
#: draw/admin.py:39 draw/admin.py:57 draw/admin.py:75 #: draw/admin.py:39 draw/admin.py:57 draw/admin.py:75
#: participation/admin.py:109 participation/models.py:253 #: participation/admin.py:109 participation/models.py:253
#: participation/tables.py:88 #: participation/tables.py:88
@ -158,68 +174,68 @@ msgstr "tour"
msgid "Draw" msgid "Draw"
msgstr "Tirage au sort" msgstr "Tirage au sort"
#: draw/consumers.py:30 #: draw/consumers.py:31
msgid "You are not an organizer." msgid "You are not an organizer."
msgstr "Vous n'êtes pas un⋅e organisateur⋅rice." msgstr "Vous n'êtes pas un⋅e organisateur⋅rice."
#: draw/consumers.py:162 #: draw/consumers.py:165
msgid "The draw is already started." msgid "The draw is already started."
msgstr "Le tirage a déjà commencé." msgstr "Le tirage a déjà commencé."
#: draw/consumers.py:168 #: draw/consumers.py:171
msgid "Invalid format" msgid "Invalid format"
msgstr "Format invalide" msgstr "Format invalide"
#: draw/consumers.py:173 #: draw/consumers.py:176
#, python-brace-format #, python-brace-format
msgid "The sum must be equal to the number of teams: expected {len}, got {sum}" msgid "The sum must be equal to the number of teams: expected {len}, got {sum}"
msgstr "" msgstr ""
"La somme doit être égale au nombre d'équipes : attendu {len}, obtenu {sum}" "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." 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." 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!" msgid "Draw started!"
msgstr "Le tirage a commencé !" msgstr "Le tirage a commencé !"
#: draw/consumers.py:240 #: draw/consumers.py:243
#, python-brace-format #, python-brace-format
msgid "The draw for the tournament {tournament} will start." msgid "The draw for the tournament {tournament} will start."
msgstr "Le tirage au sort du tournoi {tournament} va commencer." 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:254 draw/consumers.py:280 draw/consumers.py:690
#: draw/consumers.py:904 draw/consumers.py:993 draw/consumers.py:1015 #: draw/consumers.py:907 draw/consumers.py:996 draw/consumers.py:1018
#: draw/consumers.py:1106 draw/templates/draw/tournament_content.html:5 #: draw/consumers.py:1109 draw/templates/draw/tournament_content.html:5
msgid "The draw has not started yet." msgid "The draw has not started yet."
msgstr "Le tirage au sort n'a pas encore commencé." msgstr "Le tirage au sort n'a pas encore commencé."
#: draw/consumers.py:264 #: draw/consumers.py:267
#, python-brace-format #, python-brace-format
msgid "The draw for the tournament {tournament} is aborted." msgid "The draw for the tournament {tournament} is aborted."
msgstr "Le tirage au sort du tournoi {tournament} est annulé." 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:307 draw/consumers.py:328 draw/consumers.py:624
#: draw/consumers.py:692 draw/consumers.py:909 #: draw/consumers.py:695 draw/consumers.py:912
msgid "This is not the time for this." msgid "This is not the time for this."
msgstr "Ce n'est pas le moment pour cela." 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." msgid "You've already launched the dice."
msgstr "Vous avez déjà lancé le dé." msgstr "Vous avez déjà lancé le dé."
#: draw/consumers.py:323 #: draw/consumers.py:326
msgid "It is not your turn." msgid "It is not your turn."
msgstr "Ce n'est pas votre tour." msgstr "Ce n'est pas votre tour."
#: draw/consumers.py:410 #: draw/consumers.py:413
#, python-brace-format #, python-brace-format
msgid "Dices from teams {teams} are identical. Please relaunch your dices." msgid "Dices from teams {teams} are identical. Please relaunch your dices."
msgstr "" msgstr ""
"Les dés des équipes {teams} sont identiques. Merci de relancer vos dés." "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." msgid "This is only available for the final tournament."
msgstr "Cela n'est possible que pour la finale." msgstr "Cela n'est possible que pour la finale."

View File

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