Compare commits
228 Commits
21ff044044
...
0129e32643
Author | SHA1 | Date | |
---|---|---|---|
|
0129e32643 | ||
|
64a2ea007e | ||
|
531eecf4b8 | ||
|
bd416318ac | ||
|
90bec6bf5e | ||
|
ed5944e044 | ||
|
a41c17576f | ||
|
80456f4da8 | ||
|
1a641cb2d7 | ||
|
8f3929875f | ||
|
f26f102650 | ||
|
1e5d0ebcfc | ||
|
0cab21f344 | ||
|
a771710094 | ||
|
3b3dcff28b | ||
|
d6aa5eb0cc | ||
|
c6b9a84def | ||
|
675f19492c | ||
|
a5c210e9b6 | ||
|
784002c085 | ||
|
e77cc558de | ||
|
7bb0f78f34 | ||
|
bfd1a76a2d | ||
|
b86dfe7351 | ||
|
d36e97fa2e | ||
|
181bb86e49 | ||
|
a121d1042b | ||
|
2d706b2b81 | ||
|
ca91842c2d | ||
|
d617dd77c1 | ||
|
d59bb75dce | ||
|
4a78e80399 | ||
|
f3a4a99b78 | ||
|
46fc5f39c8 | ||
|
b464e7df1d | ||
|
7498677bbd | ||
|
ea8007aa07 | ||
|
d9bb0a0860 | ||
|
a594b268ea | ||
|
0bc5ef0a7f | ||
|
943276ef71 | ||
|
13c815c62c | ||
|
35e3be8af3 | ||
|
720de380d1 | ||
|
ecf80f8b81 | ||
|
3ca0148934 | ||
|
58608ea5ff | ||
|
68da61a33b | ||
|
86e978faf2 | ||
|
0845d0bfb6 | ||
|
f457a2355e | ||
|
bacdd5cfcf | ||
|
3e24e10780 | ||
|
adc4634f3e | ||
|
266afaf5c9 | ||
|
059cae75c5 | ||
|
91a1837c99 | ||
|
b24201c529 | ||
|
53302db56a | ||
|
49fda3df49 | ||
|
3a0a98a331 | ||
|
21c4d5d7f5 | ||
|
338a19ec32 | ||
|
5bfcaab831 | ||
|
49e5d97ec9 | ||
|
0e185f5046 | ||
|
ab7cdd56cc | ||
|
7edd43f626 | ||
|
aca23eaf8b | ||
|
a02697a3a7 | ||
|
d3d72e090c | ||
|
6c76f1e633 | ||
|
4a094002f0 | ||
|
3045857897 | ||
|
7a0b93b151 | ||
|
7073f64aa6 | ||
|
b4fc976197 | ||
|
7a004596ca | ||
|
1493df0078 | ||
|
7732a737bb | ||
|
b942baea17 | ||
|
188b83ce2d | ||
|
29d9432ca2 | ||
|
0181a1392d | ||
|
ec0419a6d7 | ||
|
54016a1fbf | ||
|
7ae015cef9 | ||
|
ea264fbca6 | ||
|
758f714096 | ||
|
40d24740ed | ||
|
b7344566ef | ||
|
0f5d0c8b40 | ||
|
c45071c038 | ||
|
aac4fc59e6 | ||
|
78a43148a8 | ||
|
ceedd0678c | ||
|
d13385fa01 | ||
|
8996fc2cca | ||
|
65dcc978c1 | ||
|
923b07b97e | ||
|
84860a2875 | ||
|
6add9a1419 | ||
|
eddb741eb7 | ||
|
a763abf781 | ||
|
78e8a92c3a | ||
|
424dee4aea | ||
|
a381b5583c | ||
|
867ee7efe1 | ||
|
32b2d7239c | ||
|
6ce179bd60 | ||
|
dba937fb03 | ||
|
4efce6e325 | ||
|
10a42d3633 | ||
|
bb579d640c | ||
|
d7b4233282 | ||
|
9092cf1846 | ||
|
37b86d4ea0 | ||
|
40988348d3 | ||
|
1cbf95e6e1 | ||
|
c4ec6a6f29 | ||
|
779aec5e55 | ||
|
bf5c673739 | ||
|
a62e906b0e | ||
|
630633bab4 | ||
|
8d7d7cd645 | ||
|
e53575d31d | ||
|
412ff4e067 | ||
|
29b01ebb13 | ||
|
30b9a73df8 | ||
|
572a6c3299 | ||
|
c135da1f47 | ||
|
6867c2cc2d | ||
|
1e7bd209a1 | ||
|
109b603b7a | ||
|
6595409df0 | ||
|
f1012efcaa | ||
|
5261a52401 | ||
|
a914237f66 | ||
|
2019c5c434 | ||
|
234b84ef60 | ||
|
b9295cc199 | ||
|
3fae6a00dd | ||
|
37ad3cf8a6 | ||
|
c522387482 | ||
|
0006ecc90d | ||
|
6b16ed3cc8 | ||
|
a44439671e | ||
|
5084bb65d9 | ||
|
4583cf46b1 | ||
|
a865361117 | ||
|
4ea93d3426 | ||
|
8777c562dd | ||
|
4ea70e5ab9 | ||
|
df036ba384 | ||
|
e9ae1fcb60 | ||
|
bee04b0522 | ||
|
b6d54d27cd | ||
|
3465da4c36 | ||
|
4f129280c3 | ||
|
d2c1a826a8 | ||
|
0b9079b431 | ||
|
6fa3a08a72 | ||
|
64b7644e5e | ||
|
50d8bc2aed | ||
|
7f7ac5d5e6 | ||
|
1dd9a5cf94 | ||
|
40aa2e520f | ||
|
0ebee1910b | ||
|
81c2df7f10 | ||
|
833b300fde | ||
|
12d25b64fe | ||
|
afbc67c413 | ||
|
71e33b2177 | ||
|
f95309be08 | ||
|
0530441452 | ||
|
4ff53e08db | ||
|
f9645b016a | ||
|
6b7b802d14 | ||
|
1684c079e3 | ||
|
0c45a88246 | ||
|
de22a12e85 | ||
|
415d83acc7 | ||
|
eb7e7c1579 | ||
|
348004320c | ||
|
9829541289 | ||
|
1e1fef7a7b | ||
|
d0c9256c5b | ||
|
83300ad4b7 | ||
|
92408b359b | ||
|
01ba0a1df9 | ||
|
207af441a0 | ||
|
2a2786ba6d | ||
|
1d01376703 | ||
|
6e35bdc0b3 | ||
|
9380fbaaf7 | ||
|
295717256f | ||
|
87038dd6f4 | ||
|
2155275627 | ||
|
7b4e867e33 | ||
|
2c54f315f6 | ||
|
5cbc72b41f | ||
|
de504398d2 | ||
|
cae1c6fdb8 | ||
|
6a928ee35b | ||
|
bc535f4075 | ||
|
64b91cf7e0 | ||
|
54dafe1cec | ||
|
b16b6e422f | ||
|
8d08b18d08 | ||
|
8c7e9648dd | ||
|
b3555a7807 | ||
|
98d04b9093 | ||
|
4d157b2bd7 | ||
|
7c9083a6b8 | ||
|
ece128836a | ||
|
2e574d0659 | ||
|
850659bf48 | ||
|
672529382d | ||
|
c1ce7cb70f | ||
|
bc67d1cf1f | ||
|
652e913f49 | ||
|
089374b937 | ||
|
226e5620f9 | ||
|
ca9652cc60 | ||
|
acd1d80c75 | ||
|
e7c207d2af | ||
|
196ccb69ad | ||
|
2b941cb30f |
15
.gitignore
vendored
@ -15,16 +15,6 @@ coverage
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# PyCharm project settings
|
||||
.idea
|
||||
|
||||
@ -32,7 +22,7 @@ coverage
|
||||
.vscode
|
||||
|
||||
# Local data
|
||||
secrets.py
|
||||
settings_local.py
|
||||
*.log
|
||||
media/
|
||||
output/
|
||||
@ -42,6 +32,3 @@ output/
|
||||
env/
|
||||
venv/
|
||||
db.sqlite3
|
||||
|
||||
# Don't git index
|
||||
whoosh_index/
|
||||
|
@ -7,7 +7,7 @@ py311:
|
||||
image: python:3.11-alpine
|
||||
before_script:
|
||||
- apk add --no-cache libmagic
|
||||
- apk add --no-cache git # Useful for django-haystack, remove when the newer versions are in PyPI
|
||||
- apk add --no-cache gettext git # Useful for django-haystack, remove when the newer versions are in PyPI
|
||||
- pip install tox --no-cache-dir
|
||||
script: tox -e py311
|
||||
|
||||
@ -16,7 +16,7 @@ py312:
|
||||
image: python:3.12-alpine
|
||||
before_script:
|
||||
- apk add --no-cache libmagic
|
||||
- apk add --no-cache git # Useful for django-haystack, remove when the newer versions are in PyPI
|
||||
- apk add --no-cache gettext git # Useful for django-haystack, remove when the newer versions are in PyPI
|
||||
- pip install tox --no-cache-dir
|
||||
script: tox -e py312
|
||||
|
||||
|
2
chat/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
22
chat/admin.py
Normal 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
@ -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
@ -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']})
|
0
chat/management/__init__.py
Normal file
0
chat/management/commands/__init__.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,
|
||||
),
|
||||
)
|
200
chat/migrations/0001_initial.py
Normal 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",),
|
||||
},
|
||||
),
|
||||
]
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
26
chat/migrations/0003_message_users_read.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
2
chat/migrations/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
273
chat/models.py
Normal 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
@ -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,
|
||||
),
|
||||
)
|
595
chat/static/chat.js
Normal file
@ -0,0 +1,595 @@
|
||||
(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) {
|
||||
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.innerHTML = markdownToHTML(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.innerHTML = markdownToHTML(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 markdownToHTML(text) {
|
||||
let safeText = text.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
let lines = safeText.split('\n')
|
||||
let htmlLines = []
|
||||
for (let line of lines) {
|
||||
let htmlLine = line
|
||||
.replaceAll(/_(.*)_/gim, '<span class="text-decoration-underline">$1</span>') // Underline
|
||||
.replaceAll(/\*\*(.*)\*\*/gim, '<span class="fw-bold">$1</span>') // Bold
|
||||
.replaceAll(/\*(.*)\*/gim, '<span class="fst-italic">$1</span>') // Italic
|
||||
.replaceAll(/`(.*)`/gim, '<pre>$1</pre>') // Code
|
||||
.replaceAll(/(https?:\/\/\S+)/g, '<a href="$1" target="_blank">$1</a>') // Links
|
||||
htmlLines.push(htmlLine)
|
||||
}
|
||||
return htmlLines.join('<br>')
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
29
chat/static/chat.webmanifest
Normal 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"
|
||||
}
|
19
chat/templates/chat/chat.html
Normal 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 %}
|
99
chat/templates/chat/content.html
Normal 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>
|
34
chat/templates/chat/fullscreen.html
Normal 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>
|
36
chat/templates/chat/login.html
Normal 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
@ -0,0 +1,2 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
18
chat/urls.py
Normal 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'),
|
||||
]
|
BIN
docs/_static/img/payment_bank_transfer.png
vendored
Normal file
After Width: | Height: | Size: 118 KiB |
BIN
docs/_static/img/payment_grouped.png
vendored
Normal file
After Width: | Height: | Size: 115 KiB |
BIN
docs/_static/img/payment_hello_asso_confirmation.png
vendored
Normal file
After Width: | Height: | Size: 107 KiB |
BIN
docs/_static/img/payment_hello_asso_step_1.png
vendored
Normal file
After Width: | Height: | Size: 101 KiB |
BIN
docs/_static/img/payment_hello_asso_step_2.png
vendored
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
docs/_static/img/payment_index.png
vendored
Normal file
After Width: | Height: | Size: 120 KiB |
BIN
docs/_static/img/payment_scholarship.png
vendored
Normal file
After Width: | Height: | Size: 94 KiB |
@ -29,6 +29,7 @@ author = "Animath"
|
||||
# ones.
|
||||
extensions = [
|
||||
"sphinx_rtd_theme",
|
||||
"sphinx_rtd_dark_mode",
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
@ -58,3 +59,5 @@ html_theme = 'sphinx_rtd_theme'
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
default_dark_mode = True
|
||||
|
@ -1,2 +1,3 @@
|
||||
sphinx>=3.3
|
||||
sphinx-rtd-theme>=0.5
|
||||
sphinx-rtd-theme>=2.0
|
||||
sphinx_rtd_dark_mode>=1.3.0
|
||||
|
119
docs/user.rst
@ -178,17 +178,126 @@ Vous recevrez par mail une réponse des organisateur⋅rices locaux⋅ales. En c
|
||||
Payer son inscription
|
||||
---------------------
|
||||
|
||||
Une fois votre inscription validée, il vous faudra payer votre inscription. Les frais s'élèvent à
|
||||
23 € par élève, sauf pour les élèves boursièr⋅es qui en sont exonéré⋅es. Les encadrant⋅es n'ont pas
|
||||
à payer.
|
||||
Une fois votre inscription validée, il vous faudra payer votre participation. Les frais s'élèvent à
|
||||
21 € par élève, sauf pour les élèves boursièr⋅es qui en sont exonéré⋅es. Les encadrant⋅es n'ont pas
|
||||
à payer. Pour la finale, les frais sont de 35 € par élève.
|
||||
|
||||
.. note::
|
||||
Ces frais couvrent une partie des frais de restauration et d'hébergement. L'organisation reste
|
||||
bénévole.
|
||||
|
||||
.. TODO
|
||||
Il est possible de payer par carte bancaire ou virement bancaire. Pour d'autres types de paiement,
|
||||
merci de nous contacter.
|
||||
|
||||
Pour payer, si votre équipe est bien validée, vous pouvez vous rendre sur la page de votre compte
|
||||
ou celle de votre équipe, et cliquer sur le bouton « Modifier le paiement », qui devrais désormais
|
||||
apparaître. Vous pouvez également utiliser le lien présent dans le volet « Informations ».
|
||||
|
||||
.. image:: /_static/img/payment_index.png
|
||||
:alt: Page de paiement
|
||||
|
||||
.. note::
|
||||
Cette section sera mise à jour plus tard.
|
||||
|
||||
Vous recevrez un mail de rappel chaque semaine. Le paiement doit être effectué avant le début du
|
||||
tournoi, sans quoi votre participation pourrait être refusée. En cas de difficultés de paiement,
|
||||
merci de nous contacter.
|
||||
|
||||
Carte bancaire
|
||||
""""""""""""""
|
||||
|
||||
La façon la plus simple de payer son inscription est de payer par carte bancaire. Animath utilise
|
||||
`Hello Asso <https://helloasso.com/>`_ en guise de solution de paiements en ligne.
|
||||
|
||||
Il vous suffit de cliquer sur le bouton « Aller à la page Hello Asso ». Vous serez redirigé⋅e ensuite
|
||||
vers la page de paiement.
|
||||
|
||||
.. warning::
|
||||
|
||||
Pour procéder au paiement, si vous êtes mineur⋅e, vous devrez demander à un⋅e adulte de payer à
|
||||
votre place. Il est important dans la suite de bien mettre les coordonnées du payeur ou de la payeuse,
|
||||
majeur⋅e, et non celles de l'élève.
|
||||
|
||||
.. image:: /_static/img/payment_hello_asso_step_1.png
|
||||
:alt: Formulaire de paiement Hello Asso
|
||||
|
||||
La personne qui paie peut rentrer ses informations demandées (nom, prénom, e-mail, date de naissance).
|
||||
|
||||
Notez que, par défaut, Hello Asso ajoute automatiquement une participation à ses frais de fonctionnement,
|
||||
d'environ 15 à 20 % du prix payé. Ces frais ne sont pas obligatoires, ne sont pas versés à Animath et
|
||||
représentent la seule source de revenus à Hello Asso. En effet : Animath ne verse aucune commission lors
|
||||
de ses transactions, et seules les contributions volontaires financent leur service.
|
||||
|
||||
Sur la page suivante, vous pouvez indiquer vos coordonnées bancaires :
|
||||
|
||||
.. image:: /_static/img/payment_hello_asso_step_2.png
|
||||
:alt: Formulaire de paiement Hello Asso - coordonnées bancaires
|
||||
|
||||
Vous devez ensuite éventuellement confirmer votre paiement auprès de votre banque.
|
||||
|
||||
Une fois ceci fait, vous êtes automatiquement redirigé⋅es vers la plateforme du TFJM² :
|
||||
|
||||
.. image:: /_static/img/payment_hello_asso_confirmation.png
|
||||
:alt: Confirmation de paiement Hello Asso
|
||||
|
||||
Il se peut que la validation ne soit pas instantanée. Elle peut prendre au plus quelques minutes.
|
||||
Si le délai est plus long, merci de nous contacter.
|
||||
|
||||
Vous recevrez ensuite un mail de confirmation de la plateforme, ainsi qu'un justificatif de paiement
|
||||
de la part de Hello Asso.
|
||||
|
||||
|
||||
Carte bancaire - paiement par un tiers
|
||||
""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
Il est possible, si nécessaire, de faire payer l'inscription par carte bancaire par un tiers. Pour cela,
|
||||
vous pouvez lui transmettre le lien de paiement qui apparaît au centre de l'écran. Cela est notamment
|
||||
utile pour faire payer l'inscription par un établissement scolaire, ou par des parents.
|
||||
|
||||
L'interface de paiement sera ensuite identique.
|
||||
|
||||
|
||||
Virement bancaire
|
||||
"""""""""""""""""
|
||||
|
||||
Il est possible de payer par virement bancaire. Pour cela, vous pouvez ouvrir l'onglet virement bancaire :
|
||||
|
||||
.. image:: /_static/img/payment_bank_transfer.png
|
||||
:alt: Formulaire de paiement par virement bancaire
|
||||
|
||||
Pour effectuer le virement, merci de mettre en référence du virement « TFJMpu » suivi du nom et du prénom de l'élève.
|
||||
|
||||
Les coordonnées bancaires sont :
|
||||
|
||||
* IBAN : FR76 1027 8065 0000 0206 4290 127
|
||||
* BIC : CMCIFR2A
|
||||
|
||||
Une fois le paiment effectué, vous pouvez envoyer une preuve de virement via le formulaire ci-dessus. Le paiement
|
||||
sera ensuite validé manuellement par les organisateur⋅rices après réception.
|
||||
|
||||
Si vous avez besoin d'une facture, merci de nous contacter.
|
||||
|
||||
|
||||
Exonération - boursièr⋅es
|
||||
"""""""""""""""""""""""""
|
||||
|
||||
Si vous bénéficiez d'une bourse, vous pouvez être exonéré⋅es des frais de participation. Pour cela, il vous suffit
|
||||
de nous envoyer une copie de votre notification de bourse, ou tout autre document justifiant de votre situation.
|
||||
Vous pouvez envoyer ce document en vous rendant sur l'onglet dédié :
|
||||
|
||||
.. image:: /_static/img/payment_scholarship.png
|
||||
:alt: Formulaire de soumission de notification de bourse
|
||||
|
||||
|
||||
Paiements groupés
|
||||
"""""""""""""""""
|
||||
|
||||
Il est possible de payer en une seule fois pour toute l'équipe. Cela est notamment utile si l'inscription est
|
||||
payée par l'établissement. Pour cela, il suffit de cliquer sur le bouton « Regrouper les paiements de mon équipe ».
|
||||
Cela a pour effet d'unifier les paiements de l'équipe, et de ne pas demander à chaque membre de payer individuellement.
|
||||
Attention : cette fonction n'est possible que si aucun membre de l'équipe n'a encore payé son inscription.
|
||||
|
||||
.. image:: /_static/img/payment_grouped.png
|
||||
:alt: Page de paiement groupé
|
||||
|
||||
|
||||
Envoyer ses solutions
|
||||
|
@ -7,11 +7,34 @@ from django.utils.translation import gettext_lazy as _
|
||||
from .models import Draw, Pool, Round, TeamDraw
|
||||
|
||||
|
||||
class RoundInline(admin.TabularInline):
|
||||
model = Round
|
||||
extra = 0
|
||||
autocomplete_fields = ('draw', 'current_pool',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class PoolInline(admin.TabularInline):
|
||||
model = Pool
|
||||
extra = 0
|
||||
autocomplete_fields = ('round', 'current_team', 'associated_pool',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class TeamDrawInline(admin.TabularInline):
|
||||
model = TeamDraw
|
||||
extra = 0
|
||||
autocomplete_fields = ('participation', 'round', 'pool',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@admin.register(Draw)
|
||||
class DrawAdmin(admin.ModelAdmin):
|
||||
list_display = ('tournament', 'teams', 'current_round', 'get_state',)
|
||||
list_filter = ('tournament', 'current_round',)
|
||||
list_filter = ('tournament', 'current_round__number',)
|
||||
search_fields = ('tournament__name', 'tournament__participation__team__trigram',)
|
||||
autocomplete_fields = ('tournament',)
|
||||
inlines = (RoundInline,)
|
||||
|
||||
@admin.display(description=_("teams"))
|
||||
def teams(self, record: Draw):
|
||||
@ -20,10 +43,16 @@ class DrawAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Round)
|
||||
class RoundAdmin(admin.ModelAdmin):
|
||||
list_display = ('draw', 'number', 'teams',)
|
||||
list_display = ('draw', 'tournament', 'number', 'teams',)
|
||||
list_filter = ('draw__tournament', 'number',)
|
||||
search_fields = ('draw__tournament__name', 'pool__teamdraw__participation__team__trigram')
|
||||
ordering = ('draw__tournament__name', 'number')
|
||||
autocomplete_fields = ('draw', 'current_pool',)
|
||||
inlines = (PoolInline,)
|
||||
|
||||
@admin.display(description=_("tournament"), ordering='draw__tournament__name')
|
||||
def tournament(self, record):
|
||||
return record.draw.tournament
|
||||
|
||||
@admin.display(description=_("teams"))
|
||||
def teams(self, record: Round):
|
||||
@ -36,6 +65,8 @@ class PoolAdmin(admin.ModelAdmin):
|
||||
list_filter = ('round__draw__tournament', 'round__number', 'letter')
|
||||
ordering = ('round__draw__tournament__name', 'round', 'letter')
|
||||
search_fields = ('round__draw__tournament__name', 'teamdraw__participation__team__trigram',)
|
||||
autocomplete_fields = ('round', 'current_team', 'associated_pool',)
|
||||
inlines = (TeamDrawInline,)
|
||||
|
||||
@admin.display(ordering='round__draw__tournament__name', description=_("tournament"))
|
||||
def tournament(self, record):
|
||||
@ -52,6 +83,7 @@ class TeamDrawAdmin(admin.ModelAdmin):
|
||||
'passage_index', 'choose_index', 'passage_dice', 'choice_dice',)
|
||||
list_filter = ('round__draw__tournament', 'round__number', 'pool__letter',)
|
||||
search_fields = ('round__draw__tournament__name', 'participation__team__trigram',)
|
||||
autocomplete_fields = ('participation', 'round', 'pool',)
|
||||
|
||||
@admin.display(ordering='round__draw__tournament__name', description=_("tournament"))
|
||||
def tournament(self, record):
|
||||
|
@ -3,10 +3,13 @@
|
||||
|
||||
from collections import OrderedDict
|
||||
import json
|
||||
import os
|
||||
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 _
|
||||
@ -42,10 +45,17 @@ 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']
|
||||
reg = await Registration.objects.aget(user=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
|
||||
@ -69,6 +79,10 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
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
|
||||
|
||||
# Unregister from channel layers
|
||||
if not self.registration.is_volunteer:
|
||||
await self.channel_layer.group_discard(f"team-{self.registration.team.trigram}", self.channel_name)
|
||||
@ -152,7 +166,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
try:
|
||||
# Parse format from string
|
||||
fmt: list[int] = sorted(map(int, fmt.split('+')), reverse=True)
|
||||
fmt: list[int] = sorted(map(int, fmt.split('+')))
|
||||
except ValueError:
|
||||
return await self.alert(_("Invalid format"), 'danger')
|
||||
|
||||
@ -416,10 +430,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
# For each pool of size N, put the N next teams into this pool
|
||||
async for p in Pool.objects.filter(round_id=self.tournament.draw.current_round_id).order_by('letter').all():
|
||||
# Fetch the N teams, then order them in a new order for the passages inside the pool
|
||||
# We multiply the dice scores by 27 mod 100 (which order is 20 mod 100) for this new order
|
||||
# This simulates a deterministic shuffle
|
||||
pool_tds = sorted(tds_copy[:p.size], key=lambda td: (td.passage_dice * 27) % 100)
|
||||
# Fetch the N teams
|
||||
pool_tds = tds_copy[:p.size].copy()
|
||||
# Remove the head
|
||||
tds_copy = tds_copy[p.size:]
|
||||
for i, td in enumerate(pool_tds):
|
||||
@ -428,34 +440,62 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
td.passage_index = i
|
||||
await td.asave()
|
||||
|
||||
# The passages of the second round are determined from the scores of the dices
|
||||
# The team that has the lowest dice score goes to the first pool, then the team
|
||||
# that has the second-lowest score goes to the second pool, etc.
|
||||
# This also determines the passage order, in the natural order this time.
|
||||
# If there is a 5-teams pool, we force the last team to be in the first pool,
|
||||
# which is this specific pool since they are ordered by decreasing size.
|
||||
# This is not true for the final tournament, which considers the scores of the
|
||||
# first round.
|
||||
# The passages of the second round are determined from the order of the passages of the first round.
|
||||
# We order teams by increasing passage index, and then by decreasing pool number.
|
||||
# We keep teams that were at the last position in a 5-teams pool apart, as "jokers".
|
||||
# Then, we fill pools one team by one team.
|
||||
# As we fill one pool for the second round, we check if we can place a joker in it.
|
||||
# We can add a joker team if there is not already a team in the pool that was in the same pool
|
||||
# in the first round, and such that the number of such jokers is exactly the free space of the current pool.
|
||||
# Exception: if there is one only pool with 5 teams, we exchange the first and the last teams of the pool.
|
||||
if not self.tournament.final:
|
||||
tds_copy = tds.copy()
|
||||
tds_copy = sorted(tds, key=lambda td: (td.passage_index, -td.pool.letter,))
|
||||
jokers = [td for td in tds if td.passage_index == 4]
|
||||
round2 = await self.tournament.draw.round_set.filter(number=2).aget()
|
||||
round2_pools = [p async for p in Pool.objects.filter(round__draw__tournament=self.tournament, round=round2)
|
||||
.order_by('letter').all()]
|
||||
current_pool_id, current_passage_index = 0, 0
|
||||
for i, td in enumerate(tds_copy):
|
||||
if i == len(tds) - 1 and round2_pools[0].size == 5:
|
||||
current_pool_id = 0
|
||||
current_passage_index = 4
|
||||
|
||||
td2 = await TeamDraw.objects.filter(participation=td.participation, round=round2).aget()
|
||||
td2.pool = round2_pools[current_pool_id]
|
||||
td2.passage_index = current_passage_index
|
||||
current_pool_id += 1
|
||||
if current_pool_id == len(round2_pools):
|
||||
current_pool_id = 0
|
||||
current_passage_index += 1
|
||||
if len(round2_pools) == 1:
|
||||
# Exchange first and last team if there is only one pool
|
||||
if i == 0 or i == len(tds) - 1:
|
||||
td2.passage_index = len(tds) - 1 - i
|
||||
current_passage_index += 1
|
||||
await td2.asave()
|
||||
|
||||
valid_jokers = []
|
||||
# A joker is valid if it was not in the same pool in the first round
|
||||
# as a team that is already in the current pool in the second round
|
||||
for joker in jokers:
|
||||
async for td2 in round2_pools[current_pool_id].teamdraw_set.all():
|
||||
if await joker.pool.teamdraw_set.filter(participation_id=td2.participation_id).aexists():
|
||||
break
|
||||
else:
|
||||
valid_jokers.append(joker)
|
||||
|
||||
# We can add a joker if there is exactly enough free space in the current pool
|
||||
if valid_jokers and current_passage_index + len(valid_jokers) == td2.pool.size:
|
||||
for joker in valid_jokers:
|
||||
tds_copy.remove(joker)
|
||||
jokers.remove(joker)
|
||||
td2_joker = await TeamDraw.objects.filter(participation_id=joker.participation_id,
|
||||
round=round2).aget()
|
||||
td2_joker.pool = round2_pools[current_pool_id]
|
||||
td2_joker.passage_index = current_passage_index
|
||||
current_passage_index += 1
|
||||
await td2_joker.asave()
|
||||
jokers = []
|
||||
|
||||
current_passage_index = 0
|
||||
current_pool_id += 1
|
||||
|
||||
if current_passage_index == round2_pools[current_pool_id].size:
|
||||
current_passage_index = 0
|
||||
current_pool_id += 1
|
||||
|
||||
# The current pool is the first pool of the current (first) round
|
||||
pool = await Pool.objects.filter(round=self.tournament.draw.current_round, letter=1).aget()
|
||||
self.tournament.draw.current_round.current_pool = pool
|
||||
@ -465,8 +505,9 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
msg = "Les résultats des dés sont les suivants : "
|
||||
msg += ", ".join(f"<strong>{td.participation.team.trigram}</strong> ({td.passage_dice})" for td in tds)
|
||||
msg += ". L'ordre de passage et les compositions des différentes poules sont affiché⋅es sur le côté. "
|
||||
msg += "Attention : les ordres de passage sont déterminés à partir des scores des dés, mais ne sont pas "
|
||||
msg += "directement l'ordre croissant des dés, afin d'avoir des poules mélangées."
|
||||
msg += "Les ordres de passage pour le premier tour sont déterminés à partir des scores des dés, "
|
||||
msg += "dans l'ordre croissant. Pour le deuxième tour, les ordres de passage sont déterminés à partir "
|
||||
msg += "des ordres de passage du premier tour."
|
||||
self.tournament.draw.last_message = msg
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
@ -599,6 +640,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
and isinstance(kwargs['problem'], int) and (1 <= kwargs['problem'] <= len(settings.PROBLEMS)):
|
||||
# Admins can force the draw
|
||||
problem = int(kwargs['problem'])
|
||||
break
|
||||
|
||||
# Check that the user didn't already accept this problem for the first round
|
||||
# if this is the second round
|
||||
@ -899,7 +941,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
if already_refused:
|
||||
msg += "Cela n'ajoute pas de pénalité."
|
||||
else:
|
||||
msg += "Cela ajoute une pénalité de 0.5 sur le coefficient de l'oral de la défense."
|
||||
msg += "Cela ajoute une pénalité de 25 % sur le coefficient de l'oral de la défense."
|
||||
self.tournament.draw.last_message = msg
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
@ -953,15 +995,19 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
if not await Draw.objects.filter(tournament=self.tournament).aexists():
|
||||
return await self.alert(_("The draw has not started yet."), 'danger')
|
||||
|
||||
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.export_visibility',
|
||||
'visible': False})
|
||||
|
||||
# Export each exportable pool
|
||||
async for r in self.tournament.draw.round_set.all():
|
||||
async for pool in r.pool_set.all():
|
||||
if await pool.is_exportable():
|
||||
await pool.export()
|
||||
|
||||
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.export_visibility',
|
||||
'visible': False})
|
||||
# Update Google Sheets final sheet
|
||||
if os.getenv('GOOGLE_PRIVATE_KEY_ID', None):
|
||||
await sync_to_async(self.tournament.update_ranking_spreadsheet)()
|
||||
|
||||
@ensure_orga
|
||||
async def continue_final(self, **kwargs):
|
||||
@ -977,7 +1023,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
r2 = await self.tournament.draw.round_set.filter(number=2).aget()
|
||||
self.tournament.draw.current_round = r2
|
||||
msg = "Le tirage au sort pour le tour 2 va commencer. " \
|
||||
"L'ordre de passage est déterminé à partir du classement du premier tour."
|
||||
"L'ordre de passage est déterminé à partir du classement du premier tour, " \
|
||||
"de sorte à mélanger les équipes entre les deux jours."
|
||||
self.tournament.draw.last_message = msg
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
|
28
draw/migrations/0003_alter_teamdraw_options.py
Normal 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",
|
||||
},
|
||||
),
|
||||
]
|
@ -1,6 +1,8 @@
|
||||
# Copyright (C) 2023 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import os
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
@ -89,6 +91,7 @@ class Draw(models.Model):
|
||||
return 'WAITING_DRAW_PROBLEM'
|
||||
else:
|
||||
return 'WAITING_CHOOSE_PROBLEM'
|
||||
get_state.short_description = _('State')
|
||||
|
||||
@property
|
||||
def information(self):
|
||||
@ -145,7 +148,7 @@ class Draw(models.Model):
|
||||
# The problem can be rejected
|
||||
s += "Elle peut décider d'accepter ou de refuser ce problème. "
|
||||
if len(td.rejected) >= len(settings.PROBLEMS) - 5:
|
||||
s += "Refuser ce problème ajoutera une nouvelle pénalité de 0.5 sur le coefficient de l'oral de la défense."
|
||||
s += "Refuser ce problème ajoutera une nouvelle pénalité de 25 % sur le coefficient de l'oral de la défense."
|
||||
else:
|
||||
s += f"Il reste {len(settings.PROBLEMS) - 5 - len(td.rejected)} refus sans pénalité."
|
||||
case 'WAITING_FINAL':
|
||||
@ -289,7 +292,7 @@ class Pool(models.Model):
|
||||
"""
|
||||
Returns a query set ordered by passage index of all team draws in this pool.
|
||||
"""
|
||||
return self.teamdraw_set.order_by('passage_index').all()
|
||||
return self.teamdraw_set.all()
|
||||
|
||||
@property
|
||||
def trigrams(self) -> list[str]:
|
||||
@ -346,7 +349,7 @@ class Pool(models.Model):
|
||||
Translates this Pool instance in a :model:`participation.Pool` instance, with the passage orders.
|
||||
"""
|
||||
# Create the pool
|
||||
self.associated_pool = await PPool.objects.acreate(
|
||||
self.associated_pool, _created = await PPool.objects.aget_or_create(
|
||||
tournament=self.round.draw.tournament,
|
||||
round=self.round.number,
|
||||
letter=self.letter,
|
||||
@ -358,6 +361,17 @@ class Pool(models.Model):
|
||||
.prefetch_related('participation')])
|
||||
await self.asave()
|
||||
|
||||
pool2 = None
|
||||
if self.size == 5:
|
||||
pool2, _created = await PPool.objects.aget_or_create(
|
||||
tournament=self.round.draw.tournament,
|
||||
round=self.round.number,
|
||||
letter=self.letter,
|
||||
room=2,
|
||||
)
|
||||
await pool2.participations.aset([td.participation async for td in self.team_draws
|
||||
.prefetch_related('participation')])
|
||||
|
||||
# Define the passage matrix according to the number of teams
|
||||
table = []
|
||||
if self.size == 3:
|
||||
@ -377,26 +391,34 @@ class Pool(models.Model):
|
||||
table = [
|
||||
[0, 2, 3],
|
||||
[1, 3, 4],
|
||||
[2, 0, 1],
|
||||
[3, 4, 0],
|
||||
[2, 4, 0],
|
||||
[3, 0, 1],
|
||||
[4, 1, 2],
|
||||
]
|
||||
|
||||
for i, line in enumerate(table):
|
||||
passage_pool = self.associated_pool
|
||||
passage_position = i + 1
|
||||
if self.size == 5:
|
||||
# In 5-teams pools, we may create some passages in the second room
|
||||
if i % 2 == 1:
|
||||
passage_pool = pool2
|
||||
passage_position = 1 + i // 2
|
||||
|
||||
# Create the passage
|
||||
passage = await Passage.objects.acreate(
|
||||
pool=self.associated_pool,
|
||||
position=i + 1,
|
||||
await Passage.objects.acreate(
|
||||
pool=passage_pool,
|
||||
position=passage_position,
|
||||
solution_number=tds[line[0]].accepted,
|
||||
defender=tds[line[0]].participation,
|
||||
opponent=tds[line[1]].participation,
|
||||
reporter=tds[line[2]].participation,
|
||||
defender_penalties=tds[line[0]].penalty_int,
|
||||
)
|
||||
if self.size == 4:
|
||||
# Add observer for 4-teams pools
|
||||
passage.observer = tds[line[3]].participation
|
||||
await passage.asave()
|
||||
|
||||
# Update Google Sheets
|
||||
if os.getenv('GOOGLE_PRIVATE_KEY_ID', None):
|
||||
await sync_to_async(self.associated_pool.update_spreadsheet)()
|
||||
|
||||
return self.associated_pool
|
||||
|
||||
@ -510,9 +532,9 @@ class TeamDraw(models.Model):
|
||||
@property
|
||||
def penalty(self):
|
||||
"""
|
||||
The penalty multiplier on the defender oral, which is a malus of 0.5 for each penalty.
|
||||
The penalty multiplier on the defender oral, in percentage, which is a malus of 25% for each penalty.
|
||||
"""
|
||||
return 0.5 * self.penalty_int
|
||||
return 25 * self.penalty_int
|
||||
|
||||
def __str__(self):
|
||||
return str(format_lazy(_("Draw of the team {trigram} for the pool {letter}{number}"),
|
||||
@ -523,4 +545,5 @@ class TeamDraw(models.Model):
|
||||
class Meta:
|
||||
verbose_name = _('team draw')
|
||||
verbose_name_plural = _('team draws')
|
||||
ordering = ('round__draw__tournament__name', 'round__number', 'pool__letter', 'passage_index',)
|
||||
ordering = ('round__draw__tournament__name', 'round__number', 'pool__letter', 'passage_index',
|
||||
'choice_dice', 'passage_dice',)
|
||||
|
@ -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()),
|
||||
]
|
@ -40,6 +40,20 @@ function drawDice(tid, trigram = null, result = null) {
|
||||
socket.send(JSON.stringify({'tid': tid, 'type': 'dice', 'trigram': trigram, 'result': result}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the requested dice from the buttons and request to draw it.
|
||||
* Only available for debug purposes and for admins.
|
||||
* @param tid The tournament id
|
||||
*/
|
||||
function drawDebugDice(tid) {
|
||||
let dice_10 = parseInt(document.querySelector(`input[name="debug-dice-${tid}-10"]:checked`).value)
|
||||
let dice_1 = parseInt(document.querySelector(`input[name="debug-dice-${tid}-1"]:checked`).value)
|
||||
let result = (dice_10 + dice_1) || 100
|
||||
let team_div = document.querySelector(`div[id="dices-${tid}"] > div > div[class*="text-bg-warning"]`)
|
||||
let team = team_div.getAttribute("data-team")
|
||||
drawDice(tid, team, result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to draw a new problem.
|
||||
* @param tid The tournament id
|
||||
@ -203,6 +217,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
elem.classList.add('text-bg-success')
|
||||
elem.innerText = `${trigram} 🎲 ${result}`
|
||||
}
|
||||
|
||||
let nextTeam = document.querySelector(` div[id="dices-${tid}"] > div > div[class*="text-bg-warning"]`).getAttribute("data-team")
|
||||
if (nextTeam) {
|
||||
// If there is one team that does not have launched its dice, then we update the debug section
|
||||
let debugSpan = document.getElementById(`debug-dice-${tid}-team`)
|
||||
if (debugSpan)
|
||||
debugSpan.innerText = nextTeam
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -212,10 +234,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
*/
|
||||
function updateDiceVisibility(tid, visible) {
|
||||
let div = document.getElementById(`launch-dice-${tid}`)
|
||||
if (visible)
|
||||
let div_debug = document.getElementById(`debug-dice-form-${tid}`)
|
||||
if (visible) {
|
||||
div.classList.remove('d-none')
|
||||
else
|
||||
div_debug.classList.remove('d-none')
|
||||
}
|
||||
else {
|
||||
div.classList.add('d-none')
|
||||
div_debug.classList.add('d-none')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -225,10 +252,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
*/
|
||||
function updateBoxVisibility(tid, visible) {
|
||||
let div = document.getElementById(`draw-problem-${tid}`)
|
||||
if (visible)
|
||||
let div_debug = document.getElementById(`debug-problem-form-${tid}`)
|
||||
if (visible) {
|
||||
div.classList.remove('d-none')
|
||||
else
|
||||
div_debug.classList.remove('d-none')
|
||||
}
|
||||
else {
|
||||
div.classList.add('d-none')
|
||||
div_debug.classList.add('d-none')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -582,6 +614,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
let teamLi = document.getElementById(`recap-${tid}-round-${round}-team-${team}`)
|
||||
if (teamLi !== null)
|
||||
teamLi.classList.add('list-group-item-info')
|
||||
|
||||
let debugSpan = document.getElementById(`debug-problem-${tid}-team`)
|
||||
if (debugSpan && team) {
|
||||
debugSpan.innerText = team
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -622,14 +659,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
let penaltyDiv = document.getElementById(`recap-${tid}-round-${round}-team-${team}-penalty`)
|
||||
if (rejected.length > problems_count - 5) {
|
||||
// If more than P - 5 problems were rejected, add a penalty of 0.5 of the coefficient of the oral defender
|
||||
// If more than P - 5 problems were rejected, add a penalty of 25% of the coefficient of the oral defender
|
||||
if (penaltyDiv === null) {
|
||||
penaltyDiv = document.createElement('div')
|
||||
penaltyDiv.id = `recap-${tid}-round-${round}-team-${team}-penalty`
|
||||
penaltyDiv.classList.add('badge', 'rounded-pill', 'text-bg-info')
|
||||
recapDiv.parentNode.append(penaltyDiv)
|
||||
}
|
||||
penaltyDiv.textContent = `❌ ${0.5 * (rejected.length - (problems_count - 5))}`
|
||||
penaltyDiv.textContent = `❌ ${25 * (rejected.length - (problems_count - 5))} %`
|
||||
} else {
|
||||
// Eventually remove this div
|
||||
if (penaltyDiv !== null)
|
||||
@ -736,7 +773,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function setupSocket() {
|
||||
function setupSocket(nextDelay = 1000) {
|
||||
// Open a global websocket
|
||||
socket = new WebSocket(
|
||||
(document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/draw/'
|
||||
@ -753,7 +790,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Manage errors
|
||||
socket.addEventListener('close', e => {
|
||||
console.error('Chat socket closed unexpectedly, restarting…')
|
||||
setupSocket()
|
||||
setTimeout(() => setupSocket(2 * nextDelay), nextDelay)
|
||||
})
|
||||
|
||||
// When the socket is opened, set the language in order to receive alerts in the good language
|
||||
|
@ -37,6 +37,7 @@
|
||||
{% for td in tournament.draw.current_round.team_draws %}
|
||||
<div class="col-md-1" style="order: {{ forloop.counter }};">
|
||||
<div id="dice-{{ tournament.id }}-{{ td.participation.team.trigram }}"
|
||||
data-team="{{ td.participation.team.trigram }}"
|
||||
class="badge rounded-pill text-bg-{% if td.last_dice %}success{% else %}warning{% endif %}"
|
||||
{% if request.user.registration.is_volunteer %}
|
||||
{# Volunteers can click on dices to launch the dice of a team #}
|
||||
@ -99,7 +100,7 @@
|
||||
{# If needed, add the penalty of the team #}
|
||||
<div id="recap-{{ tournament.id }}-round-{{ round.number }}-team-{{ td.participation.team.trigram }}-penalty"
|
||||
class="badge rounded-pill text-bg-info">
|
||||
❌ {{ td.penalty }}
|
||||
❌ {{ td.penalty }} %
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
@ -186,6 +187,66 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user.registration.is_admin %}
|
||||
<div class="card my-3">
|
||||
<div class="card-header">
|
||||
<div style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#debug-draw-{{ tournament.id }}-body"
|
||||
aria-controls="debug-draw-{{ tournament.id }}-body" aria-expanded="false">
|
||||
<h4>{% trans "Debug draw" %}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body collapse" id="debug-draw-{{ tournament.id }}-body">
|
||||
<div id="debug-dice-form-{{ tournament.id }}" {% if tournament.draw.get_state != 'DICE_SELECT_POULES' and tournament.draw.get_state != 'DICE_ORDER_POULE' %}class="d-none"{% endif %}>
|
||||
<h5>
|
||||
{% trans "Draw dice for" %}
|
||||
<span id="debug-dice-{{ tournament.id }}-team">
|
||||
{% regroup tournament.draw.current_round.team_draws by last_dice as td_dices %}
|
||||
{% for group in td_dices %}
|
||||
{% if group.grouper is None %}
|
||||
{{ group }}
|
||||
{% with group.list|first as td %}
|
||||
{{ td.participation.team.trigram }}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</span>
|
||||
</h5>
|
||||
<div class="btn-group w-100" role="group">
|
||||
{% for i in range_100 %}
|
||||
<input type="radio" class="btn-check" name="debug-dice-{{ tournament.id }}-10" id="debug-dice-{{ tournament.id }}-{{ i|stringformat:"02d" }}" value="{{ i }}" {% if i == 0 %}checked{% endif %}>
|
||||
<label class="btn btn-outline-warning" for="debug-dice-{{ tournament.id }}-{{ i|stringformat:"02d" }}">{{ i|stringformat:"02d" }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="btn-group w-100" role="group">
|
||||
{% for i in range_10 %}
|
||||
<input type="radio" class="btn-check" name="debug-dice-{{ tournament.id }}-1" id="debug-dice-{{ tournament.id }}-{{ i }}" value="{{ i }}" {% if i == 0 %}checked{% endif %}>
|
||||
<label class="btn btn-outline-warning" for="debug-dice-{{ tournament.id }}-{{ i }}">{{ i }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="my-2 text-center">
|
||||
<button class="btn btn-success" onclick="drawDebugDice({{ tournament.id }})">
|
||||
{% trans "Draw dice" %} 🎲
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="debug-problem-form-{{ tournament.id }}" {% if tournament.draw.get_state != 'WAITING_DRAW_PROBLEM' %}class="d-none"{% endif %}>
|
||||
<h5>
|
||||
{% trans "Draw problem for" %}
|
||||
<span id="debug-problem-{{ tournament.id }}-team">{{ tournament.draw.current_round.current_pool.current_team.participation.team.trigram }}</span>
|
||||
</h5>
|
||||
<div class="btn-group w-100" role="group">
|
||||
{% for problem in problems %}
|
||||
<button class="btn btn-outline-info" id="debug-problem-{{ tournament.id }}-{{ forloop.counter }}" onclick="drawProblem({{ tournament.id }}, {{ forloop.counter }})">
|
||||
{% trans "Pb." %} {{ forloop.counter }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -284,14 +345,14 @@
|
||||
{% if forloop.counter == 1 %}
|
||||
<td class="text-center">Déf</td>
|
||||
<td></td>
|
||||
<td class="text-center">Opp</td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td class="text-center">Opp</td>
|
||||
<td></td>
|
||||
{% elif forloop.counter == 2 %}
|
||||
<td></td>
|
||||
<td class="text-center">Déf</td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td></td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td class="text-center">Opp</td>
|
||||
{% elif forloop.counter == 3 %}
|
||||
<td class="text-center">Opp</td>
|
||||
@ -308,10 +369,9 @@
|
||||
{% elif forloop.counter == 5 %}
|
||||
<td></td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td></td>
|
||||
<td class="text-center">Opp</td>
|
||||
<td class="text-center">Déf</td>
|
||||
<td></td>
|
||||
<td class="text-center">Déf</td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</tr>
|
||||
|
@ -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)
|
||||
@ -75,7 +75,7 @@ class TestDraw(TestCase):
|
||||
self.assertFalse(await Draw.objects.filter(tournament=self.tournament).aexists())
|
||||
|
||||
# Now start the draw
|
||||
await communicator.send_json_to({'tid': tid, 'type': 'start_draw', 'fmt': '3+4+5'})
|
||||
await communicator.send_json_to({'tid': tid, 'type': 'start_draw', 'fmt': '4+5+3'})
|
||||
|
||||
# Receive data after the start
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'alert')
|
||||
@ -93,7 +93,7 @@ class TestDraw(TestCase):
|
||||
{'tid': tid, 'type': 'dice_visibility', 'visible': True})
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'alert')
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'draw_start', 'fmt': [5, 4, 3],
|
||||
{'tid': tid, 'type': 'draw_start', 'fmt': [3, 4, 5],
|
||||
'trigrams': ['AAA', 'BBB', 'CCC', 'DDD', 'EEE', 'FFF',
|
||||
'GGG', 'HHH', 'III', 'JJJ', 'KKK', 'LLL']})
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
|
||||
@ -181,8 +181,8 @@ class TestDraw(TestCase):
|
||||
.aget(number=1, draw=draw)
|
||||
p = r.current_pool
|
||||
self.assertEqual(p.letter, 1)
|
||||
self.assertEqual(p.size, 5)
|
||||
self.assertEqual(await p.teamdraw_set.acount(), 5)
|
||||
self.assertEqual(p.size, 3)
|
||||
self.assertEqual(await p.teamdraw_set.acount(), 3)
|
||||
self.assertEqual(p.current_team, None)
|
||||
|
||||
# Render page
|
||||
@ -292,7 +292,7 @@ class TestDraw(TestCase):
|
||||
self.assertIsNone(td.purposed)
|
||||
self.assertEqual(td.rejected, [purposed])
|
||||
|
||||
for i in range(4):
|
||||
for i in range(2):
|
||||
# Next team
|
||||
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r, letter=1)
|
||||
td = p.current_team
|
||||
@ -411,8 +411,6 @@ class TestDraw(TestCase):
|
||||
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
|
||||
self.assertIsNone(td.purposed)
|
||||
|
||||
# Reorder the pool since there are 5 teams
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'reorder_poule')
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'dice_visibility', 'visible': True})
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
|
||||
@ -510,8 +508,8 @@ class TestDraw(TestCase):
|
||||
.aget(number=1, draw=draw)
|
||||
p = r.current_pool
|
||||
self.assertEqual(p.letter, 3)
|
||||
self.assertEqual(p.size, 3)
|
||||
self.assertEqual(await p.teamdraw_set.acount(), 3)
|
||||
self.assertEqual(p.size, 5)
|
||||
self.assertEqual(await p.teamdraw_set.acount(), 5)
|
||||
self.assertEqual(p.current_team, None)
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'set_active', 'round': 1, 'poule': 'C', 'team': None})
|
||||
@ -532,7 +530,7 @@ class TestDraw(TestCase):
|
||||
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
|
||||
|
||||
for i in range(3):
|
||||
for i in range(5):
|
||||
# Next team
|
||||
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r, letter=3)
|
||||
td = p.current_team
|
||||
@ -562,10 +560,11 @@ class TestDraw(TestCase):
|
||||
self.assertIsNotNone(td.purposed)
|
||||
self.assertIn(td.purposed, range(1, len(settings.PROBLEMS) + 1))
|
||||
# Lower problems are already accepted
|
||||
self.assertGreaterEqual(td.purposed, i + 1)
|
||||
self.assertGreaterEqual(td.purposed, 1 + i // 2)
|
||||
|
||||
# Assume that this is the problem is i for the team i
|
||||
td.purposed = i + 1
|
||||
# Assume that this is the problem is i / 2 for the team i (there are 5 teams)
|
||||
# We force to have duplicates
|
||||
td.purposed = 1 + i // 2
|
||||
await td.asave()
|
||||
|
||||
# Render page
|
||||
@ -577,11 +576,11 @@ class TestDraw(TestCase):
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'buttons_visibility', 'visible': False})
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'set_problem', 'round': 1, 'team': trigram, 'problem': i + 1})
|
||||
{'tid': tid, 'type': 'set_problem', 'round': 1, 'team': trigram, 'problem': 1 + i // 2})
|
||||
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
|
||||
self.assertIsNone(td.purposed)
|
||||
self.assertEqual(td.accepted, i + 1)
|
||||
if i == 2:
|
||||
self.assertEqual(td.accepted, 1 + i // 2)
|
||||
if i == 4:
|
||||
break
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'box_visibility', 'visible': True})
|
||||
@ -591,6 +590,9 @@ class TestDraw(TestCase):
|
||||
resp = await self.async_client.get(reverse('draw:index'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Reorder the pool since there are 5 teams
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'reorder_poule')
|
||||
|
||||
# Start round 2
|
||||
draw: Draw = await Draw.objects.prefetch_related(
|
||||
'current_round__current_pool__current_team__participation__team').aget(tournament=self.tournament)
|
||||
@ -624,7 +626,7 @@ class TestDraw(TestCase):
|
||||
.aget(draw=draw, number=2)
|
||||
p = r.current_pool
|
||||
self.assertEqual(p.letter, i + 1)
|
||||
self.assertEqual(p.size, 5 - i)
|
||||
self.assertEqual(p.size, i + 3)
|
||||
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'set_active', 'round': 2, 'poule': chr(65 + i), 'team': None})
|
||||
@ -642,7 +644,7 @@ class TestDraw(TestCase):
|
||||
resp = await communicator.receive_json_from()
|
||||
self.assertEqual(resp['type'], 'set_info')
|
||||
|
||||
for j in range(5 - i):
|
||||
for j in range(3 + i):
|
||||
# Next team
|
||||
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r,
|
||||
letter=i + 1)
|
||||
@ -685,13 +687,13 @@ class TestDraw(TestCase):
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'set_problem')
|
||||
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
|
||||
self.assertIsNone(td.purposed)
|
||||
if j == 4 - i:
|
||||
if j == 2 + i:
|
||||
break
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'box_visibility', 'visible': True})
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
|
||||
|
||||
if i == 0:
|
||||
if i == 2:
|
||||
# Reorder the pool since there are 5 teams
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'reorder_poule')
|
||||
if i < 2:
|
||||
@ -738,20 +740,20 @@ class TestDraw(TestCase):
|
||||
draw = Draw.objects.create(tournament=self.tournament)
|
||||
r1 = Round.objects.create(draw=draw, number=1)
|
||||
r2 = Round.objects.create(draw=draw, number=2)
|
||||
p11 = Pool.objects.create(round=r1, letter=1, size=5)
|
||||
p11 = Pool.objects.create(round=r1, letter=1, size=3)
|
||||
p12 = Pool.objects.create(round=r1, letter=2, size=4)
|
||||
p13 = Pool.objects.create(round=r1, letter=3, size=3)
|
||||
p21 = Pool.objects.create(round=r2, letter=1, size=5)
|
||||
p13 = Pool.objects.create(round=r1, letter=3, size=5)
|
||||
p21 = Pool.objects.create(round=r2, letter=1, size=3)
|
||||
p22 = Pool.objects.create(round=r2, letter=2, size=4)
|
||||
p23 = Pool.objects.create(round=r2, letter=3, size=3)
|
||||
p23 = Pool.objects.create(round=r2, letter=3, size=5)
|
||||
tds = []
|
||||
for i, team in enumerate(self.teams):
|
||||
tds.append(TeamDraw.objects.create(participation=team.participation,
|
||||
round=r1,
|
||||
pool=p11 if i < 5 else p12 if i < 9 else p13))
|
||||
pool=p11 if i < 3 else p12 if i < 7 else p13))
|
||||
tds.append(TeamDraw.objects.create(participation=team.participation,
|
||||
round=r2,
|
||||
pool=p21) if i < 5 else p22 if i < 9 else p23)
|
||||
pool=p21) if i < 3 else p22 if i < 7 else p23)
|
||||
|
||||
p11.current_team = tds[0]
|
||||
p11.save()
|
||||
|
@ -40,4 +40,7 @@ class DisplayView(LoginRequiredMixin, TemplateView):
|
||||
context['tournaments_simplified'] = [{'id': t.id, 'name': t.name} for t in tournaments]
|
||||
context['problems'] = settings.PROBLEMS
|
||||
|
||||
context['range_100'] = range(0, 100, 10)
|
||||
context['range_10'] = range(0, 10, 1)
|
||||
|
||||
return context
|
||||
|
@ -3,7 +3,6 @@
|
||||
crond -l 0
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py loaddata initial
|
||||
python manage.py update_index
|
||||
|
||||
nginx
|
||||
|
@ -7,11 +7,74 @@ from django.utils.translation import gettext_lazy as _
|
||||
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament, Tweak
|
||||
|
||||
|
||||
class ParticipationInline(admin.StackedInline):
|
||||
model = Participation
|
||||
extra = 0
|
||||
autocomplete_fields = ('team', 'tournament',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class ParticipationTabularInline(admin.TabularInline):
|
||||
model = Participation
|
||||
extra = 0
|
||||
fields = ('team', 'valid', 'final',)
|
||||
readonly_fields = ('team',)
|
||||
ordering = ('final', 'valid', 'team__trigram',)
|
||||
autocomplete_fields = ('tournament',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class SolutionInline(admin.TabularInline):
|
||||
model = Solution
|
||||
extra = 0
|
||||
ordering = ('problem',)
|
||||
autocomplete_fields = ('participation',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class SynthesisInline(admin.TabularInline):
|
||||
model = Synthesis
|
||||
extra = 0
|
||||
ordering = ('passage__solution_number', 'type',)
|
||||
autocomplete_fields = ('passage',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class PoolInline(admin.TabularInline):
|
||||
model = Pool
|
||||
extra = 0
|
||||
autocomplete_fields = ('tournament', 'participations', 'jury_president', 'juries',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class PassageInline(admin.TabularInline):
|
||||
model = Passage
|
||||
extra = 0
|
||||
ordering = ('position',)
|
||||
autocomplete_fields = ('defender', 'opponent', 'reporter',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class NoteInline(admin.TabularInline):
|
||||
model = Note
|
||||
extra = 0
|
||||
autocomplete_fields = ('jury',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class TweakInline(admin.TabularInline):
|
||||
model = Tweak
|
||||
extra = 0
|
||||
autocomplete_fields = ('participation', 'pool',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@admin.register(Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'trigram', 'tournament', 'valid', 'final',)
|
||||
search_fields = ('name', 'trigram',)
|
||||
list_filter = ('participation__valid', 'participation__tournament', 'participation__final',)
|
||||
inlines = (ParticipationInline,)
|
||||
|
||||
@admin.display(description=_("tournament"))
|
||||
def tournament(self, record):
|
||||
@ -30,16 +93,18 @@ class TeamAdmin(admin.ModelAdmin):
|
||||
class ParticipationAdmin(admin.ModelAdmin):
|
||||
list_display = ('team', 'tournament', 'valid', 'final',)
|
||||
search_fields = ('team__name', 'team__trigram',)
|
||||
list_filter = ('valid',)
|
||||
list_filter = ('valid', 'tournament',)
|
||||
autocomplete_fields = ('team', 'tournament',)
|
||||
inlines = (SolutionInline, SynthesisInline,)
|
||||
|
||||
|
||||
@admin.register(Pool)
|
||||
class PoolAdmin(admin.ModelAdmin):
|
||||
list_display = ('__str__', 'tournament', 'round', 'letter', 'teams',)
|
||||
list_filter = ('tournament', 'round', 'letter',)
|
||||
list_display = ('__str__', 'tournament', 'round', 'letter', 'room', 'teams', 'jury_president',)
|
||||
list_filter = ('tournament', 'round', 'letter', 'room',)
|
||||
search_fields = ('participations__team__name', 'participations__team__trigram',)
|
||||
autocomplete_fields = ('tournament', 'participations', 'juries',)
|
||||
autocomplete_fields = ('tournament', 'participations', 'jury_president', 'juries',)
|
||||
inlines = (PassageInline, TweakInline,)
|
||||
|
||||
@admin.display(description=_("teams"))
|
||||
def teams(self, record: Pool):
|
||||
@ -49,28 +114,30 @@ class PoolAdmin(admin.ModelAdmin):
|
||||
@admin.register(Passage)
|
||||
class PassageAdmin(admin.ModelAdmin):
|
||||
list_display = ('__str__', 'defender_trigram', 'solution_number', 'opponent_trigram', 'reporter_trigram',
|
||||
'pool_abbr', 'tournament')
|
||||
'pool_abbr', 'position', 'tournament')
|
||||
list_filter = ('pool__tournament', 'pool__round', 'pool__letter', 'solution_number',)
|
||||
search_fields = ('pool__participations__team__name', 'pool__participations__team__trigram',)
|
||||
autocomplete_fields = ('pool', 'defender', 'opponent', 'reporter', 'observer',)
|
||||
ordering = ('pool__tournament', 'pool__round', 'pool__letter', 'position',)
|
||||
autocomplete_fields = ('pool', 'defender', 'opponent', 'reporter',)
|
||||
inlines = (NoteInline,)
|
||||
|
||||
@admin.display(description=_("defender"))
|
||||
@admin.display(description=_("defender"), ordering='defender__team__trigram')
|
||||
def defender_trigram(self, record: Passage):
|
||||
return record.defender.team.trigram
|
||||
|
||||
@admin.display(description=_("opponent"))
|
||||
@admin.display(description=_("opponent"), ordering='opponent__team__trigram')
|
||||
def opponent_trigram(self, record: Passage):
|
||||
return record.opponent.team.trigram
|
||||
|
||||
@admin.display(description=_("reporter"))
|
||||
@admin.display(description=_("reporter"), ordering='reporter__team__trigram')
|
||||
def reporter_trigram(self, record: Passage):
|
||||
return record.reporter.team.trigram
|
||||
|
||||
@admin.display(description=_("pool"))
|
||||
@admin.display(description=_("pool"), ordering='pool__letter')
|
||||
def pool_abbr(self, record):
|
||||
return f"{record.pool.get_letter_display()}{record.pool.round}"
|
||||
return f"{record.pool.short_name}"
|
||||
|
||||
@admin.display(description=_("tournament"))
|
||||
@admin.display(description=_("tournament"), ordering='pool__tournament__name')
|
||||
def tournament(self, record: Passage):
|
||||
return record.pool.tournament
|
||||
|
||||
@ -87,7 +154,7 @@ class NoteAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.display(description=_("pool"))
|
||||
def pool(self, record):
|
||||
return record.passage.pool.get_letter_display()
|
||||
return record.passage.pool.short_name
|
||||
|
||||
|
||||
@admin.register(Solution)
|
||||
@ -124,12 +191,16 @@ class SynthesisAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Tournament)
|
||||
class TournamentAdmin(admin.ModelAdmin):
|
||||
list_display = ('name',)
|
||||
list_display = ('name', 'date_start', 'date_end',)
|
||||
search_fields = ('name',)
|
||||
ordering = ('date_start', 'name',)
|
||||
autocomplete_fields = ('organizers',)
|
||||
inlines = (ParticipationTabularInline, PoolInline,)
|
||||
|
||||
|
||||
@admin.register(Tweak)
|
||||
class TweakAdmin(admin.ModelAdmin):
|
||||
list_display = ('participation', 'pool', 'diff',)
|
||||
list_filter = ('pool__tournament', 'pool__round',)
|
||||
search_fields = ('participation__team__name', 'participation__team__trigram',)
|
||||
autocomplete_fields = ('participation', 'pool',)
|
||||
|
@ -61,3 +61,9 @@ class TournamentSerializer(serializers.ModelSerializer):
|
||||
'inscription_limit', 'solution_limit', 'solutions_draw', 'syntheses_first_phase_limit',
|
||||
'solutions_available_second_phase', 'syntheses_second_phase_limit',
|
||||
'description', 'organizers', 'final', 'participations',)
|
||||
|
||||
|
||||
class TweakSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = '__all__'
|
||||
|
@ -2,7 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .views import NoteViewSet, ParticipationViewSet, PassageViewSet, PoolViewSet, \
|
||||
SolutionViewSet, SynthesisViewSet, TeamViewSet, TournamentViewSet
|
||||
SolutionViewSet, SynthesisViewSet, TeamViewSet, TournamentViewSet, TweakViewSet
|
||||
|
||||
|
||||
def register_participation_urls(router, path):
|
||||
@ -17,3 +17,4 @@ def register_participation_urls(router, path):
|
||||
router.register(path + "/synthesis", SynthesisViewSet)
|
||||
router.register(path + "/team", TeamViewSet)
|
||||
router.register(path + "/tournament", TournamentViewSet)
|
||||
router.register(path + "/tweak", TweakViewSet)
|
||||
|
@ -4,8 +4,8 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from .serializers import NoteSerializer, ParticipationSerializer, PassageSerializer, PoolSerializer, \
|
||||
SolutionSerializer, SynthesisSerializer, TeamSerializer, TournamentSerializer
|
||||
from ..models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
|
||||
SolutionSerializer, SynthesisSerializer, TeamSerializer, TournamentSerializer, TweakSerializer
|
||||
from ..models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament, Tweak
|
||||
|
||||
|
||||
class NoteViewSet(ModelViewSet):
|
||||
@ -67,3 +67,11 @@ class TournamentViewSet(ModelViewSet):
|
||||
'inscription_limit', 'solution_limit', 'solutions_draw', 'syntheses_first_phase_limit',
|
||||
'solutions_available_second_phase', 'syntheses_second_phase_limit',
|
||||
'description', 'organizers', 'final', ]
|
||||
|
||||
|
||||
class TweakViewSet(ModelViewSet):
|
||||
queryset = Tweak.objects.all()
|
||||
serializer_class = TweakSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['pool', 'pool__tournament', 'pool__tournament__name', 'participation',
|
||||
'participation__team__trigram', 'diff', ]
|
||||
|
@ -12,8 +12,9 @@ class ParticipationConfig(AppConfig):
|
||||
name = 'participation'
|
||||
|
||||
def ready(self):
|
||||
from participation.signals import create_notes, create_team_participation, update_mailing_list
|
||||
pre_save.connect(update_mailing_list, "participation.Team")
|
||||
post_save.connect(create_team_participation, "participation.Team")
|
||||
post_save.connect(create_notes, "participation.Passage")
|
||||
post_save.connect(create_notes, "participation.Pool")
|
||||
from participation import signals
|
||||
pre_save.connect(signals.update_mailing_list, "participation.Team")
|
||||
post_save.connect(signals.create_team_participation, "participation.Team")
|
||||
post_save.connect(signals.create_payments, "participation.Participation")
|
||||
post_save.connect(signals.create_notes, "participation.Passage")
|
||||
post_save.connect(signals.create_notes, "participation.Pool")
|
||||
|
@ -1,22 +1,19 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import csv
|
||||
from io import StringIO
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from crispy_forms.bootstrap import InlineField
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Div, Fieldset, Submit
|
||||
from crispy_forms.layout import Div, Field, Submit
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.db.models import CharField, Value
|
||||
from django.db.models.functions import Concat
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import pandas
|
||||
from pypdf import PdfReader
|
||||
from registration.models import VolunteerRegistration
|
||||
|
||||
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
|
||||
|
||||
@ -55,6 +52,10 @@ class JoinTeamForm(forms.ModelForm):
|
||||
access_code = self.cleaned_data["access_code"]
|
||||
if not Team.objects.filter(access_code=access_code).exists():
|
||||
raise ValidationError(_("No team was found with this access code."))
|
||||
else:
|
||||
team = Team.objects.get(access_code=access_code)
|
||||
if team.participation.valid is not None:
|
||||
raise ValidationError(_("The team is already validated or the validation is pending."))
|
||||
return access_code
|
||||
|
||||
def clean(self):
|
||||
@ -79,7 +80,7 @@ class ParticipationForm(forms.ModelForm):
|
||||
|
||||
|
||||
class MotivationLetterForm(forms.ModelForm):
|
||||
def clean_file(self):
|
||||
def clean_motivation_letter(self):
|
||||
if "motivation_letter" in self.files:
|
||||
file = self.files["motivation_letter"]
|
||||
if file.size > 2e6:
|
||||
@ -126,7 +127,7 @@ class ValidateParticipationForm(forms.Form):
|
||||
class TournamentForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Tournament
|
||||
fields = '__all__'
|
||||
exclude = ('notes_sheet_id', )
|
||||
widgets = {
|
||||
'date_start': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
'date_end': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
@ -175,8 +176,13 @@ class SolutionForm(forms.ModelForm):
|
||||
class PoolForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Pool
|
||||
fields = ('tournament', 'round', 'letter', 'bbb_url', 'results_available', 'juries',)
|
||||
fields = ('tournament', 'round', 'letter', 'bbb_url', 'results_available', 'jury_president', 'juries',)
|
||||
widgets = {
|
||||
"jury_president": forms.Select(attrs={
|
||||
'class': 'selectpicker',
|
||||
'data-live-search': 'true',
|
||||
'data-live-search-normalize': 'true',
|
||||
}),
|
||||
"juries": forms.SelectMultiple(attrs={
|
||||
'class': 'selectpicker',
|
||||
'data-live-search': 'true',
|
||||
@ -185,47 +191,31 @@ class PoolForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PoolTeamsForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["participations"].queryset = self.instance.tournament.participations.all()
|
||||
|
||||
class Meta:
|
||||
model = Pool
|
||||
fields = ('participations',)
|
||||
widgets = {
|
||||
"participations": forms.SelectMultiple(attrs={
|
||||
'class': 'selectpicker',
|
||||
'data-live-search': 'true',
|
||||
'data-live-search-normalize': 'true',
|
||||
'data-width': 'fit',
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
class AddJuryForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['first_name'].required = True
|
||||
self.fields['last_name'].required = True
|
||||
self.fields['email'].required = True
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_class = 'form-inline'
|
||||
self.helper.layout = Fieldset(
|
||||
_("Add new jury"),
|
||||
self.helper.layout = Div(
|
||||
Div(
|
||||
Div(
|
||||
InlineField('first_name', autofocus="autofocus"),
|
||||
css_class='col-xl-3',
|
||||
Field('email', autofocus="autofocus", list="juries-email"),
|
||||
css_class='col-md-5 px-1',
|
||||
),
|
||||
Div(
|
||||
InlineField('last_name'),
|
||||
css_class='col-xl-3',
|
||||
Field('first_name', list="juries-first-name"),
|
||||
css_class='col-md-3 px-1',
|
||||
),
|
||||
Div(
|
||||
InlineField('email'),
|
||||
css_class='col-xl-5',
|
||||
Field('last_name', list="juries-last-name"),
|
||||
css_class='col-md-3 px-1',
|
||||
),
|
||||
Div(
|
||||
Submit('submit', _("Add")),
|
||||
css_class='col-xl-1',
|
||||
css_class='col-md-1 py-md-4 px-1',
|
||||
),
|
||||
css_class='row',
|
||||
)
|
||||
@ -237,7 +227,10 @@ class AddJuryForm(forms.ModelForm):
|
||||
"""
|
||||
email = self.data["email"]
|
||||
if User.objects.filter(email=email).exists():
|
||||
self.add_error("email", _("This email address is already used."))
|
||||
self.instance = User.objects.get(email=email)
|
||||
if self.instance.registration.participates:
|
||||
self.add_error(None, _("This user already exists, but is a participant."))
|
||||
return
|
||||
return email
|
||||
|
||||
class Meta:
|
||||
@ -247,79 +240,82 @@ class AddJuryForm(forms.ModelForm):
|
||||
|
||||
class UploadNotesForm(forms.Form):
|
||||
file = forms.FileField(
|
||||
label=_("CSV file:"),
|
||||
validators=[FileExtensionValidator(allowed_extensions=["csv"])],
|
||||
label=_("Spreadsheet file:"),
|
||||
validators=[FileExtensionValidator(allowed_extensions=["csv", "ods"])],
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['file'].widget.attrs['accept'] = 'text/csv'
|
||||
self.fields['file'].widget.attrs['accept'] = 'text/csv,application/vnd.oasis.opendocument.spreadsheet'
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if 'file' in cleaned_data:
|
||||
file = cleaned_data['file']
|
||||
with file:
|
||||
try:
|
||||
data: bytes = file.read()
|
||||
if file.name.endswith('.csv'):
|
||||
with file:
|
||||
try:
|
||||
content = data.decode()
|
||||
data: bytes = file.read()
|
||||
try:
|
||||
content = data.decode()
|
||||
except UnicodeDecodeError:
|
||||
# This is not UTF-8, grrrr
|
||||
content = data.decode('latin1')
|
||||
|
||||
table = pandas.read_csv(StringIO(content), sep=None, header=None)
|
||||
self.process(table, cleaned_data)
|
||||
except UnicodeDecodeError:
|
||||
# This is not UTF-8, grrrr
|
||||
content = data.decode('latin1')
|
||||
csvfile = csv.reader(StringIO(content))
|
||||
self.process(csvfile, cleaned_data)
|
||||
except UnicodeDecodeError:
|
||||
self.add_error('file', _("This file contains non-UTF-8 and non-ISO-8859-1 content. "
|
||||
"Please send your sheet as a CSV file."))
|
||||
self.add_error('file', _("This file contains non-UTF-8 and non-ISO-8859-1 content. "
|
||||
"Please send your sheet as a CSV file."))
|
||||
elif file.name.endswith('.ods'):
|
||||
table = pandas.read_excel(file, header=None, engine='odf')
|
||||
self.process(table, cleaned_data)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def process(self, csvfile: Iterable[str], cleaned_data: dict):
|
||||
def process(self, df: pandas.DataFrame, cleaned_data: dict):
|
||||
parsed_notes = {}
|
||||
valid_lengths = [1 + 6 * 3, 1 + 7 * 4, 1 + 6 * 5] # Per pool sizes
|
||||
pool_size = 0
|
||||
line_length = 0
|
||||
for line in csvfile:
|
||||
line = [s.strip() for s in line if s]
|
||||
for line in df.values.tolist():
|
||||
# Remove NaN
|
||||
line = [s for s in line if s == s]
|
||||
# Strip cases
|
||||
line = [str(s).strip() for s in line if str(s)]
|
||||
if line and line[0] == 'Problème':
|
||||
pool_size = len(line) - 1
|
||||
if pool_size < 3 or pool_size > 5:
|
||||
self.add_error('file', _("Can't determine the pool size. Are you sure your file is correct?"))
|
||||
return
|
||||
line_length = valid_lengths[pool_size - 3]
|
||||
line_length = 2 + 6 * pool_size
|
||||
continue
|
||||
|
||||
if pool_size == 0 or len(line) < line_length:
|
||||
continue
|
||||
|
||||
name = line[0]
|
||||
if name.lower() in ["rôle", "juré", "moyenne", "coefficient", "sous-total", "équipe", "equipe"]:
|
||||
if name.lower() in ["rôle", "juré⋅e", "juré?e", "moyenne", "coefficient", "sous-total", "équipe", "equipe"]:
|
||||
continue
|
||||
notes = line[1:line_length]
|
||||
notes = line[2:line_length]
|
||||
print(name, notes)
|
||||
if not all(s.isnumeric() or s[0] == '-' and s[1:].isnumeric() for s in notes):
|
||||
continue
|
||||
notes = list(map(int, notes))
|
||||
notes = list(map(lambda x: int(float(x)), notes))
|
||||
print(notes)
|
||||
|
||||
max_notes = pool_size * ([20, 16, 9, 10, 9, 10] + ([4] if pool_size == 4 else []))
|
||||
max_notes = pool_size * [20, 20, 10, 10, 10, 10]
|
||||
for n, max_n in zip(notes, max_notes):
|
||||
if n > max_n:
|
||||
self.add_error('file',
|
||||
_("The following note is higher of the maximum expected value:")
|
||||
+ str(n) + " > " + str(max_n))
|
||||
|
||||
# Search by "{first_name} {last_name}"
|
||||
jury = User.objects.annotate(full_name=Concat('first_name', Value(' '), 'last_name',
|
||||
output_field=CharField())) \
|
||||
.filter(full_name=name.replace('’', '\''), registration__volunteerregistration__isnull=False)
|
||||
# Search by volunteer id
|
||||
jury = VolunteerRegistration.objects.filter(pk=int(float(line[1])))
|
||||
if jury.count() != 1:
|
||||
self.add_error('file', _("The following user was not found:") + " " + name)
|
||||
continue
|
||||
raise ValidationError({'file': _("The following user was not found:") + " " + name})
|
||||
jury = jury.get()
|
||||
parsed_notes[jury] = notes
|
||||
|
||||
vr = jury.registration
|
||||
parsed_notes[vr] = notes
|
||||
print(parsed_notes)
|
||||
|
||||
cleaned_data['parsed_notes'] = parsed_notes
|
||||
|
||||
@ -340,7 +336,7 @@ class PassageForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Passage
|
||||
fields = ('position', 'solution_number', 'defender', 'opponent', 'reporter', 'observer', 'defender_penalties',)
|
||||
fields = ('position', 'solution_number', 'defender', 'opponent', 'reporter', 'defender_penalties',)
|
||||
|
||||
|
||||
class SynthesisForm(forms.ModelForm):
|
||||
@ -371,4 +367,4 @@ class NoteForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Note
|
||||
fields = ('defender_writing', 'defender_oral', 'opponent_writing',
|
||||
'opponent_oral', 'reporter_writing', 'reporter_oral', 'observer_oral', )
|
||||
'opponent_oral', 'reporter_writing', 'reporter_oral', )
|
||||
|
@ -1,87 +0,0 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import os
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management import BaseCommand
|
||||
from django.db.models import Q
|
||||
import requests
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options): # noqa: C901
|
||||
# Get access token
|
||||
response = requests.post('https://api.helloasso.com/oauth2/token', headers={
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}, data={
|
||||
'client_id': os.getenv('HELLOASSO_CLIENT_ID', ''),
|
||||
'client_secret': os.getenv('HELLOASSO_CLIENT_SECRET', ''),
|
||||
'grant_type': 'client_credentials',
|
||||
}).json()
|
||||
|
||||
token = response['access_token']
|
||||
|
||||
organization = "animath"
|
||||
form_slug = "tfjm-2023-tournois-regionaux"
|
||||
from_date = "2000-01-01"
|
||||
url = f"https://api.helloasso.com/v5/organizations/{organization}/forms/Event/{form_slug}/payments" \
|
||||
f"?from={from_date}&pageIndex=1&pageSize=100&retrieveOfflineDonations=false"
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
}
|
||||
http_response = requests.get(url, headers=headers)
|
||||
response = http_response.json()
|
||||
|
||||
if http_response.status_code != 200:
|
||||
message = response["message"]
|
||||
self.stderr.write(f"Error while querying Hello Asso: {message}")
|
||||
return
|
||||
|
||||
for payment in response["data"]:
|
||||
if payment["state"] != "Authorized":
|
||||
continue
|
||||
|
||||
payer = payment["payer"]
|
||||
email = payer["email"]
|
||||
last_name = payer["lastName"]
|
||||
first_name = payer["firstName"]
|
||||
base_filter = Q(
|
||||
registration__participantregistration__isnull=False,
|
||||
registration__participantregistration__team__isnull=False,
|
||||
registration__participantregistration__team__participation__valid=True,
|
||||
)
|
||||
qs = User.objects.filter(
|
||||
base_filter,
|
||||
email=email,
|
||||
)
|
||||
if not qs.exists():
|
||||
qs = User.objects.filter(
|
||||
base_filter,
|
||||
last_name__icontains=last_name,
|
||||
)
|
||||
if qs.count() >= 2:
|
||||
qs = qs.filter(first_name__icontains=first_name)
|
||||
if not qs.exists():
|
||||
self.stderr.write(f"Warning: a payment was found by {first_name} {last_name} ({email}), "
|
||||
"but this user is unknown.")
|
||||
continue
|
||||
if qs.count() > 1:
|
||||
self.stderr.write(f"Warning: a payment was found by {first_name} {last_name} ({email}), "
|
||||
f"but there are {qs.count()} matching users.")
|
||||
continue
|
||||
user = qs.get()
|
||||
if not user.registration.participates:
|
||||
self.stderr.write(f"Warning: a payment was found by the email address {email}, "
|
||||
"but this user is not a participant.")
|
||||
continue
|
||||
payment_obj = user.registration.payment
|
||||
payment_obj.valid = True
|
||||
payment_obj.type = "helloasso"
|
||||
payment_obj.additional_information = f"Identifiant de transation : {payment['id']}\n" \
|
||||
f"Date : {payment['date']}\n" \
|
||||
f"Reçu : {payment['paymentReceiptUrl']}\n" \
|
||||
f"Montant : {payment['amount'] / 100:.2f} €"
|
||||
payment_obj.save()
|
||||
self.stdout.write(f"{payment_obj} is validated")
|
@ -22,11 +22,11 @@ class Command(BaseCommand):
|
||||
"Liste de diffusion pour contacter toutes les equipes non validees du TFJM2.",
|
||||
"education", raise_error=False)
|
||||
|
||||
sympa.create_list("admins", "Administrateurs du TFJM2", "hotline",
|
||||
"Liste de diffusion pour contacter tous les administrateurs du TFJM2.",
|
||||
sympa.create_list("admins", "Administrateur⋅rices du TFJM2", "hotline",
|
||||
"Liste de diffusion pour contacter toustes les administrateur.rices du TFJM2.",
|
||||
"education", raise_error=False)
|
||||
sympa.create_list("organisateurs", "Organisateurs du TFJM2", "hotline",
|
||||
"Liste de diffusion pour contacter tous les organisateurs du TFJM2.",
|
||||
"Liste de diffusion pour contacter toustes les organisateur.rices du TFJM2.",
|
||||
"education", raise_error=False)
|
||||
sympa.create_list("jurys", "Jurys du TFJM2", "hotline",
|
||||
"Liste de diffusion pour contacter tous les jurys du TFJM2.",
|
||||
|
149
participation/management/commands/generate_seconds_sheet.py
Normal file
@ -0,0 +1,149 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import BaseCommand
|
||||
from django.utils.translation import activate
|
||||
import gspread
|
||||
from gspread.utils import a1_range_to_grid_range, MergeType
|
||||
|
||||
from ...models import Passage, Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
activate('fr')
|
||||
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
|
||||
try:
|
||||
spreadsheet = gc.open("Tableau des deuxièmes", folder_id=settings.NOTES_DRIVE_FOLDER_ID)
|
||||
except gspread.SpreadsheetNotFound:
|
||||
spreadsheet = gc.create("Tableau des deuxièmes", folder_id=settings.NOTES_DRIVE_FOLDER_ID)
|
||||
spreadsheet.update_locale("fr_FR")
|
||||
spreadsheet.share(None, "anyone", "writer", with_link=True)
|
||||
|
||||
sheet = spreadsheet.sheet1
|
||||
|
||||
header1 = ["Tournoi", "Équipe 2", "Tour 1", "", "", "", "", "", "", "", "Tour 2", "", "", "", "", "", "", "",
|
||||
"Score total", "Score équipe 1", "Score équipe 3"]
|
||||
header2 = ["", ""] + 2 * ["PJ", "Problème", "Défenseur⋅se", "", "Opposant⋅e", "", "Rapporteur⋅rice", ""]
|
||||
header2 += ["", "", ""]
|
||||
header3 = ["", ""] + 2 * (["", ""] + 3 * ["Écrit", "Oral"]) + ["", "", ""]
|
||||
lines = [header1, header2, header3]
|
||||
nb_tournaments = Tournament.objects.filter(final=False).count()
|
||||
for tournament in Tournament.objects.filter(final=False).all():
|
||||
line = [tournament.name]
|
||||
lines.append(line)
|
||||
|
||||
notes = dict()
|
||||
for participation in tournament.participations.filter(valid=True).all():
|
||||
note = sum(pool.average(participation)
|
||||
for pool in tournament.pools.filter(participations=participation).all())
|
||||
if note:
|
||||
notes[participation] = note
|
||||
|
||||
if not notes:
|
||||
continue
|
||||
|
||||
sorted_notes = sorted(notes.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
team1, score1 = sorted_notes[0]
|
||||
team2, score2 = sorted_notes[1]
|
||||
team3, score3 = sorted_notes[2]
|
||||
|
||||
pool1 = tournament.pools.filter(round=1, participations=team2).first()
|
||||
defender_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, defender=team2)
|
||||
opponent_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, opponent=team2)
|
||||
reporter_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, reporter=team2)
|
||||
pool2 = tournament.pools.filter(round=2, participations=team2).first()
|
||||
defender_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, defender=team2)
|
||||
opponent_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, opponent=team2)
|
||||
reporter_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, reporter=team2)
|
||||
|
||||
line.append(team2.team.trigram)
|
||||
line.append(str(pool1.jury_president or ""))
|
||||
line.append(f"Pb. {defender_passage_1.solution_number}")
|
||||
line.extend([defender_passage_1.average_defender_writing, defender_passage_1.average_defender_oral,
|
||||
opponent_passage_1.average_opponent_writing, opponent_passage_1.average_opponent_oral,
|
||||
reporter_passage_1.average_reporter_writing, reporter_passage_1.average_reporter_oral])
|
||||
line.append(str(pool2.jury_president or ""))
|
||||
line.append(f"Pb. {defender_passage_2.solution_number}")
|
||||
line.extend([defender_passage_2.average_defender_writing, defender_passage_2.average_defender_oral,
|
||||
opponent_passage_2.average_opponent_writing, opponent_passage_2.average_opponent_oral,
|
||||
reporter_passage_2.average_reporter_writing, reporter_passage_2.average_reporter_oral])
|
||||
line.extend([score2, f"{score1:.1f} ({team1.team.trigram})",
|
||||
f"{score3:.1f} ({team3.team.trigram})"])
|
||||
|
||||
sheet.update(lines)
|
||||
|
||||
format_requests = []
|
||||
merge_cells = ["A1:A3", "B1:B3", "C1:J1", "K1:R1", "E2:F2", "G2:H2", "I2:J2", "M2:N2", "O2:P2", "Q2:R2",
|
||||
"C2:C3", "D2:D3", "K2:K3", "L2:L3", "S1:S3", "T1:T3", "U1:U3"]
|
||||
format_requests.append({"unmergeCells": {"range": a1_range_to_grid_range("A1:AF", sheet.id)}})
|
||||
for name in merge_cells:
|
||||
grid_range = a1_range_to_grid_range(name, sheet.id)
|
||||
format_requests.append({"mergeCells": {"mergeType": MergeType.merge_all, "range": grid_range}})
|
||||
|
||||
bold_ranges = [("A1:AF", False), ("A1:U3", True), (f"A4:A{3 + nb_tournaments}", True)]
|
||||
for bold_range, bold in bold_ranges:
|
||||
format_requests.append({
|
||||
"repeatCell": {
|
||||
"range": a1_range_to_grid_range(bold_range, sheet.id),
|
||||
"cell": {"userEnteredFormat": {"textFormat": {"bold": bold}}},
|
||||
"fields": "userEnteredFormat(textFormat)",
|
||||
}
|
||||
})
|
||||
|
||||
border_ranges = [("A1:AF", "0000"),
|
||||
(f"A1:U{3 + nb_tournaments}", "1111")]
|
||||
sides_names = ['top', 'bottom', 'left', 'right']
|
||||
styles = ["NONE", "SOLID", "SOLID_MEDIUM", "SOLID_THICK", "DOUBLE"]
|
||||
for border_range, sides in border_ranges:
|
||||
borders = {}
|
||||
for side_name, side in zip(sides_names, sides):
|
||||
borders[side_name] = {"style": styles[int(side)]}
|
||||
format_requests.append({
|
||||
"repeatCell": {
|
||||
"range": a1_range_to_grid_range(border_range, sheet.id),
|
||||
"cell": {
|
||||
"userEnteredFormat": {
|
||||
"borders": borders,
|
||||
"horizontalAlignment": "CENTER",
|
||||
},
|
||||
},
|
||||
"fields": "userEnteredFormat(borders,horizontalAlignment)",
|
||||
}
|
||||
})
|
||||
|
||||
column_widths = [("A", 120), ("B", 80), ("C", 180), ("D", 80)] + [(chr(ord("E") + i), 60) for i in range(6)]
|
||||
column_widths += [("K", 180), ("L", 80)] + [(chr(ord("M") + i), 60) for i in range(6)]
|
||||
column_widths += [("S", 100), ("T", 120), ("U", 120)]
|
||||
for column, width in column_widths:
|
||||
grid_range = a1_range_to_grid_range(column, sheet.id)
|
||||
format_requests.append({
|
||||
"updateDimensionProperties": {
|
||||
"range": {
|
||||
"sheetId": sheet.id,
|
||||
"dimension": "COLUMNS",
|
||||
"startIndex": grid_range['startColumnIndex'],
|
||||
"endIndex": grid_range['endColumnIndex'],
|
||||
},
|
||||
"properties": {
|
||||
"pixelSize": width,
|
||||
},
|
||||
"fields": "pixelSize",
|
||||
}
|
||||
})
|
||||
|
||||
# Set number format, display only one decimal
|
||||
number_format_ranges = [f"E4:J{3 + nb_tournaments}", f"M4:S{3 + nb_tournaments}"]
|
||||
for number_format_range in number_format_ranges:
|
||||
format_requests.append({
|
||||
"repeatCell": {
|
||||
"range": a1_range_to_grid_range(number_format_range, sheet.id),
|
||||
"cell": {"userEnteredFormat": {"numberFormat": {"type": "NUMBER", "pattern": "0.0"}}},
|
||||
"fields": "userEnteredFormat.numberFormat",
|
||||
}
|
||||
})
|
||||
|
||||
body = {"requests": format_requests}
|
||||
sheet.client.batch_update(spreadsheet.id, body)
|
56
participation/management/commands/parse_notation_sheets.py
Normal file
@ -0,0 +1,56 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
from time import sleep
|
||||
|
||||
from django.core.management import BaseCommand
|
||||
from participation.models import Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--round', '-r', type=int, help="Round number to update (if not set, all rounds will be updated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--letter', '-l', help="Letter of the pool to update (if not set, all pools will be updated)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
tournaments = Tournament.objects.all() if not options['tournament'] \
|
||||
else Tournament.objects.filter(name=options['tournament']).all()
|
||||
|
||||
for tournament in tournaments:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Parsing notation sheet for {tournament}")
|
||||
|
||||
if not tournament.notes_sheet_id:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"No spreadsheet found for {tournament}. Please create it first"))
|
||||
continue
|
||||
|
||||
pools = tournament.pools.all()
|
||||
if options['round']:
|
||||
pools = pools.filter(round=options['round'])
|
||||
if options['letter']:
|
||||
pools = pools.filter(letter=ord(options['letter']) - 64)
|
||||
for pool in pools.all():
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Parsing notation sheet for pool {pool.short_name} for {tournament}")
|
||||
try:
|
||||
pool.parse_spreadsheet()
|
||||
except Exception as e:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stderr.write(
|
||||
self.style.ERROR(f"Error while parsing pool {pool.short_name} for {tournament.name}: {e}"))
|
||||
finally:
|
||||
sleep(3) # Three calls = 3s sleep
|
||||
|
||||
try:
|
||||
tournament.parse_tweaks_spreadsheets()
|
||||
except Exception as e:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stderr.write(self.style.ERROR(f"Error while parsing tweaks for {tournament.name}: {e}"))
|
@ -0,0 +1,61 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from hashlib import sha1
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management import BaseCommand
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import localtime
|
||||
import gspread
|
||||
|
||||
from ...models import Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
tournaments = Tournament.objects.all() if not options['tournament'] \
|
||||
else Tournament.objects.filter(name=options['tournament']).all()
|
||||
|
||||
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
|
||||
http_client = gc.http_client
|
||||
http_client.login()
|
||||
|
||||
site = Site.objects.get(pk=settings.SITE_ID)
|
||||
|
||||
now = localtime(timezone.now())
|
||||
tomorrow = now + timezone.timedelta(days=1)
|
||||
tomorrow -= timezone.timedelta(hours=now.hour, minutes=now.minute, seconds=now.second,
|
||||
microseconds=now.microsecond)
|
||||
|
||||
for tournament in tournaments:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Renewing Google Drive notifications for {tournament}")
|
||||
|
||||
if not tournament.notes_sheet_id:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"No spreadsheet found for {tournament}. Please create it first"))
|
||||
continue
|
||||
|
||||
channel_id = sha1(f"{tournament.name}-{now.date()}-{site.domain}".encode()).hexdigest()
|
||||
url = f"https://www.googleapis.com/drive/v3/files/{tournament.notes_sheet_id}/watch?supportsAllDrives=true"
|
||||
notif_path = reverse('participation:tournament_gsheet_notifications', args=[tournament.pk])
|
||||
notif_url = f"https://{site.domain}{notif_path}"
|
||||
body = {
|
||||
"id": channel_id,
|
||||
"type": "web_hook",
|
||||
"address": notif_url,
|
||||
"expiration": str(int(1000 * tomorrow.timestamp())),
|
||||
}
|
||||
try:
|
||||
http_client.request(method="POST", endpoint=url, json=body).raise_for_status()
|
||||
except Exception as e:
|
||||
self.stderr.write(self.style.ERROR(f"Error while renewing notifications for {tournament.name}: {e}"))
|
39
participation/management/commands/update_notation_sheets.py
Normal file
@ -0,0 +1,39 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.core.management import BaseCommand
|
||||
from participation.models import Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--round', '-r', type=int, help="Round number to update (if not set, all rounds will be updated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--letter', '-l', help="Letter of the pool to update (if not set, all pools will be updated)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
tournaments = Tournament.objects.all() if not options['tournament'] \
|
||||
else Tournament.objects.filter(name=options['tournament']).all()
|
||||
|
||||
for tournament in tournaments:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Updating notation sheet for {tournament}")
|
||||
tournament.create_spreadsheet()
|
||||
|
||||
pools = tournament.pools.all()
|
||||
if options['round']:
|
||||
pools = pools.filter(round=options['round'])
|
||||
if options['letter']:
|
||||
pools = pools.filter(letter=ord(options['letter']) - 64)
|
||||
for pool in pools.all():
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Updating notation sheet for pool {pool.short_name} for {tournament}")
|
||||
pool.update_spreadsheet()
|
||||
|
||||
tournament.update_ranking_spreadsheet()
|
27
participation/migrations/0009_pool_jury_president.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.0.2 on 2024-03-24 14:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0008_alter_participation_options"),
|
||||
("registration", "0012_payment_token_alter_payment_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="pool",
|
||||
name="jury_president",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="pools_presided",
|
||||
to="registration.volunteerregistration",
|
||||
verbose_name="president of the jury",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,93 @@
|
||||
# Generated by Django 5.0.3 on 2024-03-29 22:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0009_pool_jury_president"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="tournament",
|
||||
name="notes_sheet_id",
|
||||
field=models.CharField(
|
||||
blank=True, default="", max_length=64, verbose_name="Google Sheet ID"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="defender_oral",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
(11, 11),
|
||||
(12, 12),
|
||||
(13, 13),
|
||||
(14, 14),
|
||||
(15, 15),
|
||||
(16, 16),
|
||||
(17, 17),
|
||||
(18, 18),
|
||||
(19, 19),
|
||||
(20, 20),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="defender oral note",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="opponent_writing",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="opponent writing note",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="reporter_writing",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="reporter writing note",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-16 21:59
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"participation",
|
||||
"0010_tournament_notes_sheet_id_alter_note_defender_oral_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="note",
|
||||
name="observer_oral",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="passage",
|
||||
name="observer",
|
||||
),
|
||||
]
|
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-16 22:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0011_remove_note_observer_oral_remove_passage_observer"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="participation",
|
||||
name="mention",
|
||||
field=models.CharField(
|
||||
blank=True, default="", max_length=255, verbose_name="mention"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="participation",
|
||||
name="mention_final",
|
||||
field=models.CharField(
|
||||
blank=True, default="", max_length=255, verbose_name="mention (final)"
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-17 20:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0012_participation_mention_participation_mention_final"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="pool",
|
||||
options={
|
||||
"ordering": ("round", "letter", "room"),
|
||||
"verbose_name": "pool",
|
||||
"verbose_name_plural": "pools",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="pool",
|
||||
name="room",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[(1, "Room 1"), (2, "Room 2")],
|
||||
default=1,
|
||||
help_text="For 5-teams pools only",
|
||||
verbose_name="room",
|
||||
),
|
||||
),
|
||||
]
|
@ -2,7 +2,8 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
from typing import Union
|
||||
|
||||
from participation.models import Note, Participation, Passage, Pool, Team
|
||||
from participation.models import Note, Participation, Passage, Pool, Team, Tournament
|
||||
from registration.models import Payment
|
||||
from tfjm.lists import get_sympa_client
|
||||
|
||||
|
||||
@ -36,6 +37,50 @@ def update_mailing_list(instance: Team, raw, **_):
|
||||
f"{coach.user.first_name} {coach.user.last_name}")
|
||||
|
||||
|
||||
def create_payments(instance: Participation, created, raw, **_):
|
||||
"""
|
||||
When a participation got created, create an associated payment.
|
||||
"""
|
||||
if instance.valid and not raw:
|
||||
for student in instance.team.students.all():
|
||||
payment_qs = Payment.objects.filter(registrations=student, final=False)
|
||||
if payment_qs.exists():
|
||||
payment = payment_qs.get()
|
||||
else:
|
||||
payment = Payment.objects.create()
|
||||
payment.registrations.add(student)
|
||||
payment.save()
|
||||
payment.amount = instance.tournament.price
|
||||
if payment.amount == 0:
|
||||
payment.type = "free"
|
||||
payment.valid = True
|
||||
payment.save()
|
||||
|
||||
if instance.final:
|
||||
for student in instance.team.students.all():
|
||||
payment_qs = Payment.objects.filter(registrations=student, final=True)
|
||||
if payment_qs.exists():
|
||||
payment = payment_qs.get()
|
||||
else:
|
||||
payment = Payment.objects.create(final=True)
|
||||
payment.registrations.add(student)
|
||||
|
||||
payment_regional = Payment.objects.get(registrations=student, final=False)
|
||||
if payment_regional.type == 'scholarship':
|
||||
payment.type = 'scholarship'
|
||||
with open(payment_regional.receipt.path, 'rb') as f:
|
||||
payment.receipt.save(payment_regional.receipt.name, f)
|
||||
payment.additional_information = payment_regional.additional_information
|
||||
payment.fee = 0
|
||||
payment.valid = payment_regional.valid
|
||||
payment.save()
|
||||
payment.amount = Tournament.final_tournament().price
|
||||
if payment.amount == 0:
|
||||
payment.type = "free"
|
||||
payment.valid = True
|
||||
payment.save()
|
||||
|
||||
|
||||
def create_notes(instance: Union[Passage, Pool], raw, **_):
|
||||
if not raw:
|
||||
if isinstance(instance, Pool):
|
||||
|
@ -2,6 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.utils import formats
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.text import format_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import django_tables2 as tables
|
||||
@ -90,7 +91,7 @@ class PoolTable(tables.Table):
|
||||
)
|
||||
|
||||
def render_letter(self, record):
|
||||
return format_lazy(_("Pool {letter}{round}"), letter=record.get_letter_display(), round=record.round)
|
||||
return format_lazy(_("Pool {code}"), code=record.short_name)
|
||||
|
||||
def render_teams(self, record):
|
||||
return ", ".join(participation.team.trigram for participation in record.participations.all()) \
|
||||
@ -137,10 +138,21 @@ class NoteTable(tables.Table):
|
||||
}
|
||||
)
|
||||
|
||||
update = tables.Column(
|
||||
verbose_name=_("Update"),
|
||||
accessor="id",
|
||||
empty_values=(),
|
||||
)
|
||||
|
||||
def render_update(self, record):
|
||||
return mark_safe(f'<button class="btn btn-info" data-bs-toggle="modal" '
|
||||
f'data-bs-target="#{record.modal_name}Modal">'
|
||||
f'{_("Update")}</button>')
|
||||
|
||||
class Meta:
|
||||
attrs = {
|
||||
'class': 'table table-condensed table-striped text-center',
|
||||
}
|
||||
model = Note
|
||||
fields = ('jury', 'defender_writing', 'defender_oral', 'opponent_writing', 'opponent_oral',
|
||||
'reporter_writing', 'reporter_oral', 'observer_oral',)
|
||||
'reporter_writing', 'reporter_oral', 'update',)
|
||||
|
@ -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 %}
|
@ -5,16 +5,42 @@
|
||||
<title>Équipe validée – TFJM²</title>
|
||||
</head>
|
||||
<body>
|
||||
Bonjour,<br/>
|
||||
<br/>
|
||||
Félicitations ! Votre équipe « {{ team.name }} » ({{ team.trigram }}) est désormais validée ! Vous êtes désormais apte
|
||||
à travailler sur vos problèmes. Vous pourrez ensuite envoyer vos solutions sur la plateforme.<br>
|
||||
Les organisateurs vous adressent ce message :<br/>
|
||||
<br/>
|
||||
{{ message }}<br />
|
||||
<br/>
|
||||
Cordialement,<br/>
|
||||
<br/>
|
||||
Le comité d'organisation du TFJM²
|
||||
<p>
|
||||
Bonjour {{ registration }},
|
||||
</p>
|
||||
<p>
|
||||
Félicitations ! Votre équipe « {{ team.name }} » ({{ team.trigram }}) est désormais validée ! Vous êtes désormais
|
||||
apte à travailler sur vos problèmes. Vous pourrez ensuite envoyer vos solutions sur la plateforme.
|
||||
</p>
|
||||
|
||||
{% if payment %}
|
||||
<p>
|
||||
Vous devez désormais vous acquitter de vos frais de participation, de {{ payment.amount }} € par élève.
|
||||
Vous pouvez payer par carte bancaire ou par virement bancaire. Vous trouverez les informations
|
||||
sur <a href="https://{{ domain }}{% url 'registration:update_payment' pk=payment.pk %}">la page de paiement</a>.
|
||||
Si vous disposez d'une bourse, l'inscription est gratuite, mais vous devez soumettre un justificatif
|
||||
sur la même page.
|
||||
</p>
|
||||
{% elif registration.is_coach and team.participation.tournament.price %}
|
||||
<p>
|
||||
Votre équipe doit désormais s'acquitter des frais de participation de {{ team.participation.tournament.price }} €
|
||||
par élève (les encadrant⋅es sont exonéré⋅es). Les élèves qui disposent d'une bourse sont exonéré⋅es de ces frais.
|
||||
Vous pouvez suivre l'état des paiements sur
|
||||
<a href="https://{{ domain }}{% url 'participation:team_detail' pk=team.pk %}">la page de votre équipe</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if message %}
|
||||
<p>
|
||||
Les organisateur⋅ices vous adressent ce message :
|
||||
</p>
|
||||
<p>
|
||||
{{ message }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
Le comité d'organisation du TFJM²
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,12 +1,23 @@
|
||||
Bonjour,
|
||||
Bonjour {{ registration }},
|
||||
|
||||
Félicitations ! Votre équipe « {{ team.name }} » ({{ team.trigram }}) est désormais validée ! Vous êtes désormais apte
|
||||
à travailler sur vos problèmes. Vous pourrez ensuite envoyer vos solutions sur la plateforme.
|
||||
|
||||
Les organisateurs vous adressent ce message :
|
||||
{% if team.participation.amount %}
|
||||
Vous devez désormais vous acquitter de vos frais de participation, de {{ team.participation.amount }} €.
|
||||
Vous pouvez payer par carte bancaire ou par virement bancaire. Vous trouverez les informations
|
||||
sur la page de paiement que vous pouvez retrouver sur votre compte :
|
||||
https://{{ domain }}{% url 'registration:my_account_detail' %}
|
||||
Si vous disposez d'une bourse, l'inscription est gratuite, mais vous devez soumettre un justificatif
|
||||
sur la même page.
|
||||
{% elif registration.is_coach and team.participation.tournament.price %}
|
||||
Votre équipe doit désormais s'acquitter des frais de participation de {{ team.participation.tournament.price }} €
|
||||
par élève (les encadrant⋅es sont exonéré⋅es). Les élèves qui disposent d'une bourse sont exonéré⋅es de ces frais.
|
||||
Vous pouvez suivre l'état des paiements sur la page de votre équipe :
|
||||
https://{{ domain }}{% url 'participation:team_detail' pk=team.pk %}
|
||||
{% endif %}
|
||||
{% if message %}
|
||||
Les organisateurices vous adressent ce message :
|
||||
|
||||
{{ message }}
|
||||
|
||||
Cordialement,
|
||||
|
||||
{% endif %}
|
||||
Le comité d'organisation du TFJM²
|
||||
|
@ -5,6 +5,9 @@
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
<div id="form-content">
|
||||
<h4>{% trans "Notes of" %} {{ note.jury }}</h4>
|
||||
<h5>{% trans "Defense of" %} {{ note.passage.defender.team.trigram }}, {% trans "Pb." %} {{ note.passage.solution_number }}</h5>
|
||||
<hr>
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
</div>
|
||||
|
@ -30,6 +30,12 @@
|
||||
{% empty %}
|
||||
<li>{% trans "No solution was uploaded yet." %}</li>
|
||||
{% endfor %}
|
||||
<li>
|
||||
<a href="{% url "participation:participation_solutions" team_id=participation.team_id %}"
|
||||
class="btn btn-sm btn-info">
|
||||
<i class="fas fa-archive"></i> {% trans "Download as ZIP" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
|
||||
|
@ -6,7 +6,16 @@
|
||||
{% trans "any" as any %}
|
||||
<div class="card bg-body shadow">
|
||||
<div class="card-header text-center">
|
||||
<h4>{{ passage }}</h4>
|
||||
<h4>
|
||||
{{ passage }}
|
||||
{% if user.registration.is_admin or user.registration in passage.pool.tournament.organizers.all %}
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
data-bs-toggle="modal" data-bs-target="#updatePassageModal">
|
||||
<i class="fas fa-edit"></i>
|
||||
{% trans "Update" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
@ -25,11 +34,6 @@
|
||||
<dt class="col-sm-3">{% trans "Reporter:" %}</dt>
|
||||
<dd class="col-sm-9"><a href="{{ passage.reporter.get_absolute_url }}">{{ passage.reporter.team }}</a></dd>
|
||||
|
||||
{% if passage.observer %}
|
||||
<dt class="col-sm-3">{% trans "Observer:" %}</dt>
|
||||
<dd class="col-sm-9"><a href="{{ passage.observer.get_absolute_url }}">{{ passage.observer.team }}</a></dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-3">{% trans "Defended solution:" %}</dt>
|
||||
<dd class="col-sm-9"><a href="{{ passage.defended_solution.file.url }}">{{ passage.defended_solution }}</a></dd>
|
||||
|
||||
@ -49,9 +53,8 @@
|
||||
{% if notes is not None %}
|
||||
<div class="card-footer text-center">
|
||||
{% if my_note is not None %}
|
||||
<button class="btn btn-info" data-bs-toggle="modal" data-bs-target="#updateNotesModal">{% trans "Update notes" %}</button>
|
||||
<button class="btn btn-info" data-bs-toggle="modal" data-bs-target="#{{ my_note.modal_name }}Modal">{% trans "Update notes" %}</button>
|
||||
{% endif %}
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updatePassageModal">{% trans "Update" %}</button>
|
||||
</div>
|
||||
{% elif user.registration.participates %}
|
||||
<div class="card-footer text-center">
|
||||
@ -70,46 +73,63 @@
|
||||
<div class="card bg-body shadow">
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-8">{% trans "Average points for the defender writing:" %}</dt>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Average points for the defender writing" %}
|
||||
({{ passage.defender.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_defender_writing|floatformat }}/20</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Average points for the defender oral:" %}</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_defender_oral|floatformat }}/16</dd>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Average points for the defender oral" %}
|
||||
({{ passage.defender.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_defender_oral|floatformat }}/20</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Average points for the opponent writing:" %}</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_opponent_writing|floatformat }}/9</dd>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Average points for the opponent writing" %}
|
||||
({{ passage.opponent.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_opponent_writing|floatformat }}/10</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Average points for the opponent oral:" %}</dt>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Average points for the opponent oral" %}
|
||||
({{ passage.opponent.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_opponent_oral|floatformat }}/10</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Average points for the reporter writing:" %}</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_reporter_writing|floatformat }}/9</dd>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Average points for the reporter writing" %}
|
||||
({{ passage.reporter.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_reporter_writing|floatformat }}/10</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Average points for the reporter oral:" %}</dt>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Average points for the reporter oral" %}
|
||||
({{ passage.reporter.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_reporter_oral|floatformat }}/10</dd>
|
||||
|
||||
{% if passage.observer %}
|
||||
<dt class="col-sm-8">{% trans "Average points for the observer oral:" %}</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
<hr>
|
||||
|
||||
<dl class="row">
|
||||
<dt class="col-sm-8">{% trans "Defender points:" %}</dt>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Defender points" %}
|
||||
({{ passage.defender.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_defender|floatformat }}/52</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Opponent points:" %}</dt>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Opponent points" %}
|
||||
({{ passage.opponent.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_opponent|floatformat }}/29</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Reporter points:" %}</dt>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Reporter points" %}
|
||||
({{ passage.reporter.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_reporter|floatformat }}/19</dd>
|
||||
|
||||
{% if passage.observer %}
|
||||
<dt class="col-sm-8">{% trans "Observer points:" %}</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@ -121,12 +141,12 @@
|
||||
{% url "participation:passage_update" pk=passage.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="updatePassage" %}
|
||||
|
||||
{% if my_note is not None %}
|
||||
{% for note in notes.data %}
|
||||
{% trans "Update notes" as modal_title %}
|
||||
{% trans "Update" as modal_button %}
|
||||
{% url "participation:update_notes" pk=my_note.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="updateNotes" %}
|
||||
{% endif %}
|
||||
{% url "participation:update_notes" pk=note.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id=note.modal_name %}
|
||||
{% endfor %}
|
||||
{% elif user.registration.participates %}
|
||||
{% trans "Upload synthesis" as modal_title %}
|
||||
{% trans "Upload" as modal_button %}
|
||||
@ -141,9 +161,9 @@
|
||||
{% if notes is not None %}
|
||||
initModal("updatePassage", "{% url "participation:passage_update" pk=passage.pk %}")
|
||||
|
||||
{% if my_note is not None %}
|
||||
initModal("updateNotes", "{% url "participation:update_notes" pk=my_note.pk %}")
|
||||
{% endif %}
|
||||
{% for note in notes.data %}
|
||||
initModal("{{ note.modal_name }}", "{% url "participation:update_notes" pk=note.pk %}")
|
||||
{% endfor %}
|
||||
{% elif user.registration.participates %}
|
||||
initModal("uploadSynthesis", "{% url "participation:upload_synthesis" pk=passage.pk %}")
|
||||
{% endif %}
|
||||
|
@ -1,47 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="alert alert-info">
|
||||
<p>
|
||||
{% trans "You can here register juries for the pool." %}
|
||||
{% trans "Be careful: this form register new users. To add existing users into the jury, please use this form:" %}
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updatePoolModal">{% trans "Update pool" %}</button>
|
||||
</p>
|
||||
<p>
|
||||
{% trans "For now, the registered juries for the tournament are:" %}
|
||||
<ul>
|
||||
{% for jury in pool.juries.all %}
|
||||
<li>{{ jury.user.first_name }} {{ jury.user.last_name }} (<a class="alert-link" href="mailto:{{ jury.user.email }}">{{ jury.user.email }}</a>)</li>
|
||||
{% empty %}
|
||||
<li><i>{% trans "There is no jury yet." %}</i></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
{% crispy form %}
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row text-center">
|
||||
<a href="{% url 'participation:pool_detail' pk=pool.pk %}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> {% trans "Back to pool detail" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% trans "Update pool" as modal_title %}
|
||||
{% trans "Update" as modal_button %}
|
||||
{% url "participation:pool_update" pk=pool.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="updatePool" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initModal("updatePool", "{% url "participation:pool_update" pk=pool.pk %}")
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -5,7 +5,15 @@
|
||||
{% block content %}
|
||||
<div class="card bg-body shadow">
|
||||
<div class="card-header text-center">
|
||||
<h4>{{ pool }}</h4>
|
||||
<h4>
|
||||
{{ pool }}
|
||||
{% if user.registration.is_admin or user.registration in pool.tournament.organizers.all %}
|
||||
<button class="btn btn-sm btn-secondary" data-bs-toggle="modal" data-bs-target="#updatePoolModal">
|
||||
<i class="fas fa-edit"></i>
|
||||
{% trans "Update" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
@ -18,6 +26,11 @@
|
||||
<dt class="col-sm-3">{% trans "Letter:" %}</dt>
|
||||
<dd class="col-sm-9">{{ pool.get_letter_display }}</dd>
|
||||
|
||||
{% if pool.participations.count == 5 %}
|
||||
<dt class="col-sm-3">{% trans "Room:" %}</dt>
|
||||
<dd class="col-sm-9">{{ pool.get_room_display }}</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-3">{% trans "Teams:" %}</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% for participation in pool.participations.all %}
|
||||
@ -28,8 +41,8 @@
|
||||
<dt class="col-sm-3">{% trans "Juries:" %}</dt>
|
||||
<dd class="col-sm-9">
|
||||
{{ pool.juries.all|join:", " }}
|
||||
<a class="badge rounded-pill text-bg-info" href="{% url 'participation:pool_add_jurys' pk=pool.pk %}">
|
||||
<i class="fas fa-plus"></i> {% trans "Add jurys" %}
|
||||
<a class="badge rounded-pill text-bg-info" href="{% url 'participation:pool_jury' pk=pool.pk %}">
|
||||
<i class="fas fa-plus"></i> {% trans "Edit jury" %}
|
||||
</a>
|
||||
</dd>
|
||||
|
||||
@ -38,7 +51,7 @@
|
||||
{% for passage in pool.passages.all %}
|
||||
<a href="{{ passage.defended_solution.file.url }}">{{ passage.defender.team.trigram }} — {{ passage.get_solution_number_display }}</a>{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
<a href="{% url 'participation:pool_download_solutions' pk=pool.pk %}" class="badge rounded-pill text-bg-secondary">
|
||||
<a href="{% url 'participation:pool_download_solutions' pool_id=pool.id %}" class="badge rounded-pill text-bg-secondary">
|
||||
<i class="fas fa-download"></i> {% trans "Download all" %}
|
||||
</a>
|
||||
</dd>
|
||||
@ -57,13 +70,45 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<a href="{% url 'participation:pool_download_syntheses' pk=pool.pk %}" class="badge rounded-pill text-bg-secondary">
|
||||
<a href="{% url 'participation:pool_download_syntheses' pool_id=pool.id %}" class="badge rounded-pill text-bg-secondary">
|
||||
<i class="fas fa-download"></i> {% trans "Download all" %}
|
||||
</a>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">{% trans "BigBlueButton link:" %}</dt>
|
||||
<dd class="col-sm-9">{{ pool.bbb_url|urlize }}</dd>
|
||||
{% if pool.bbb_url %}
|
||||
<dt class="col-sm-3">{% trans "BigBlueButton link:" %}</dt>
|
||||
<dd class="col-sm-9">{{ pool.bbb_url|urlize }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if user.registration.is_admin or user.registration.is_volunteer %}
|
||||
{% if user.registration.is_admin or user.registration in pool.tournament.organizers.all or user.registration == pool.jury_president %}
|
||||
<dt class="col-sm-3">{% trans "Notation sheets:" %}</dt>
|
||||
<dd class="col-sm-9">
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-sm btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}">
|
||||
<i class="fas fa-download"></i>
|
||||
{% trans "Download the scale sheet" %}
|
||||
</a>
|
||||
<a class="btn btn-sm btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}">
|
||||
<i class="fas fa-download"></i>
|
||||
{% trans "Download the final notation sheet" %}
|
||||
</a>
|
||||
<a class="btn btn-sm btn-info" href="{% url 'participation:pool_notation_sheets' pool_id=pool.id %}">
|
||||
<i class="fas fa-archive"></i>
|
||||
{% trans "Download all notation sheets" %}
|
||||
</a>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">{% trans "Google Sheets Spreadsheet:" %}</dt>
|
||||
<dd class="col-sm-9">
|
||||
<a class="btn btn-sm btn-success" href="https://docs.google.com/spreadsheets/d/{{ pool.tournament.notes_sheet_id }}/edit">
|
||||
<i class="fas fa-table"></i>
|
||||
{% trans "Go to the Google Sheets page of the pool" %}
|
||||
</a>
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
<div class="card bg-body shadow">
|
||||
@ -77,40 +122,24 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% if user.registration.is_volunteer %}
|
||||
<div class="card-footer text-center">
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}">
|
||||
{% trans "Download the scale sheet" %}{% if pool.passages.count == 5 %} — {% trans "Room" %} 1{% endif %}
|
||||
</a>
|
||||
{% if pool.passages.count == 5 %}
|
||||
<a class="btn btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}?page=2">
|
||||
{% trans "Room" %} 2
|
||||
{% if user.registration.is_admin or user.registration.is_volunteer %}
|
||||
{% if user.registration.is_admin or user.registration in pool.tournament.organizers.all or user.registration == pool.jury_president %}
|
||||
<div class="card-footer text-center">
|
||||
<div class="btn btn-group">
|
||||
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#uploadNotesModal">
|
||||
<i class="fas fa-upload"></i>
|
||||
{% trans "Upload notes from a spreadsheet file" %}
|
||||
</button>
|
||||
<a class="btn btn-sm btn-info" href="{% url 'participation:pool_notes_template' pk=pool.pk %}">
|
||||
<i class="fas fa-download"></i>
|
||||
{% trans "Download notation spreadsheet" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}">
|
||||
{% trans "Download the final notation sheet" %}{% if pool.passages.count == 5 %} — {% trans "Room" %} 1{% endif %}
|
||||
</a>
|
||||
{% if pool.passages.count == 5 %}
|
||||
<a class="btn btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}?page=2">
|
||||
{% trans "Room" %} 2
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadNotesModal">{% trans "Upload notes from a CSV file" %}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if user.registration.is_volunteer %}
|
||||
<div class="card-footer text-center">
|
||||
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#addPassageModal">{% trans "Add passage" %}</button>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updatePoolModal">{% trans "Update" %}</button>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateTeamsModal">{% trans "Update teams" %}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
@ -119,21 +148,11 @@
|
||||
|
||||
{% render_table passages %}
|
||||
|
||||
{% trans "Add passage" as modal_title %}
|
||||
{% trans "Add" as modal_button %}
|
||||
{% url "participation:passage_create" pk=pool.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="addPassage" modal_button_type="success" %}
|
||||
|
||||
{% trans "Update pool" as modal_title %}
|
||||
{% trans "Update" as modal_button %}
|
||||
{% url "participation:pool_update" pk=pool.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="updatePool" %}
|
||||
|
||||
{% trans "Update teams" as modal_title %}
|
||||
{% trans "Update" as modal_button %}
|
||||
{% url "participation:pool_update_teams" pk=pool.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="updateTeams" %}
|
||||
|
||||
{% trans "Upload notes" as modal_title %}
|
||||
{% trans "Upload" as modal_button %}
|
||||
{% url "participation:pool_upload_notes" pk=pool.pk as modal_action %}
|
||||
@ -144,8 +163,6 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initModal("updatePool", "{% url "participation:pool_update" pk=pool.pk %}")
|
||||
initModal("updateTeams", "{% url "participation:pool_update_teams" pk=pool.pk %}")
|
||||
initModal("addPassage", "{% url "participation:passage_create" pk=pool.pk %}")
|
||||
initModal("uploadNotes", "{% url "participation:pool_upload_notes" pk=pool.pk %}")
|
||||
})
|
||||
</script>
|
||||
|
141
participation/templates/participation/pool_jury.html
Normal file
@ -0,0 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load crispy_forms_tags crispy_forms_filters %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="alert alert-info">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
On this page, you can manage the juries of the pool. You can add a new jury by entering the email address
|
||||
of the jury. If the jury is not registered, the account will be created automatically. If the jury already
|
||||
exists, its account will be autocompleted and directly linked to the pool.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
On this page, you can also define the president of the jury, who will have the right to see all solutions
|
||||
and if necessary define the notes of other jury members.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
{% for jury in pool.juries.all %}
|
||||
<div class="row my-3 px-0">
|
||||
<div class="col-md-5 px-1">
|
||||
<input type="email" class="form-control" value="{{ jury.user.email }}" disabled>
|
||||
</div>
|
||||
<div class="col-md-3 px-1">
|
||||
<input type="text" class="form-control" value="{{ jury.user.first_name }}" disabled>
|
||||
</div>
|
||||
<div class="col-md-3 px-1">
|
||||
<input type="text" class="form-control" value="{{ jury.user.last_name }}" disabled>
|
||||
</div>
|
||||
<div class="col-md-1 px-1">
|
||||
<div class="btn-group-vertical btn-group-sm">
|
||||
{% if jury == pool.jury_president %}
|
||||
<button class="btn btn-success">
|
||||
<i class="fas fa-crown"></i> {% trans "PoJ" %}
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{% url 'participation:pool_preside' pk=pool.pk jury_id=jury.id %}"
|
||||
class="btn btn-warning">
|
||||
<i class="fas fa-crown"></i> {% trans "Preside" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'participation:pool_remove_jury' pk=pool.pk jury_id=jury.id %}"
|
||||
class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> {% trans "Remove" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{{ form|as_crispy_errors }}
|
||||
{% crispy form %}
|
||||
|
||||
<datalist id="juries-email">
|
||||
</datalist>
|
||||
|
||||
<datalist id="juries-first-name">
|
||||
</datalist>
|
||||
|
||||
<datalist id="juries-last-name">
|
||||
</datalist>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row text-center">
|
||||
<a href="{% url 'participation:pool_detail' pk=pool.pk %}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> {% trans "Back to pool detail" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
const emailField = document.getElementById('id_email')
|
||||
const firstNameField = document.getElementById('id_first_name')
|
||||
const lastNameField = document.getElementById('id_last_name')
|
||||
|
||||
const juriesEmailList = document.getElementById('juries-email')
|
||||
const juriesFirstNameList = document.getElementById('juries-first-name')
|
||||
const juriesLastNameList = document.getElementById('juries-last-name')
|
||||
|
||||
function updateJuries(filter) {
|
||||
fetch(`/api/registration/volunteers/?search=${filter}`)
|
||||
.then(response => response.json())
|
||||
.then(response => response.results)
|
||||
.then(data => {
|
||||
juriesEmailList.innerHTML = ''
|
||||
juriesFirstNameList.innerHTML = ''
|
||||
juriesLastNameList.innerHTML = ''
|
||||
|
||||
data.forEach(jury => {
|
||||
const optionEmail = document.createElement('option')
|
||||
optionEmail.value = jury.email
|
||||
optionEmail.setAttribute('data-id', jury.id)
|
||||
optionEmail.text = `${jury.first_name} ${jury.last_name} (${jury.email})`
|
||||
juriesEmailList.appendChild(optionEmail)
|
||||
|
||||
const optionFirstName = document.createElement('option')
|
||||
optionFirstName.value = jury.first_name
|
||||
optionFirstName.setAttribute('data-id', jury.id)
|
||||
optionFirstName.text = `${jury.first_name} ${jury.last_name} (${jury.email})`
|
||||
juriesFirstNameList.appendChild(optionFirstName)
|
||||
|
||||
const optionLastName = document.createElement('option')
|
||||
optionLastName.value = jury.last_name
|
||||
optionLastName.setAttribute('data-id', jury.id)
|
||||
optionLastName.text = `${jury.first_name} ${jury.last_name} (${jury.email})`
|
||||
juriesLastNameList.appendChild(optionLastName)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
emailField.addEventListener('input', event => {
|
||||
let emailOption = document.querySelector(`datalist[id="juries-email"] > option[value="${event.target.value}"]`)
|
||||
if (emailOption) {
|
||||
let id = emailOption.getAttribute('data-id')
|
||||
let firstNameOption = document.querySelector(`datalist[id="juries-first-name"] > option[data-id="${id}"]`)
|
||||
let lastNameOption = document.querySelector(`datalist[id="juries-last-name"] > option[data-id="${id}"]`)
|
||||
if (firstNameOption && lastNameOption) {
|
||||
firstNameField.value = firstNameOption.value
|
||||
lastNameField.value = lastNameOption.value
|
||||
}
|
||||
}
|
||||
updateJuries(event.target.value)
|
||||
})
|
||||
firstNameField.addEventListener('input', event => {
|
||||
updateJuries(event.target.value)
|
||||
})
|
||||
lastNameField.addEventListener('input', event => {
|
||||
updateJuries(event.target.value)
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -10,19 +10,19 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-6 text-end">{% trans "Name:" %}</dt>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Name:" %}</dt>
|
||||
<dd class="col-sm-6">{{ team.name }}</dd>
|
||||
|
||||
<dt class="col-sm-6 text-end">{% trans "Trigram:" %}</dt>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Trigram:" %}</dt>
|
||||
<dd class="col-sm-6">{{ team.trigram }}</dd>
|
||||
|
||||
<dt class="col-sm-6 text-end">{% trans "Email:" %}</dt>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Email:" %}</dt>
|
||||
<dd class="col-sm-6"><a href="mailto:{{ team.email }}">{{ team.email }}</a></dd>
|
||||
|
||||
<dt class="col-sm-6 text-end">{% trans "Access code:" %}</dt>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Access code:" %}</dt>
|
||||
<dd class="col-sm-6">{{ team.access_code }}</dd>
|
||||
|
||||
<dt class="col-sm-6 text-end">{% trans "Coaches:" %}</dt>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Coaches:" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% for coach in team.coaches.all %}
|
||||
<a href="{% url "registration:user_detail" pk=coach.user.pk %}">{{ coach }}</a>{% if not forloop.last %},{% endif %}
|
||||
@ -31,7 +31,7 @@
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-6 text-end">{% trans "Participants:" %}</dt>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Participants:" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% for student in team.students.all %}
|
||||
<a href="{% url "registration:user_detail" pk=student.user.pk %}">{{ student }}</a>{% if not forloop.last %},{% endif %}
|
||||
@ -40,7 +40,7 @@
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-6 text-end">{% trans "Tournament:" %}</dt>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Tournament:" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% if team.participation.tournament %}
|
||||
<a href="{% url "participation:tournament_detail" pk=team.participation.tournament.pk %}">{{ team.participation.tournament }}</a>
|
||||
@ -49,7 +49,7 @@
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-6 text-end">{% trans "Photo authorizations:" %}</dt>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Photo authorizations:" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% for participant in team.participants.all %}
|
||||
{% if participant.photo_authorization %}
|
||||
@ -60,8 +60,21 @@
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
{% if team.participation.final %}
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Photo authorizations (final):" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% for participant in team.participants.all %}
|
||||
{% if participant.photo_authorization_final %}
|
||||
<a href="{{ participant.photo_authorization_final.url }}">{{ participant }}</a>{% if not forloop.last %},{% endif %}
|
||||
{% else %}
|
||||
{{ participant }} ({% trans "Not uploaded yet" %}){% if not forloop.last %},{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if not team.participation.tournament.remote %}
|
||||
<dt class="col-sm-6 text-end">{% trans "Health sheets:" %}</dt>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Health sheets:" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% for student in team.students.all %}
|
||||
{% if student.under_18 %}
|
||||
@ -74,7 +87,7 @@
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-6 text-end">{% trans "Vaccine sheets:" %}</dt>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Vaccine sheets:" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% for student in team.students.all %}
|
||||
{% if student.under_18 %}
|
||||
@ -87,7 +100,7 @@
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-6 text-end">{% trans "Parental authorizations:" %}</dt>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Parental authorizations:" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% for student in team.students.all %}
|
||||
{% if student.under_18 %}
|
||||
@ -99,9 +112,24 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
{% if team.participation.final %}
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Parental authorizations (final):" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% for student in team.students.all %}
|
||||
{% if student.under_18_final %}
|
||||
{% if student.parental_authorization_final %}
|
||||
<a href="{{ student.parental_authorization_final.url }}">{{ student }}</a>{% if not forloop.last %},{% endif %}
|
||||
{% else %}
|
||||
{{ student }} ({% trans "Not uploaded yet" %}){% if not forloop.last %},{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-6 text-end">{% trans "Motivation letter:" %}</dt>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Motivation letter:" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% if team.motivation_letter %}
|
||||
<a href="{{ team.motivation_letter.url }}">{% trans "Download" %}</a>
|
||||
@ -112,17 +140,40 @@
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadMotivationLetterModal">{% trans "Replace" %}</button>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{% if user.registration.is_volunteer %}
|
||||
{% if user.registration in self.team.participation.tournament.organizers or user.registration.is_admin %}
|
||||
<div class="text-center">
|
||||
<a class="btn btn-info" href="{% url "participation:team_authorizations" pk=team.pk %}">
|
||||
<a class="btn btn-info" href="{% url "participation:team_authorizations" team_id=team.id %}">
|
||||
<i class="fas fa-file-archive"></i> {% trans "Download all submitted authorizations" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if team.participation.valid %}
|
||||
<hr class="my-3">
|
||||
{% for student in team.students.all %}
|
||||
{% for payment in student.payments.all %}
|
||||
<dt class="col-sm-6 text-sm-end">
|
||||
{% trans "Payment of" %} {{ student }}
|
||||
{% if payment.grouped %}({% trans "grouped" %}){% endif %}
|
||||
{% if payment.final %} ({% trans "final" %}){% endif %} :
|
||||
</dt>
|
||||
<dd class="col-sm-6">
|
||||
Valide : {{ payment.valid|yesno }}
|
||||
{% if payment.valid is False or user.registration.is_volunteer %}
|
||||
{% if user.registration in payment.registrations.all or user.registration.is_coach or user.registration.is_volunteer %}
|
||||
<a href="{% url "registration:update_payment" pk=payment.pk %}" class="btn btn-secondary">
|
||||
<i class="fas fa-money-bill-wave"></i> {% trans "Update payment" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateTeamModal">{% trans "Update" %}</button>
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% load django_tables2 i18n %}
|
||||
|
||||
{% block contenttitle %}
|
||||
{% block content-title %}
|
||||
<h1>{% trans "All teams" %}</h1>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
\documentclass[12pt,a4paper,landscape]{article}
|
||||
\documentclass[11pt,a4paper,landscape]{article}
|
||||
|
||||
\usepackage[T1]{fontenc}
|
||||
\usepackage[utf8x]{inputenc}
|
||||
@ -22,7 +22,7 @@
|
||||
\addtolength{\textwidth}{4cm}
|
||||
\setlength{\parindent}{0mm}
|
||||
|
||||
\geometry{left=1.6cm,right=1.6cm,top=1.2cm,bottom=2cm}
|
||||
\geometry{left=1.6cm,right=1.6cm,top=1.2cm,bottom=1.2cm}
|
||||
|
||||
\newcommand{\tfjm}{$\mathbb{TFJM}^2$}
|
||||
\pagestyle{empty}
|
||||
@ -49,78 +49,75 @@
|
||||
\vspace{6mm}
|
||||
|
||||
%%%%%%%%%%%%%%%%%%%%%DEFENSEUR
|
||||
\begin{tabular}{|c|p{20mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
|
||||
\multicolumn{4}{|l|}{Læ {\bf D\'efenseur\textperiodcentered{}se} \normalsize pr\'esente les id\'ees et r\'esultats principaux pour la solution du probl\`eme.} {% for passage in passages.all %}& Passage {{ forloop.counter }} {% endfor %}\\ \hline \hline
|
||||
\begin{tabular}{|c|p{24mm}|p{11cm}|c|{% for passage in passages.all %}p{2cm}|{% endfor %}}\hline
|
||||
\multicolumn{4}{|l|}{Læ {\bf D\'efenseur\textperiodcentered{}se} \normalsize pr\'esente les id\'ees et r\'esultats principaux pour la solution du probl\`eme.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.defender.team.trigram }} {% endfor %}\\ \hline \hline
|
||||
|
||||
%ECRIT
|
||||
\multirow{6}{3mm}{\centering \bf\'E\\ C\\ R\\ I\\ T} & \multirow{3}{20mm}{Partie scientifique} & Profondeur des r\'esultats d\'emontr\'es & [0,5] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Originalit\'e et pertinence des preuves& [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Exactitude et justesse des d\'emonstrations, algorithmes, etc. & [0,7] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multirow{2}{20mm}{Forme} & Pr\'esentation (lisibilit\'e, etc.) & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Clart\'e du raisonnement : facile \`a comprendre ou compl\`etement obscur ? & [0,3]{{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
|
||||
\multirow{6}{3mm}{\centering \bf\'E\\ C\\ R\\ I\\ T} & \multirow{3}{20mm}{Partie scientifique} & Profondeur et difficulté des éléments présentés & [0,6] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Présence, exactitude et justesse des démonstrations et algorithmes & [0,6] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Pertinence, efficacité et élégance & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multirow{3}{20mm}{Forme}& Clarté du raisonnement (explications, exemples, illustrations, schémas, etc.) & [0,3]{{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Présentation (lisibilité, respect du format, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/20)} {{ esp|safe }} \\ \hline \hline
|
||||
|
||||
%ORAL
|
||||
\multirow{8}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{4}{20mm}{Partie scientifique} & Compr\'ehension du mat\'eriel, connaissance des sujets math\'ematiques correspondants \emph{lors de la pr\'esentation} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& P\'edagogie, notamment clart\'e, exactitude et justesse des d\'emonstrations \emph{lors de la pr\'esentation} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Capacit\'e \`a r\'eagir aux questions et remarques de l'Opposant\textperiodcentered{}e et de læ Rapporteur\textperiodcentered{}e & [0,4] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Capacit\'e \`a r\'eagir aux questions et remarques du jury & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multirow{3}{20mm}{Forme} & Bri\`evet\'e et propret\'e de la pr\'esentation & [0,2] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Capacit\'e de faire avancer le d\'ebat & [0,2] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& \emph{Conformit\'e} entre la pr\'esentation et le mat\'eriel \'ecrit & [--5,0] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/16)} {{ esp|safe }} \\ \hline
|
||||
\multirow{8}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{4}{20mm}{Présentation orale} & Compréhension du matériel présenté, connaissance et maîtrise des sujets mathématiques utilisés \emph{lors de la présentation} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Pertinence des choix (démonstrations, exemples, profondeur au regard de la solution écrite) & [0,4] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Pédagogie et clarté du discours (explications, illustrations, etc.) & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Brieveté et propreté de la présentation & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multirow{2}{20mm}{Débats} & Réponses correctes aux questions posées & [0,5] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Capacité de faire avancer le débat (expliquer les limites de ses connaissances, des conjectures, rechercher en direct, etc.) & [0,4] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multirow{2}{20mm}{Malus} & Attitude irrespectueuse ? & [--6,0] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Non-conformité de la présentation avec le matériel écrit ? & [--6,0] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/20)} {{ esp|safe }} \\ \hline
|
||||
|
||||
\end{tabular}
|
||||
|
||||
\newpage
|
||||
|
||||
%%%%%%%%%%%%%%%%%OPPOSANT
|
||||
\begin{tabular}{|c|p{20mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
|
||||
\begin{tabular}{|c|p{24mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
|
||||
\multicolumn{4}{|l|}{L' {\bf Opposant\textperiodcentered{}e} \normalsize fournit une analyse critique de la solution et de la pr\'esentation.}
|
||||
{% for passage in passages.all %}& Passage {{ forloop.counter }} {% endfor %} \\ \hline \hline
|
||||
{% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.opponent.team.trigram }} {% endfor %} \\ \hline \hline
|
||||
|
||||
%ECRIT
|
||||
\multirow{4}{3mm}{\centering\bf\'E\\ C\\ R\\ I\\ T} &\multirow{2}{20mm}{Partie scientifique} & Compr\'ehension du probl\`eme, savoir \'evaluer la qualit\'e g\'en\'erale de la solution & [0,4] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Rep\'erer les erreurs et leur importance & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
& Forme & Pr\'esentation (lisibilit\'e, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/9)} {{ esp|safe }} \\ \hline \hline
|
||||
\multirow{4}{3mm}{\centering\bf\'E\\ C\\ R\\ I\\ T} &\multirow{3}{20mm}{Partie scientifique} & Recul et esprit critique par rapport à la solution proposée & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Validité des erreurs et points positifs soulevés & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Repérer les erreurs et points positifs les plus importants et les hiérarchiser & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
& Forme & Pr\'esentation (lisibilité, respect du format, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/10)} {{ esp|safe }} \\ \hline \hline
|
||||
|
||||
%ORAL
|
||||
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{Partie scientifique} & Compr\'ehension du probl\`eme, savoir \'evaluer la qualit\'e g\'en\'erale de la pr\'esentation de læ D\'efenseur\textperiodcentered{}se
|
||||
& [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Rep\'erer les erreurs et leur importance & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Pertinence des questions & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
& Forme & M\`ene un d\'ebat de fa\c con comp\'etente et propre. & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{Questions et discours de l'opposant\textperiodcentered{}e} & Pertinence des questions (importance des sujets abordés, des points soulevés) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Gestion de l'échange (formulation des questions, réaction aux réponses, articulation entre les questions, gestion du temps) & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Capacité à évaluer la qualité de la prestation de læ Défenseur⋅se (présentation et réponses à l'Opposant⋅e) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&& Réponses aux questions de læ Rapporteur\textperiodcentered{}rice et du jury (fond et capacité à faire avancer le débat) & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
& Malus & Attitude irrespectueuse ? & [-3,0] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/10)} {{ esp|safe }}\\ \hline
|
||||
\end{tabular}
|
||||
|
||||
\vfill
|
||||
|
||||
%%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR
|
||||
\begin{tabular}{|c|p{20mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
|
||||
\multicolumn{4}{|l|}{Læ {\bf Rapporteur\textperiodcentered{}e} \normalsize \'evalue le d\'ebat entre læ D\'efenseur\textperiodcentered{}se et l'Opposant\textperiodcentered{}e.} {% for passage in passages.all %}& Passage {{ forloop.counter }} {% endfor %}\\ \hline \hline
|
||||
%%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR.RICE
|
||||
\begin{tabular}{|c|p{24mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
|
||||
\multicolumn{4}{|l|}{Læ {\bf Rapporteur\textperiodcentered{}rice} \normalsize \'evalue le d\'ebat entre læ D\'efenseur\textperiodcentered{}se et l'Opposant\textperiodcentered{}e.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.reporter.team.trigram }} {% endfor %}\\ \hline \hline
|
||||
|
||||
%ECRIT
|
||||
\multirow{4}{3mm}{\centering\bf\'E\\ C\\ R\\ I\\ T} &\multirow{2}{20mm}{Partie scientifique} & Compr\'ehension du probl\`eme, savoir \'evaluer la qualit\'e g\'en\'erale de la solution & [0,4] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
& & Rep\'erer les erreurs et leur importance & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
& Forme & Pr\'esentation (lisibilit\'e, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/9)} {{ esp|safe }}\\ \hline \hline
|
||||
\multirow{4}{3mm}{\centering\bf\'E\\ C\\ R\\ I\\ T} &\multirow{3}{20mm}{Partie scientifique} & Recul et esprit critique par rapport à la solution proposée & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Validité des erreurs et points positifs soulevés & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Repérer les erreurs et points positifs les plus importants et les hiérarchiser & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
& Forme & Présentation (lisibilité, respect du format, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/10)} {{ esp|safe }}\\ \hline \hline
|
||||
|
||||
%ORAL
|
||||
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} &\multirow{4}{20mm}{Partie scientifique} & Compr\'ehension du probl\`eme, savoir \'evaluer la qualit\'e g\'en\'erale de la pr\'esentation de læ D\'efenseur\textperiodcentered{}se & [0,1] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Savoir \'evaluer la qualit\'e g\'en\'erale du d\'ebat & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Rep\'erer les points importants non abord\'es & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Pertinence des questions & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
& Forme & M\`ene un d\'ebat de fa\c con comp\'etente et propre. & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
& \multicolumn{3}{|l|}{\bf TOTAL ORAL (/10)} {{ esp|safe }}\\ \hline
|
||||
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{Questions et discours de læ rapporteur\textperiodcentered{}rice} & \footnotesize Faire prendre de la hauteur au débat (par les sujets abordés, la pertinence des questions posées, les points soulevés, gestion du temps) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& \footnotesize Créer un échange constructif entre les participants (formulation des questions, réaction aux réponses, articulation entre les questions, circulation de la parole) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Capacité à évaluer la qualité des échanges (Défenseur⋅se-Opposant⋅e et à trois) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&& Réponses aux questions de læ Rapporteur\textperiodcentered{}rice et du jury (fond et capacité à faire avancer le débat) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
& Malus & Attitude irrespectueuse ? & [-3,0] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/10)} {{ esp|safe }}\\ \hline
|
||||
\end{tabular}
|
||||
|
||||
\vfill
|
||||
|
||||
{% if passages.count == 4 %}
|
||||
%%%%%%% INTERVENTION EXCEPTIONNELLE
|
||||
\begin{tabular}{|c|p{11cm}|c|p{2cm}|p{2cm}|p{2cm}|p{2cm}|}\hline
|
||||
\multicolumn{3}{|l|}{L'{\bf Intervention exceptionnelle} \normalsize permet de signaler une erreur grave omise par tous.} {% for passage in passages.all %}& Passage {{ forloop.counter }} {% endfor %}\\ \hline \hline
|
||||
%ORAL
|
||||
\multirow{1}{3mm}{\centering\bf O\\ R\\ A\\ L}
|
||||
& Toute intervention exceptionnelle non pertinente est sanctionn\'ee par une note n\'egative, l'absence d'intervention re\c coit un z\'ero forfaitaire. \phantom{pour avoir oral en entier dans la} \phantom{colonne il} \phantom{faut blablater un peu}& [-4,4] {{ esp|safe }}\\ \hline
|
||||
\end{tabular}
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
\end{document}
|
||||
|
@ -37,52 +37,36 @@
|
||||
|
||||
\Large {\bf \tfjmedition$^{e}$ Tournoi Fran\c cais des Jeunes Math\'ematiciennes et Math\'ematiciens \tfjm}\\
|
||||
\vspace{3mm}
|
||||
Tour {{ pool.round }} \;-- Poule {{ pool.get_letter_display }}{{ page }} \;-- {% if pool.round == 1 %}{{ pool.tournament.date_start }}{% else %}{{ pool.tournament.date_end }}{% endif %}
|
||||
Tour {{ pool.round }} \;-- Poule {{ pool.get_letter_display }}{% if pool.participations.count == 5 %} \;-- {{ pool.get_room_display }}{% endif %} \;-- {% if pool.round == 1 %}{{ pool.tournament.date_start }}{% else %}{{ pool.tournament.date_end }}{% endif %}
|
||||
|
||||
|
||||
\vspace{15mm}
|
||||
|
||||
|
||||
\begin{tabular}{|p{35mm}{% for passage in passages.all %}{% if passages.count == 3 %}|p{3cm}|p{3cm}{% else %}|p{2.5cm}|p{2.5cm}{% endif %}{% endfor %}|}\hline
|
||||
\multirow{2}{35mm}{\LARGE R\^ole} {% for passage in passages.all %}& \multicolumn{2}{c|}{ \Large Probl\`eme {{ passage.solution_number }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
|
||||
\begin{tabular}{|p{40mm}{% for passage in passages.all %}{% if passages.count == 3 %}|p{3cm}|p{3cm}{% else %}|p{2.5cm}|p{2.5cm}{% endif %}{% endfor %}|}\hline
|
||||
\multirow{2}{40mm}{\LARGE R\^ole} {% for passage in passages.all %}& \multicolumn{2}{c|}{ \Large Probl\`eme {{ passage.solution_number }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
|
||||
{% for passage in passages.all %}& \hspace{4mm} {\Large \'ECRIT} & \hspace{4mm} {\Large ORAL}{% endfor %} \\ \hline
|
||||
\multirow{2}{35mm}{\LARGE D\'efenseur\textperiodcentered{}se} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.defender.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
|
||||
{% for passage in passages.all %}
|
||||
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 20$
|
||||
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 16$
|
||||
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 20$
|
||||
{% endfor %} & \hline
|
||||
\multirow{2}{35mm}{\LARGE Opposant\textperiodcentered{}e} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.opponent.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
|
||||
{% for passage in passages.all %}
|
||||
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 9$
|
||||
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
|
||||
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
|
||||
{% endfor %} & \hline
|
||||
\multirow{2}{35mm}{\LARGE Rapporteur\textperiodcentered{}e} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.reporter.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
|
||||
\multirow{2}{35mm}{\LARGE Rapporteur\textperiodcentered{}rice} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.reporter.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
|
||||
{% for passage in passages.all %}
|
||||
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 9$
|
||||
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
|
||||
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
|
||||
{% endfor %} & \hline
|
||||
{% if passages.count == 4 %}
|
||||
\multirow{4}{35mm}{\Large Intervention exceptionnelle}{% for passage in passages.all %} & \multicolumn{2}{c|}{\Large {{ passage.observer.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}\\
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}\\
|
||||
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$}
|
||||
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$}
|
||||
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$}
|
||||
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$} & \hline
|
||||
{% endif %}
|
||||
|
||||
\end{tabular}
|
||||
|
||||
\vspace{15mm}
|
||||
|
||||
\LARGE Nom jur\'e\textperiodcentered{}e :
|
||||
{% if is_jury %}\underline{ {{ user.first_name|safe }} {{ user.last_name|safe }} }{% else %}\underline{\phantom{Phrase suffisamment longue pour le nom}}{% endif %}
|
||||
{% if jury %}\underline{ {{ jury.user.first_name|safe }} {{ jury.user.last_name|safe }} }{% else %}\underline{\phantom{Phrase suffisamment longue pour le nom}}{% endif %}
|
||||
$\qquad$ Signature : \underline{\phantom{Phrase moins longue}}
|
||||
|
||||
\newpage
|
||||
|
@ -9,60 +9,62 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-xl-6 text-end">{% trans 'organizers'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.organizers.all|join:", " }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'organizers'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.organizers.all|join:", " }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'size'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.max_teams }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'size'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.max_teams }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'place'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.place }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'place'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.place }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'price'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'price'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'remote'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.remote|yesno }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'remote'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.remote|yesno }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'dates'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{% trans "From" %} {{ tournament.date_start }} {% trans "to" %} {{ tournament.date_end }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'dates'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{% trans "From" %} {{ tournament.date_start }} {% trans "to" %} {{ tournament.date_end }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'date of registration closing'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.inscription_limit }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'date of registration closing'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.inscription_limit }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'date of maximal solution submission'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.solution_limit }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal solution submission'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.solution_limit }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'date of the random draw'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.solutions_draw }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'date of the random draw'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.solutions_draw }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'date of maximal syntheses submission for the first round'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.syntheses_first_phase_limit }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal syntheses submission for the first round'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.syntheses_first_phase_limit }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'date when solutions of round 2 are available'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.solutions_available_second_phase }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'date when solutions of round 2 are available'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.solutions_available_second_phase }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'date of maximal syntheses submission for the second round'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.syntheses_second_phase_limit }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal syntheses submission for the second round'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.syntheses_second_phase_limit }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'description'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.description }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'description'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.description }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'To contact organizers' %}</dt>
|
||||
<dd class="col-xl-6"><a href="mailto:{{ tournament.organizers_email }}">{{ tournament.organizers_email }}</a></dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'To contact organizers' %}</dt>
|
||||
<dd class="col-sm-6"><a href="mailto:{{ tournament.organizers_email }}">{{ tournament.organizers_email }}</a></dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'To contact juries' %}</dt>
|
||||
<dd class="col-xl-6"><a href="mailto:{{ tournament.jurys_email }}">{{ tournament.jurys_email }}</a></dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'To contact juries' %}</dt>
|
||||
<dd class="col-sm-6"><a href="mailto:{{ tournament.jurys_email }}">{{ tournament.jurys_email }}</a></dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'To contact valid teams' %}</dt>
|
||||
<dd class="col-xl-6"><a href="mailto:{{ tournament.teams_email }}">{{ tournament.teams_email }}</a></dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'To contact valid teams' %}</dt>
|
||||
<dd class="col-sm-6"><a href="mailto:{{ tournament.teams_email }}">{{ tournament.teams_email }}</a></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
|
||||
<div class="card-footer text-center">
|
||||
<a href="{% url "participation:tournament_update" pk=tournament.pk %}"><button class="btn btn-secondary">{% trans "Edit tournament" %}</button></a>
|
||||
<a href="{% url "participation:tournament_csv" pk=tournament.pk %}"><button class="btn btn-success">{% trans "Export as CSV" %}</button></a>
|
||||
<a class="btn btn-secondary" href="{% url "participation:tournament_update" pk=tournament.pk %}">
|
||||
<i class="fas fa-edit"></i>
|
||||
{% trans "Edit tournament" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -74,6 +76,14 @@
|
||||
{% render_table teams %}
|
||||
</div>
|
||||
|
||||
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
|
||||
<div class="text-center">
|
||||
<a href="{% url "participation:tournament_payments" pk=tournament.pk %}" class="btn btn-secondary">
|
||||
<i class="fas fa-money-bill-wave"></i> {% trans "Access to payments list" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if pools.data %}
|
||||
<hr>
|
||||
|
||||
@ -83,12 +93,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user.registration.is_admin %}
|
||||
<div class="d-grid">
|
||||
<button class="btn gap-0 btn-success" data-bs-toggle="modal" data-bs-target="#addPoolModal">{% trans "Add new pool" %}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if notes %}
|
||||
<hr>
|
||||
|
||||
@ -99,27 +103,159 @@
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
{% for participation, note in notes %}
|
||||
<li><strong>{{ participation.team }} :</strong> {{ note|floatformat }}</li>
|
||||
<li>
|
||||
<strong>{{ participation.team }} :</strong> {{ note|floatformat }}
|
||||
{% if available_notes_2 %}
|
||||
{% if not tournament.final and participation.mention %}
|
||||
— {{ participation.mention }}
|
||||
{% endif %}
|
||||
{% if tournament.final and participation.mention_final %}
|
||||
— {{ participation.mention_final }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if participation.final %}
|
||||
<span class="badge badge-sm text-bg-warning">
|
||||
<i class="fas fa-medal"></i>
|
||||
{% trans "Selected for final tournament" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
|
||||
{% if team_selectable_for_final == participation %}
|
||||
<a href="{% url 'participation:select_team_final' pk=tournament.pk participation_id=participation.pk %}"
|
||||
class="badge badge-sm text-bg-success">
|
||||
<i class="fas fa-medal"></i>
|
||||
{% trans "Select for final tournament" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
|
||||
<div class="card-footer text-center">
|
||||
<div class="btn-group">
|
||||
<a href="{% url 'participation:tournament_harmonize' pk=tournament.pk round=1 %}" class="btn btn-secondary">
|
||||
<i class="fas fa-ranking-star"></i>
|
||||
{% trans "Harmonize" %} - {% trans "Day" %} 1
|
||||
</a>
|
||||
<a href="{% url 'participation:tournament_harmonize' pk=tournament.pk round=2 %}" class="btn btn-secondary">
|
||||
<i class="fas fa-ranking-star"></i>
|
||||
{% trans "Harmonize" %} - {% trans "Day" %} 2
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<div class="btn-group">
|
||||
{% if not available_notes_1 %}
|
||||
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=1 %}" class="btn btn-sm btn-info">
|
||||
<i class="fas fa-eye"></i>
|
||||
{% trans "Publish notes for first round" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=1 %}?hide" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
{% trans "Unpublish notes for first round" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if not available_notes_2 %}
|
||||
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=2 %}" class="btn btn-sm btn-info">
|
||||
<i class="fas fa-eye"></i>
|
||||
{% trans "Publish notes for second round" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=2 %}?hide" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
{% trans "Unpublish notes for second round" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
|
||||
<hr>
|
||||
|
||||
<h3>{% trans "Files available for download" %}</h3>
|
||||
|
||||
<div class="alert alert-warning fade show files-to-download-collapse" id="files-to-download-popup">
|
||||
<h4>IMPORTANT</h4>
|
||||
|
||||
<p>
|
||||
Les fichiers accessibles ci-dessous peuvent contenir des informations personnelles.
|
||||
Par conformité avec le droit européen et par respect de la confidentialité des données
|
||||
des participant⋅es, vous ne devez utiliser ces données que dans un cadre strictement
|
||||
nécessaire en lien avec l'organisation du tournoi.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
De plus, il est de votre responsabilité de supprimer ces fichiers une fois que vous
|
||||
n'en avez plus besoin, notamment à la fin du tournoi.
|
||||
</p>
|
||||
|
||||
<p class="text-center">
|
||||
<button class="btn btn-warning" data-bs-toggle="collapse" href=".files-to-download-collapse"
|
||||
role="button" aria-expanded="false" aria-controls="files-to-download files-to-download-popup">
|
||||
Je m'engage à ne pas divulguer les données des participant⋅es
|
||||
et de les supprimer à l'issue du tournoi
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card bg-body shadow fade collapse files-to-download-collapse" id="files-to-download">
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{% url "participation:tournament_csv" pk=tournament.pk %}">
|
||||
Tableur de données des participant⋅es des équipes validées
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url "participation:tournament_csv" pk=tournament.pk %}?all">
|
||||
Tableur de données des participant⋅es de toutes les équipes
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url "participation:tournament_authorizations" tournament_id=tournament.id %}">
|
||||
Archive de toutes les autorisations triées par équipe et par personne
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url "participation:tournament_solutions" tournament_id=tournament.id %}">
|
||||
Archive de toutes les solutions envoyées triées par équipe
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url "participation:tournament_solutions" tournament_id=tournament.id %}?sort_by=problem">
|
||||
Archive de toutes les solutions envoyées triées par problème
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url "participation:tournament_solutions" tournament_id=tournament.id %}?sort_by=pool">
|
||||
Archive de toutes les solutions envoyées triées par poule
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url "participation:tournament_syntheses" tournament_id=tournament.id %}?sort_by=pool">
|
||||
Archive de toutes les notes de synthèse triées par poule et par passage
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://docs.google.com/spreadsheets/d/{{ tournament.notes_sheet_id }}/edit">
|
||||
<i class="fas fa-table"></i>
|
||||
Tableur de notes sur Google Sheets
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url "participation:tournament_notation_sheets" tournament_id=tournament.id %}">
|
||||
Archive de toutes les feuilles de notes à imprimer triées par poule
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user.registration.is_admin %}
|
||||
{% trans "Add pool" as modal_title %}
|
||||
{% trans "Add" as modal_button %}
|
||||
{% url "participation:pool_create" as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="addPool" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
{% if user.registration.is_admin %}
|
||||
initModal("addPool", "{% url "participation:pool_create" %}")
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-body shadow">
|
||||
<div class="card-header text-center">
|
||||
<h5>{% trans "Ranking" %}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped text-center">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Rank" %}</th>
|
||||
<th>{% trans "team"|capfirst %}</th>
|
||||
<th>{% trans "Note" %}</th>
|
||||
<th>{% trans "Including bonus / malus" %}</th>
|
||||
<th>{% trans "Add bonus / malus" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for participation, note in notes %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td>{{ participation.team }}</td>
|
||||
<td>{{ note.note|floatformat }}</td>
|
||||
<td>{% if note.tweak >= 0 %}+{% endif %}{{ note.tweak }}</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
<a href="{% url 'participation:tournament_harmonize_note' pk=tournament.pk round=round action="add" trigram=participation.team.trigram %}"
|
||||
class="btn btn-sm btn-success">
|
||||
+1
|
||||
</a>
|
||||
<a href="{% url 'participation:tournament_harmonize_note' pk=tournament.pk round=round action="remove" trigram=participation.team.trigram %}"
|
||||
class="btn btn-sm btn-danger">
|
||||
-1
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<a class="btn btn-secondary" href="{% url 'participation:tournament_detail' pk=tournament.pk %}">
|
||||
<i class="fas fa-arrow-left-long"></i>
|
||||
{% trans "Back to tournament page" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% load django_tables2 i18n %}
|
||||
|
||||
{% block contenttitle %}
|
||||
{% block content-title %}
|
||||
<h1>{% trans "All tournaments" %}</h1>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load django_tables2 i18n %}
|
||||
|
||||
{% block content %}
|
||||
{% render_table table %}
|
||||
|
||||
<a href="{% url "participation:tournament_detail" pk=tournament.pk %}" class="btn btn-primary">
|
||||
<i class="fas fa-long-arrow-alt-left"></i> {% trans "Back to tournament page" %}
|
||||
</a>
|
||||
{% endblock %}
|
@ -6,9 +6,16 @@
|
||||
{% block content %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<div id="form-content">
|
||||
<div class="alert alert-warning">
|
||||
{% url 'participation:pool_jury' pk=pool.jury as jury_url %}
|
||||
{% blocktrans trimmed with jury_url=jury_url %}
|
||||
Rows that are full of zeros are ignored.
|
||||
Unknown juries are not considered.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
<a class="alert-link" href="{% url "participation:pool_notes_template" pk=pool.pk %}">
|
||||
{% trans "Download empty notation sheet" %}
|
||||
{% trans "Download empty notation sheet" %}
|
||||
</a>
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
|
@ -9,6 +9,7 @@
|
||||
{% trans "Templates:" %}
|
||||
<a class="alert-link" href="{% static "Fiche_synthèse.pdf" %}"> PDF</a> —
|
||||
<a class="alert-link" href="{% static "Fiche_synthèse.tex" %}"> TEX</a> —
|
||||
<a class="alert-link" href="{% static "Fiche_synthèse.odt" %}"> ODT</a> —
|
||||
<a class="alert-link" href="{% static "Fiche_synthèse.docx" %}" title="{% trans "Warning: non-free format" %}"> DOCX</a>
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
|
@ -4,10 +4,12 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import mail
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
from django.test import LiveServerTestCase, override_settings, TestCase
|
||||
from django.urls import reverse
|
||||
from registration.models import CoachRegistration, StudentRegistration
|
||||
from registration.models import CoachRegistration, Payment, StudentRegistration
|
||||
|
||||
from .models import Participation, Team, Tournament
|
||||
|
||||
@ -438,6 +440,7 @@ class TestStudentParticipation(TestCase):
|
||||
self.user.registration.save()
|
||||
|
||||
# Team is valid
|
||||
self.team.participation.tournament = self.tournament
|
||||
self.team.participation.valid = True
|
||||
self.team.participation.save()
|
||||
response = self.client.post(reverse("participation:team_leave"))
|
||||
@ -479,6 +482,7 @@ class TestStudentParticipation(TestCase):
|
||||
reverse("participation:participation_detail", args=(self.team.participation.pk,)),
|
||||
302, 403)
|
||||
|
||||
self.team.participation.tournament = self.tournament
|
||||
self.team.participation.valid = True
|
||||
self.team.participation.save()
|
||||
response = self.client.get(reverse("participation:my_participation_detail"))
|
||||
@ -513,6 +517,616 @@ class TestStudentParticipation(TestCase):
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
|
||||
class TestPayment(TestCase):
|
||||
"""
|
||||
Tests that are relative to a payment
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.superuser = User.objects.create_superuser(
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
password="admin",
|
||||
)
|
||||
self.tournament = Tournament.objects.create(
|
||||
name="France",
|
||||
place="Here",
|
||||
price=21,
|
||||
)
|
||||
self.team = Team.objects.create(
|
||||
name="Super team",
|
||||
trigram="AAA",
|
||||
access_code="azerty",
|
||||
)
|
||||
self.user = User.objects.create(
|
||||
first_name="Toto",
|
||||
last_name="Toto",
|
||||
email="toto@example.com",
|
||||
password="toto",
|
||||
)
|
||||
StudentRegistration.objects.create(
|
||||
user=self.user,
|
||||
team=self.team,
|
||||
student_class=12,
|
||||
address="1 Rue de Rivoli",
|
||||
zip_code=75001,
|
||||
city="Paris",
|
||||
school="Earth",
|
||||
give_contact_to_animath=True,
|
||||
email_confirmed=True,
|
||||
)
|
||||
self.second_user = User.objects.create(
|
||||
first_name="Lalala",
|
||||
last_name="Lalala",
|
||||
email="lalala@example.com",
|
||||
password="lalala",
|
||||
)
|
||||
StudentRegistration.objects.create(
|
||||
user=self.second_user,
|
||||
team=self.team,
|
||||
student_class=11,
|
||||
address="1 Rue de Rivoli",
|
||||
zip_code=75001,
|
||||
city="Paris",
|
||||
school="Moon",
|
||||
give_contact_to_animath=True,
|
||||
email_confirmed=True,
|
||||
)
|
||||
self.coach = User.objects.create(
|
||||
first_name="Coach",
|
||||
last_name="Coach",
|
||||
email="coach@example.com",
|
||||
password="coach",
|
||||
)
|
||||
CoachRegistration.objects.create(
|
||||
user=self.coach,
|
||||
team=self.team,
|
||||
address="1 Rue de Rivoli",
|
||||
zip_code=75001,
|
||||
city="Paris",
|
||||
)
|
||||
|
||||
self.team.participation.tournament = self.tournament
|
||||
self.team.participation.valid = True
|
||||
self.team.participation.save()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_check_payments_exists(self):
|
||||
"""
|
||||
Check that users in a validated team have an invalid payment, but not for the final,
|
||||
and that coaches are not concerned.
|
||||
"""
|
||||
self.assertTrue(Payment.objects.filter(final=False, valid=False, type='',
|
||||
registrations=self.user.registration).exists())
|
||||
self.assertTrue(Payment.objects.filter(final=False, valid=False, type='',
|
||||
registrations=self.second_user.registration).exists())
|
||||
self.assertFalse(Payment.objects.filter(final=False, valid=False, type='',
|
||||
registrations=self.coach.registration).exists())
|
||||
|
||||
self.assertFalse(Payment.objects.filter(final=True, valid=False, type='',
|
||||
registrations=self.user.registration).exists())
|
||||
self.assertFalse(Payment.objects.filter(final=True, valid=False, type='',
|
||||
registrations=self.second_user.registration).exists())
|
||||
self.assertFalse(Payment.objects.filter(final=True, valid=False, type='',
|
||||
registrations=self.coach.registration).exists())
|
||||
|
||||
def test_load_payment_page(self):
|
||||
"""
|
||||
Ensure that the payment page loads correctly.
|
||||
"""
|
||||
response = self.client.get(reverse('participation:team_detail', args=(self.team.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(reverse('registration:user_detail', args=(self.user.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(reverse('participation:tournament_payments', args=(self.tournament.pk,)))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_bank_transfer_payment(self):
|
||||
"""
|
||||
Try to send a bank transfer.
|
||||
"""
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
|
||||
data={'type': "bank_transfer",
|
||||
'additional_information': "This is a bank transfer"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFormError(response.context['form'], 'receipt',
|
||||
["This field is required.", "You must upload your receipt."])
|
||||
payment.refresh_from_db()
|
||||
self.assertFalse(payment.valid)
|
||||
self.assertEqual(payment.type, "")
|
||||
|
||||
# README is not a valid PDF file
|
||||
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
|
||||
data={'type': "bank_transfer",
|
||||
'additional_information': "This is a bank transfer",
|
||||
'receipt': open("README.md", "rb")})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFormError(response.context['form'], 'receipt',
|
||||
["The uploaded file must be a PDF, PNG of JPEG file."])
|
||||
self.assertFalse(payment.valid)
|
||||
self.assertEqual(payment.type, "")
|
||||
|
||||
# Don't send too large files
|
||||
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
|
||||
data={'type': "bank_transfer",
|
||||
'additional_information': "This is a bank transfer",
|
||||
'receipt': SimpleUploadedFile(
|
||||
"file.pdf",
|
||||
content=int(0).to_bytes(2000001, "big"),
|
||||
content_type="application/pdf"),
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFormError(response.context['form'], 'receipt',
|
||||
["The uploaded file size must be under 2 Mo."])
|
||||
self.assertFalse(payment.valid)
|
||||
self.assertEqual(payment.type, "")
|
||||
|
||||
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
|
||||
data={'type': "bank_transfer",
|
||||
'additional_information': "This is a bank transfer",
|
||||
'receipt': open("tfjm/static/Fiche_sanitaire.pdf", "rb")})
|
||||
self.assertRedirects(response, reverse('participation:team_detail', args=(self.team.pk,)), 302, 200)
|
||||
payment.refresh_from_db()
|
||||
self.assertIsNone(payment.valid)
|
||||
self.assertEqual(payment.type, "bank_transfer")
|
||||
self.assertEqual(payment.additional_information, "This is a bank transfer")
|
||||
self.assertIsNotNone(payment.receipt)
|
||||
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_scholarship(self):
|
||||
"""
|
||||
Try to don't pay because of a scholarship.
|
||||
"""
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
|
||||
data={'type': "scholarship",
|
||||
'additional_information': "I don't have to pay because I have a scholarship"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFormError(response.context['form'], 'receipt',
|
||||
["This field is required.", "You must upload your receipt."])
|
||||
payment.refresh_from_db()
|
||||
self.assertFalse(payment.valid)
|
||||
self.assertEqual(payment.type, "")
|
||||
self.assertEqual(payment.amount, self.tournament.price)
|
||||
|
||||
# README is not a valid PDF file
|
||||
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
|
||||
data={'type': "scholarship",
|
||||
'additional_information': "I don't have to pay because I have a scholarship",
|
||||
'receipt': open("README.md", "rb")})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFormError(response.context['form'], 'receipt',
|
||||
["The uploaded file must be a PDF, PNG of JPEG file."])
|
||||
self.assertFalse(payment.valid)
|
||||
self.assertEqual(payment.type, "")
|
||||
self.assertEqual(payment.amount, self.tournament.price)
|
||||
|
||||
# Don't send too large files
|
||||
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
|
||||
data={'type': "scholarship",
|
||||
'additional_information': "I don't have to pay because I have a scholarship",
|
||||
'receipt': SimpleUploadedFile(
|
||||
"file.pdf",
|
||||
content=int(0).to_bytes(2000001, "big"),
|
||||
content_type="application/pdf"),
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFormError(response.context['form'], 'receipt',
|
||||
["The uploaded file size must be under 2 Mo."])
|
||||
self.assertFalse(payment.valid)
|
||||
self.assertEqual(payment.type, "")
|
||||
self.assertEqual(payment.amount, self.tournament.price)
|
||||
|
||||
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
|
||||
data={'type': "scholarship",
|
||||
'additional_information': "I don't have to pay because I have a scholarship",
|
||||
'receipt': open("tfjm/static/Fiche_sanitaire.pdf", "rb")})
|
||||
self.assertRedirects(response, reverse('participation:team_detail', args=(self.team.pk,)), 302, 200)
|
||||
payment.refresh_from_db()
|
||||
self.assertIsNone(payment.valid)
|
||||
self.assertEqual(payment.type, "scholarship")
|
||||
self.assertEqual(payment.additional_information, "I don't have to pay because I have a scholarship")
|
||||
self.assertIsNotNone(payment.receipt)
|
||||
self.assertEqual(payment.amount, 0)
|
||||
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_other(self):
|
||||
"""
|
||||
Try to send a different type of payment.
|
||||
"""
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
|
||||
data={'type': "other"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFormError(response.context['form'], 'additional_information',
|
||||
["This field is required."])
|
||||
payment.refresh_from_db()
|
||||
self.assertFalse(payment.valid)
|
||||
self.assertEqual(payment.type, "")
|
||||
self.assertEqual(payment.amount, self.tournament.price)
|
||||
|
||||
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
|
||||
data={'type': "other",
|
||||
'additional_information': "Why should I pay"})
|
||||
self.assertRedirects(response, reverse('participation:team_detail', args=(self.team.pk,)), 302, 200)
|
||||
payment.refresh_from_db()
|
||||
self.assertIsNone(payment.valid)
|
||||
self.assertEqual(payment.type, "other")
|
||||
self.assertEqual(payment.additional_information, "Why should I pay")
|
||||
self.assertIsNotNone(payment.receipt)
|
||||
self.assertEqual(payment.amount, self.tournament.price)
|
||||
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_group(self):
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
self.assertFalse(payment.grouped)
|
||||
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(reverse('registration:update_payment_group_mode', args=(payment.pk,)))
|
||||
self.assertRedirects(response, reverse('registration:update_payment', args=(payment.pk,)), 302, 200)
|
||||
payment.refresh_from_db()
|
||||
self.assertTrue(payment.grouped)
|
||||
self.assertEqual(Payment.objects.count(), 1)
|
||||
self.assertIn(self.user.registration, payment.registrations.all())
|
||||
self.assertIn(self.second_user.registration, payment.registrations.all())
|
||||
self.assertEqual(payment.amount, 2 * self.tournament.price)
|
||||
|
||||
def test_ungroup(self):
|
||||
"""
|
||||
Test to ungroup payments
|
||||
"""
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
self.client.get(reverse('registration:update_payment_group_mode', args=(payment.pk,)))
|
||||
payment.refresh_from_db()
|
||||
self.assertTrue(payment.grouped)
|
||||
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(reverse('registration:update_payment_group_mode', args=(payment.pk,)))
|
||||
self.assertRedirects(response, reverse('registration:update_payment', args=(payment.pk,)), 302, 200)
|
||||
payment.refresh_from_db()
|
||||
self.assertFalse(payment.grouped)
|
||||
self.assertEqual(Payment.objects.count(), 2)
|
||||
self.assertIn(self.user.registration, payment.registrations.all())
|
||||
self.assertNotIn(self.second_user.registration, payment.registrations.all())
|
||||
self.assertEqual(payment.amount, self.tournament.price)
|
||||
|
||||
def test_group_forbidden(self):
|
||||
"""
|
||||
Payment grouping is forbidden if at least one payment is already valid.
|
||||
"""
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
payment.valid = True
|
||||
payment.save()
|
||||
payment2 = Payment.objects.get(registrations=self.second_user.registration, final=False)
|
||||
response = self.client.get(reverse('registration:update_payment_group_mode', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = self.client.get(reverse('registration:update_payment_group_mode', args=(payment2.pk,)))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_validate_payment(self):
|
||||
"""
|
||||
Try to validate a payment.
|
||||
"""
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
payment.type = "other"
|
||||
payment.valid = None
|
||||
payment.save()
|
||||
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
|
||||
data={'valid': True})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertFalse(payment.valid)
|
||||
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
|
||||
data={'valid': True})
|
||||
self.assertRedirects(response, reverse('participation:team_detail', args=(self.team.pk,)), 302, 200)
|
||||
payment.refresh_from_db()
|
||||
self.assertTrue(payment.valid)
|
||||
|
||||
def test_invalidate_payment(self):
|
||||
"""
|
||||
Try to invalidate a payment.
|
||||
"""
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
payment.type = "other"
|
||||
payment.valid = None
|
||||
payment.save()
|
||||
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
|
||||
data={'valid': False})
|
||||
self.assertRedirects(response, reverse('participation:team_detail', args=(self.team.pk,)), 302, 200)
|
||||
payment.refresh_from_db()
|
||||
self.assertFalse(payment.valid)
|
||||
|
||||
def test_payment_reminder(self):
|
||||
"""
|
||||
Check that the payment reminder command works correctly.
|
||||
"""
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
call_command('remind_payments')
|
||||
self.assertEqual(len(mail.outbox), 2)
|
||||
self.assertEqual(mail.outbox[0].subject, "[TFJM²] Rappel pour votre paiement")
|
||||
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
payment2 = Payment.objects.get(registrations=self.second_user.registration, final=False)
|
||||
payment.type = 'other'
|
||||
payment.valid = True
|
||||
payment.save()
|
||||
payment2.type = 'bank_transfer'
|
||||
payment2.valid = None
|
||||
payment2.save()
|
||||
|
||||
mail.outbox = []
|
||||
call_command('remind_payments')
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
|
||||
@override_settings(HELLOASSO_TEST_ENDPOINT=True, ROOT_URLCONF="tfjm.helloasso.test_urls")
|
||||
class TestHelloAssoPayment(LiveServerTestCase):
|
||||
"""
|
||||
Tests that are relative to a HelloAsso
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.superuser = User.objects.create_superuser(
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
password="admin",
|
||||
)
|
||||
self.tournament = Tournament.objects.create(
|
||||
name="France",
|
||||
place="Here",
|
||||
price=21,
|
||||
)
|
||||
self.team = Team.objects.create(
|
||||
name="Super team",
|
||||
trigram="AAA",
|
||||
access_code="azerty",
|
||||
)
|
||||
self.user = User.objects.create(
|
||||
first_name="Toto",
|
||||
last_name="Toto",
|
||||
email="toto@example.com",
|
||||
password="toto",
|
||||
)
|
||||
StudentRegistration.objects.create(
|
||||
user=self.user,
|
||||
team=self.team,
|
||||
student_class=12,
|
||||
address="1 Rue de Rivoli",
|
||||
zip_code=75001,
|
||||
city="Paris",
|
||||
school="Earth",
|
||||
give_contact_to_animath=True,
|
||||
email_confirmed=True,
|
||||
)
|
||||
self.coach = User.objects.create(
|
||||
first_name="Coach",
|
||||
last_name="Coach",
|
||||
email="coach@example.com",
|
||||
password="coach",
|
||||
)
|
||||
CoachRegistration.objects.create(
|
||||
user=self.coach,
|
||||
team=self.team,
|
||||
address="1 Rue de Rivoli",
|
||||
zip_code=75001,
|
||||
city="Paris",
|
||||
)
|
||||
|
||||
self.team.participation.tournament = self.tournament
|
||||
self.team.participation.valid = True
|
||||
self.team.participation.save()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
Site.objects.update(domain=self.live_server_url.replace("http://", ""))
|
||||
|
||||
def test_create_checkout_intent(self):
|
||||
with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url):
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
checkout_intent = payment.create_checkout_intent()
|
||||
|
||||
self.assertIsNotNone(checkout_intent)
|
||||
self.assertEqual(checkout_intent['metadata'], {
|
||||
'payment_id': payment.pk,
|
||||
'users': [
|
||||
{
|
||||
'user_id': self.user.pk,
|
||||
'first_name': self.user.first_name,
|
||||
'last_name': self.user.last_name,
|
||||
'email': self.user.email,
|
||||
}
|
||||
],
|
||||
'final': False,
|
||||
'tournament_id': self.tournament.pk,
|
||||
})
|
||||
self.assertNotIn('order', checkout_intent)
|
||||
|
||||
checkout_intent_fetched = payment.get_checkout_intent()
|
||||
self.assertEqual(checkout_intent, checkout_intent_fetched)
|
||||
|
||||
# Don't create a new checkout intent if one already exists
|
||||
checkout_intent_new = payment.create_checkout_intent()
|
||||
self.assertEqual(checkout_intent, checkout_intent_new)
|
||||
|
||||
payment.refresh_from_db()
|
||||
self.assertEqual(payment.checkout_intent_id, checkout_intent['id'])
|
||||
self.assertFalse(payment.valid)
|
||||
|
||||
def test_helloasso_payment_success(self):
|
||||
"""
|
||||
Simulates the redirection to Hello Asso and the return for a successful payment.
|
||||
"""
|
||||
with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url):
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
self.assertIsNone(payment.checkout_intent_id)
|
||||
self.assertFalse(payment.valid)
|
||||
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(reverse('registration:payment_hello_asso', args=(payment.pk,)),
|
||||
follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.redirect_chain[-1],
|
||||
(reverse('participation:team_detail', args=(self.team.pk,)), 302))
|
||||
self.assertIn("type=return", response.redirect_chain[1][0])
|
||||
self.assertIn("code=succeeded", response.redirect_chain[1][0])
|
||||
|
||||
payment.refresh_from_db()
|
||||
self.assertIsNotNone(payment.checkout_intent_id)
|
||||
self.assertTrue(payment.valid)
|
||||
|
||||
checkout_intent = payment.get_checkout_intent()
|
||||
self.assertIn('order', checkout_intent)
|
||||
|
||||
def test_helloasso_payment_refused(self):
|
||||
"""
|
||||
Simulates the redirection to Hello Asso and the return for a refused payment.
|
||||
"""
|
||||
with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url):
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
checkout_intent = payment.create_checkout_intent()
|
||||
self.assertFalse(payment.valid)
|
||||
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(checkout_intent['redirectUrl'] + "?refused", follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.redirect_chain[-1],
|
||||
(reverse('registration:update_payment', args=(payment.pk,)), 302))
|
||||
self.assertIn("type=return", response.redirect_chain[0][0])
|
||||
self.assertIn("code=refused", response.redirect_chain[0][0])
|
||||
|
||||
payment.refresh_from_db()
|
||||
self.assertFalse(payment.valid)
|
||||
|
||||
checkout_intent = payment.get_checkout_intent()
|
||||
self.assertNotIn('order', checkout_intent)
|
||||
|
||||
def test_helloasso_payment_error(self):
|
||||
"""
|
||||
Simulates the redirection to Hello Asso and the return for an errored payment.
|
||||
"""
|
||||
with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url):
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
checkout_intent = payment.create_checkout_intent()
|
||||
self.assertFalse(payment.valid)
|
||||
|
||||
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(checkout_intent['redirectUrl'] + "?error", follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.redirect_chain[-1],
|
||||
(reverse('registration:update_payment', args=(payment.pk,)), 302))
|
||||
self.assertIn("type=error", response.redirect_chain[0][0])
|
||||
self.assertIn("error=", response.redirect_chain[0][0])
|
||||
|
||||
payment.refresh_from_db()
|
||||
self.assertFalse(payment.valid)
|
||||
|
||||
checkout_intent = payment.get_checkout_intent()
|
||||
self.assertNotIn('order', checkout_intent)
|
||||
|
||||
def test_anonymous_payment(self):
|
||||
"""
|
||||
Test to make a successful payment from an anonymous user, authenticated by token.
|
||||
"""
|
||||
self.client.logout()
|
||||
|
||||
with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url):
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
self.assertIsNone(payment.checkout_intent_id)
|
||||
self.assertFalse(payment.valid)
|
||||
|
||||
response = self.client.get(reverse('registration:payment_hello_asso', args=(payment.pk,)),
|
||||
follow=True)
|
||||
self.assertRedirects(response,
|
||||
f"{reverse('login')}?next="
|
||||
f"{reverse('registration:payment_hello_asso', args=(payment.pk,))}")
|
||||
|
||||
response = self.client.get(
|
||||
reverse('registration:payment_hello_asso', args=(payment.pk,)) + "?token=" + payment.token,
|
||||
follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.redirect_chain[-1], (reverse('index'), 302))
|
||||
self.assertIn("type=return", response.redirect_chain[1][0])
|
||||
self.assertIn("code=succeeded", response.redirect_chain[1][0])
|
||||
|
||||
payment.refresh_from_db()
|
||||
self.assertIsNotNone(payment.checkout_intent_id)
|
||||
self.assertTrue(payment.valid)
|
||||
|
||||
checkout_intent = payment.get_checkout_intent()
|
||||
self.assertIn('order', checkout_intent)
|
||||
|
||||
def test_hello_asso_payment_verification(self):
|
||||
"""
|
||||
Check that a payment that is pending verification can be verified.
|
||||
"""
|
||||
with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url):
|
||||
payment = Payment.objects.get(registrations=self.user.registration, final=False)
|
||||
self.assertFalse(payment.valid)
|
||||
|
||||
call_command('check_hello_asso')
|
||||
payment.refresh_from_db()
|
||||
self.assertFalse(payment.valid)
|
||||
|
||||
self.client.get(reverse('registration:payment_hello_asso', args=(payment.pk,)),
|
||||
follow=True)
|
||||
|
||||
payment.refresh_from_db()
|
||||
payment.valid = None
|
||||
payment.additional_information = ""
|
||||
payment.save()
|
||||
self.assertIsNone(payment.valid)
|
||||
|
||||
call_command('check_hello_asso')
|
||||
payment.refresh_from_db()
|
||||
self.assertTrue(payment.valid)
|
||||
self.assertTrue(payment.additional_information)
|
||||
|
||||
|
||||
class TestAdmin(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_superuser(
|
||||
|
@ -2,15 +2,17 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.urls import path
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from .views import CreateTeamView, FinalNotationSheetTemplateView, JoinTeamView, MyParticipationDetailView, \
|
||||
MyTeamDetailView, NoteUpdateView, ParticipationDetailView, PassageCreateView, PassageDetailView, \
|
||||
PassageUpdateView, PoolAddJurysView, PoolCreateView, PoolDetailView, PoolDownloadView, PoolNotesTemplateView, \
|
||||
PoolUpdateTeamsView, PoolUpdateView, PoolUploadNotesView, ScaleNotationSheetTemplateView, SolutionUploadView, \
|
||||
SynthesisUploadView, TeamAuthorizationsView, TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, \
|
||||
from .views import CreateTeamView, FinalNotationSheetTemplateView, GSheetNotificationsView, JoinTeamView, \
|
||||
MyParticipationDetailView, MyTeamDetailView, NotationSheetsArchiveView, NoteUpdateView, ParticipationDetailView, \
|
||||
PassageDetailView, PassageUpdateView, PoolCreateView, PoolDetailView, PoolJuryView, PoolNotesTemplateView, \
|
||||
PoolPresideJuryView, PoolRemoveJuryView, PoolUpdateView, PoolUploadNotesView, \
|
||||
ScaleNotationSheetTemplateView, SelectTeamFinalView, \
|
||||
SolutionsDownloadView, SolutionUploadView, SynthesisUploadView, \
|
||||
TeamAuthorizationsView, TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, \
|
||||
TeamUploadMotivationLetterView, TournamentCreateView, TournamentDetailView, TournamentExportCSVView, \
|
||||
TournamentListView, TournamentUpdateView
|
||||
TournamentHarmonizeNoteView, TournamentHarmonizeView, TournamentListView, TournamentPaymentsView, \
|
||||
TournamentPublishNotesView, TournamentUpdateView
|
||||
|
||||
|
||||
app_name = "participation"
|
||||
@ -24,31 +26,51 @@ urlpatterns = [
|
||||
path("team/<int:pk>/update/", TeamUpdateView.as_view(), name="update_team"),
|
||||
path("team/<int:pk>/upload-motivation-letter/", TeamUploadMotivationLetterView.as_view(),
|
||||
name="upload_team_motivation_letter"),
|
||||
path("team/<int:pk>/authorizations/", TeamAuthorizationsView.as_view(), name="team_authorizations"),
|
||||
path("team/<int:team_id>/authorizations/", TeamAuthorizationsView.as_view(), name="team_authorizations"),
|
||||
path("team/leave/", TeamLeaveView.as_view(), name="team_leave"),
|
||||
path("detail/", MyParticipationDetailView.as_view(), name="my_participation_detail"),
|
||||
path("detail/<int:pk>/", ParticipationDetailView.as_view(), name="participation_detail"),
|
||||
path("detail/<int:pk>/solution/", SolutionUploadView.as_view(), name="upload_solution"),
|
||||
path("detail/<int:team_id>/solutions/", SolutionsDownloadView.as_view(), name="participation_solutions"),
|
||||
path("tournament/", TournamentListView.as_view(), name="tournament_list"),
|
||||
path("tournament/create/", TournamentCreateView.as_view(), name="tournament_create"),
|
||||
path("tournament/<int:pk>/", TournamentDetailView.as_view(), name="tournament_detail"),
|
||||
path("tournament/<int:pk>/update/", TournamentUpdateView.as_view(), name="tournament_update"),
|
||||
path("tournament/<int:pk>/payments/", TournamentPaymentsView.as_view(), name="tournament_payments"),
|
||||
path("tournament/<int:pk>/csv/", TournamentExportCSVView.as_view(), name="tournament_csv"),
|
||||
path("tournament/<int:tournament_id>/authorizations/", TeamAuthorizationsView.as_view(),
|
||||
name="tournament_authorizations"),
|
||||
path("tournament/<int:tournament_id>/solutions/", SolutionsDownloadView.as_view(),
|
||||
name="tournament_solutions"),
|
||||
path("tournament/<int:tournament_id>/syntheses/", SolutionsDownloadView.as_view(),
|
||||
name="tournament_syntheses"),
|
||||
path("tournament/<int:tournament_id>/notation/sheets/", NotationSheetsArchiveView.as_view(),
|
||||
name="tournament_notation_sheets"),
|
||||
path("tournament/<int:pk>/notation/notifications/", GSheetNotificationsView.as_view(),
|
||||
name="tournament_gsheet_notifications"),
|
||||
path("tournament/<int:pk>/publish-notes/<int:round>/", TournamentPublishNotesView.as_view(),
|
||||
name="tournament_publish_notes"),
|
||||
path("tournament/<int:pk>/harmonize/<int:round>/", TournamentHarmonizeView.as_view(),
|
||||
name="tournament_harmonize"),
|
||||
path("tournament/<int:pk>/harmonize/<int:round>/<str:action>/<str:trigram>/", TournamentHarmonizeNoteView.as_view(),
|
||||
name="tournament_harmonize_note"),
|
||||
path("tournament/<int:pk>/select-final/<int:participation_id>/", SelectTeamFinalView.as_view(),
|
||||
name="select_team_final"),
|
||||
path("pools/create/", PoolCreateView.as_view(), name="pool_create"),
|
||||
path("pools/<int:pk>/", PoolDetailView.as_view(), name="pool_detail"),
|
||||
path("pools/<int:pk>/update/", PoolUpdateView.as_view(), name="pool_update"),
|
||||
path("pools/<int:pk>/solutions/", PoolDownloadView.as_view(), name="pool_download_solutions"),
|
||||
path("pools/<int:pk>/syntheses/", PoolDownloadView.as_view(), name="pool_download_syntheses"),
|
||||
path("pools/<int:pool_id>/solutions/", SolutionsDownloadView.as_view(), name="pool_download_solutions"),
|
||||
path("pools/<int:pool_id>/syntheses/", SolutionsDownloadView.as_view(), name="pool_download_syntheses"),
|
||||
path("pools/<int:pk>/notation/scale/", ScaleNotationSheetTemplateView.as_view(), name="pool_scale_note_sheet"),
|
||||
path("pools/<int:pk>/notation/final/", FinalNotationSheetTemplateView.as_view(), name="pool_final_note_sheet"),
|
||||
path("pools/<int:pk>/update-teams/", PoolUpdateTeamsView.as_view(), name="pool_update_teams"),
|
||||
path("pools/<int:pk>/add-jurys/", PoolAddJurysView.as_view(), name="pool_add_jurys"),
|
||||
path("pools/<int:pool_id>/notation/sheets/", NotationSheetsArchiveView.as_view(), name="pool_notation_sheets"),
|
||||
path("pools/<int:pk>/jury/", PoolJuryView.as_view(), name="pool_jury"),
|
||||
path("pools/<int:pk>/jury/remove/<int:jury_id>/", PoolRemoveJuryView.as_view(), name="pool_remove_jury"),
|
||||
path("pools/<int:pk>/jury/preside/<int:jury_id>/", PoolPresideJuryView.as_view(), name="pool_preside"),
|
||||
path("pools/<int:pk>/upload-notes/", PoolUploadNotesView.as_view(), name="pool_upload_notes"),
|
||||
path("pools/<int:pk>/upload-notes/template/", PoolNotesTemplateView.as_view(), name="pool_notes_template"),
|
||||
path("pools/passages/add/<int:pk>/", PassageCreateView.as_view(), name="passage_create"),
|
||||
path("pools/passages/<int:pk>/", PassageDetailView.as_view(), name="passage_detail"),
|
||||
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")
|
||||
]
|
||||
|
@ -3,13 +3,45 @@
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin import ModelAdmin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicChildModelFilter, PolymorphicParentModelAdmin
|
||||
from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicChildModelFilter, PolymorphicInlineSupportMixin, \
|
||||
PolymorphicParentModelAdmin, StackedPolymorphicInline
|
||||
|
||||
from .models import CoachRegistration, ParticipantRegistration, Payment, Registration, \
|
||||
StudentRegistration, VolunteerRegistration
|
||||
|
||||
|
||||
class RegistrationInline(StackedPolymorphicInline):
|
||||
class StudentRegistrationInline(StackedPolymorphicInline.Child):
|
||||
model = StudentRegistration
|
||||
autocomplete_fields = ('team',)
|
||||
show_change_link = True
|
||||
|
||||
class CoachRegistrationInline(StackedPolymorphicInline.Child):
|
||||
model = CoachRegistration
|
||||
autocomplete_fields = ('team',)
|
||||
show_change_link = True
|
||||
|
||||
class VolunteerRegistrationInline(StackedPolymorphicInline.Child):
|
||||
model = VolunteerRegistration
|
||||
show_change_link = True
|
||||
|
||||
model = Registration
|
||||
child_inlines = (
|
||||
StudentRegistrationInline,
|
||||
CoachRegistrationInline,
|
||||
VolunteerRegistrationInline,
|
||||
)
|
||||
|
||||
|
||||
class PaymentInline(admin.TabularInline):
|
||||
model = Payment
|
||||
extra = 0
|
||||
autocomplete_fields = ('registrations',)
|
||||
|
||||
|
||||
@admin.register(Registration)
|
||||
class RegistrationAdmin(PolymorphicParentModelAdmin):
|
||||
child_models = (StudentRegistration, CoachRegistration, VolunteerRegistration,)
|
||||
@ -97,11 +129,34 @@ class VolunteerRegistrationAdmin(PolymorphicChildModelAdmin):
|
||||
|
||||
@admin.register(Payment)
|
||||
class PaymentAdmin(ModelAdmin):
|
||||
list_display = ('registration', 'registration_type', 'type', 'valid', )
|
||||
search_fields = ('registration__user__last_name', 'registration__user__first_name', 'registration__user__email',)
|
||||
list_filter = ('registration__team__participation__valid', 'type', 'type', 'valid',)
|
||||
autocomplete_fields = ('registration',)
|
||||
list_display = ('concerned_people', 'tournament', 'team', 'grouped', 'type', 'amount', 'final', 'valid', )
|
||||
search_fields = ('registrations__user__last_name', 'registrations__user__first_name', 'registrations__user__email',
|
||||
'registrations__team__name', 'registrations__team__participation__team__trigram',)
|
||||
list_filter = ('registrations__team__participation__valid', 'type',
|
||||
'grouped', 'valid', 'registrations__team__participation__tournament', 'final',)
|
||||
autocomplete_fields = ('registrations',)
|
||||
actions = ('mark_as_valid', 'mark_as_pending', 'mark_as_invalid',)
|
||||
|
||||
@admin.display(description=_('registration type'), ordering='registration__polymorphic_ctype')
|
||||
def registration_type(self, record: Payment):
|
||||
return record.registration.get_real_instance().type
|
||||
@admin.display(description=_('concerned people'))
|
||||
def concerned_people(self, record: Payment):
|
||||
return ", ".join(f"{reg.user.first_name} {reg.user.last_name}" for reg in record.registrations.all())
|
||||
|
||||
@admin.action(description=_('Mark as valid'))
|
||||
def mark_as_valid(self, request, queryset):
|
||||
queryset.update(valid=True)
|
||||
|
||||
@admin.action(description=_('Mark as pending'))
|
||||
def mark_as_pending(self, request, queryset):
|
||||
queryset.update(valid=None)
|
||||
|
||||
@admin.action(description=_('Mark as invalid'))
|
||||
def mark_as_invalid(self, request, queryset):
|
||||
queryset.update(valid=False)
|
||||
|
||||
|
||||
admin.site.unregister(User)
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserCustomAdmin(PolymorphicInlineSupportMixin, UserAdmin):
|
||||
inlines = [RegistrationInline]
|
||||
|
@ -1,11 +1,12 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework import serializers
|
||||
from rest_polymorphic.serializers import PolymorphicSerializer
|
||||
|
||||
from ..models import CoachRegistration, ParticipantRegistration, \
|
||||
StudentRegistration, VolunteerRegistration
|
||||
Payment, StudentRegistration, VolunteerRegistration
|
||||
|
||||
|
||||
class CoachSerializer(serializers.ModelSerializer):
|
||||
@ -38,3 +39,15 @@ class RegistrationSerializer(PolymorphicSerializer):
|
||||
StudentRegistration: StudentSerializer,
|
||||
VolunteerRegistration: VolunteerSerializer,
|
||||
}
|
||||
|
||||
|
||||
class PaymentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Payment
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class BasicUserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'first_name', 'last_name', 'email', ]
|
||||
|
@ -1,11 +1,13 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .views import RegistrationViewSet
|
||||
from .views import PaymentViewSet, RegistrationViewSet, VolunteersViewSet
|
||||
|
||||
|
||||
def register_registration_urls(router, path):
|
||||
"""
|
||||
Configure router for registration REST API.
|
||||
"""
|
||||
router.register(path + "/payment", PaymentViewSet)
|
||||
router.register(path + "/registration", RegistrationViewSet)
|
||||
router.register(path + "/volunteers", VolunteersViewSet)
|
||||
|
@ -1,11 +1,14 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.permissions import BasePermission, IsAdminUser, IsAuthenticated, SAFE_METHODS
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
|
||||
from .serializers import RegistrationSerializer
|
||||
from ..models import Registration
|
||||
from .serializers import BasicUserSerializer, PaymentSerializer, RegistrationSerializer
|
||||
from ..models import Payment, Registration
|
||||
|
||||
|
||||
class RegistrationViewSet(ModelViewSet):
|
||||
@ -13,3 +16,25 @@ class RegistrationViewSet(ModelViewSet):
|
||||
serializer_class = RegistrationSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['user', 'participantregistration__team', ]
|
||||
|
||||
|
||||
class PaymentViewSet(ModelViewSet):
|
||||
queryset = Payment.objects.all()
|
||||
serializer_class = PaymentSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['registrations', 'grouped', 'amount', 'final', 'type', 'valid', ]
|
||||
|
||||
|
||||
class IsTournamentOrganizer(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
reg = request.user.registration
|
||||
return request.method in SAFE_METHODS and reg.is_volunteer and reg.organized_tournaments.exists()
|
||||
|
||||
|
||||
class VolunteersViewSet(ReadOnlyModelViewSet):
|
||||
queryset = User.objects.filter(registration__volunteerregistration__isnull=False)
|
||||
serializer_class = BasicUserSerializer
|
||||
permission_classes = [IsAdminUser | (IsAuthenticated & IsTournamentOrganizer)]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['first_name', 'last_name', 'email', ]
|
||||
search_fields = ['$first_name', '$last_name', '$email', ]
|
||||
|
@ -12,11 +12,8 @@ class RegistrationConfig(AppConfig):
|
||||
name = 'registration'
|
||||
|
||||
def ready(self):
|
||||
from registration.signals import create_admin_registration, create_payment, \
|
||||
set_username, send_email_link
|
||||
pre_save.connect(set_username, "auth.User")
|
||||
pre_save.connect(send_email_link, "auth.User")
|
||||
post_save.connect(create_admin_registration, "auth.User")
|
||||
post_save.connect(create_payment, "registration.Registration")
|
||||
post_save.connect(create_payment, "registration.StudentRegistration")
|
||||
post_save.connect(create_payment, "registration.CoachRegistration")
|
||||
from registration import signals
|
||||
pre_save.connect(signals.set_username, 'auth.User')
|
||||
pre_save.connect(signals.send_email_link, 'auth.User')
|
||||
pre_save.connect(signals.update_payment_amount, 'registration.Payment')
|
||||
post_save.connect(signals.create_admin_registration, 'auth.User')
|
||||
|
@ -133,6 +133,28 @@ class PhotoAuthorizationForm(forms.ModelForm):
|
||||
fields = ('photo_authorization',)
|
||||
|
||||
|
||||
class PhotoAuthorizationFinalForm(forms.ModelForm):
|
||||
"""
|
||||
Form to send a photo authorization.
|
||||
"""
|
||||
def clean_photo_authorization_final(self):
|
||||
if "photo_authorization_final" in self.files:
|
||||
file = self.files["photo_authorization_final"]
|
||||
if file.size > 2e6:
|
||||
raise ValidationError(_("The uploaded file size must be under 2 Mo."))
|
||||
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
|
||||
raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file."))
|
||||
return self.cleaned_data["photo_authorization_final"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["photo_authorization_final"].widget = FileInput()
|
||||
|
||||
class Meta:
|
||||
model = ParticipantRegistration
|
||||
fields = ('photo_authorization_final',)
|
||||
|
||||
|
||||
class HealthSheetForm(forms.ModelForm):
|
||||
"""
|
||||
Form to send a health sheet.
|
||||
@ -199,6 +221,28 @@ class ParentalAuthorizationForm(forms.ModelForm):
|
||||
fields = ('parental_authorization',)
|
||||
|
||||
|
||||
class ParentalAuthorizationFinalForm(forms.ModelForm):
|
||||
"""
|
||||
Form to send a parental authorization.
|
||||
"""
|
||||
def clean_parental_authorization(self):
|
||||
if "parental_authorization_final" in self.files:
|
||||
file = self.files["parental_authorization_final"]
|
||||
if file.size > 2e6:
|
||||
raise ValidationError(_("The uploaded file size must be under 2 Mo."))
|
||||
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
|
||||
raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file."))
|
||||
return self.cleaned_data["parental_authorization"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["parental_authorization_final"].widget = FileInput()
|
||||
|
||||
class Meta:
|
||||
model = StudentRegistration
|
||||
fields = ('parental_authorization_final',)
|
||||
|
||||
|
||||
class CoachRegistrationForm(forms.ModelForm):
|
||||
"""
|
||||
A coach can tell its professional activity.
|
||||
@ -219,32 +263,68 @@ class VolunteerRegistrationForm(forms.ModelForm):
|
||||
fields = ('professional_activity', 'admin', 'give_contact_to_animath', 'email_confirmed',)
|
||||
|
||||
|
||||
class PaymentForm(forms.ModelForm):
|
||||
class PaymentAdminForm(forms.ModelForm):
|
||||
"""
|
||||
Indicate payment information
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["valid"].widget.choices[0] = ('unknown', _("Pending"))
|
||||
payment_type = kwargs.get('data', {}).get('type', "")
|
||||
self.fields['receipt'].required = payment_type in ["scholarship", "bank_transfer"]
|
||||
self.fields['additional_information'].required = payment_type in ["other"]
|
||||
|
||||
def clean_scholarship_file(self):
|
||||
if "scholarship_file" in self.files:
|
||||
file = self.files["scholarship_file"]
|
||||
def clean_receipt(self):
|
||||
if "receipt" in self.files:
|
||||
file = self.files["receipt"]
|
||||
if file.size > 2e6:
|
||||
raise ValidationError(_("The uploaded file size must be under 2 Mo."))
|
||||
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
|
||||
raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file."))
|
||||
return self.cleaned_data["scholarship_file"]
|
||||
return self.cleaned_data["receipt"]
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if "type" in cleaned_data and cleaned_data["type"] == "scholarship" \
|
||||
and "scholarship_file" not in cleaned_data and not self.instance.scholarship_file:
|
||||
self.add_error("scholarship_file", _("You must upload your scholarship attestation."))
|
||||
if "type" in cleaned_data and cleaned_data['type'] in ["scholarship", "bank_transfer"] \
|
||||
and "receipt" not in self.files and not self.instance.receipt:
|
||||
self.add_error("receipt", _("You must upload your receipt."))
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = Payment
|
||||
fields = ('type', 'scholarship_file', 'additional_information', 'valid',)
|
||||
fields = ('type', 'receipt', 'additional_information', 'valid',)
|
||||
|
||||
|
||||
class PaymentForm(forms.ModelForm):
|
||||
"""
|
||||
Indicate payment information
|
||||
"""
|
||||
def __init__(self, payment_type, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['type'].widget = forms.HiddenInput(attrs={'value': payment_type})
|
||||
self.fields['receipt'].required = payment_type in ["scholarship", "bank_transfer"]
|
||||
self.fields['additional_information'].required = payment_type in ["other"]
|
||||
|
||||
def clean_receipt(self):
|
||||
if "receipt" in self.files:
|
||||
file = self.files["receipt"]
|
||||
if file.size > 2e6:
|
||||
raise ValidationError(_("The uploaded file size must be under 2 Mo."))
|
||||
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
|
||||
raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file."))
|
||||
return self.cleaned_data["receipt"]
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if "type" in cleaned_data and cleaned_data['type'] in ["scholarship", "bank_transfer"] \
|
||||
and "receipt" not in self.files and not self.instance.receipt:
|
||||
self.add_error("receipt", _("You must upload your receipt."))
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = Payment
|
||||
fields = ('type', 'receipt', 'additional_information',)
|
||||
|
25
registration/management/commands/check_hello_asso.py
Normal file
@ -0,0 +1,25 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
||||
from django.core.management import BaseCommand
|
||||
|
||||
from ...models import Payment
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
This command checks if the initiated Hello Asso payments are validated or not.
|
||||
"""
|
||||
help = "Vérifie si les paiements Hello Asso initiés sont validés ou non. Si oui, valide les inscriptions."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
for payment in Payment.objects.exclude(valid=True).filter(checkout_intent_id__isnull=False).all():
|
||||
checkout_intent = payment.get_checkout_intent()
|
||||
if checkout_intent is not None and 'order' in checkout_intent:
|
||||
payment.type = 'helloasso'
|
||||
payment.valid = True
|
||||
payment.additional_information = json.dumps(checkout_intent['order'])
|
||||
payment.save()
|
||||
payment.send_helloasso_payment_confirmation_mail()
|
17
registration/management/commands/remind_payments.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.core.management import BaseCommand
|
||||
|
||||
from ...models import Payment
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
This command sends a mail to each participant who has not yet paid.
|
||||
"""
|
||||
help = "Envoie un mail de rappel à toustes les participant⋅es qui n'ont pas encore payé ou déclaré de paiement."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
for payment in Payment.objects.filter(valid=False).filter(registrations__team__participation__valid=True).all():
|
||||
payment.send_remind_mail()
|
@ -113,7 +113,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('type', models.CharField(blank=True, choices=[('', 'No payment'), ('helloasso', 'Hello Asso'), ('scholarship', 'Scholarship'), ('bank_transfer', 'Bank transfer'), ('other', 'Other (please indicate)'), ('free', 'The tournament is free')], default='', max_length=16, verbose_name='type')),
|
||||
('scholarship_file', models.FileField(blank=True, default='', help_text='only if you have a scholarship.', upload_to=registration.models.get_scholarship_filename, verbose_name='scholarship file')),
|
||||
('scholarship_file', models.FileField(blank=True, default='', help_text='only if you have a scholarship.', upload_to=registration.models.get_receipt_filename, verbose_name='scholarship file')),
|
||||
('additional_information', models.TextField(blank=True, default='', help_text='To help us to find your payment.', verbose_name='additional information')),
|
||||
('valid', models.BooleanField(default=False, null=True, verbose_name='valid')),
|
||||
('registration', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='payment', to='registration.participantregistration', verbose_name='registration')),
|
||||
|
@ -0,0 +1,76 @@
|
||||
# Generated by Django 5.0.1 on 2024-02-12 20:40
|
||||
|
||||
import registration.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("registration", "0010_coachregistration_last_degree"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="payment",
|
||||
name="registration",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="payment",
|
||||
name="scholarship_file",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="payment",
|
||||
name="amount",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
default=0,
|
||||
help_text="Corresponds to the total required amount to pay, in euros.",
|
||||
verbose_name="total amount",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="payment",
|
||||
name="checkout_intent_id",
|
||||
field=models.IntegerField(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
verbose_name="Hello Asso checkout intent ID",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="payment",
|
||||
name="final",
|
||||
field=models.BooleanField(
|
||||
default=False, verbose_name="for final tournament"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="payment",
|
||||
name="grouped",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="If set to true, then one payment is made for the full team, for example if the school pays for all.",
|
||||
verbose_name="grouped",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="payment",
|
||||
name="receipt",
|
||||
field=models.FileField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="only if you have a scholarship or if you chose a bank transfer.",
|
||||
upload_to=registration.models.get_receipt_filename,
|
||||
verbose_name="receipt",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="payment",
|
||||
name="registrations",
|
||||
field=models.ManyToManyField(
|
||||
related_name="payments",
|
||||
to="registration.participantregistration",
|
||||
verbose_name="registrations",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.0.1 on 2024-02-20 22:48
|
||||
|
||||
import registration.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("registration", "0011_remove_payment_registration_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="payment",
|
||||
name="token",
|
||||
field=models.CharField(
|
||||
default=registration.models.get_random_token,
|
||||
help_text="A token to authorize external users to make this payment.",
|
||||
max_length=32,
|
||||
verbose_name="token",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="payment",
|
||||
name="type",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("", "No payment"),
|
||||
("helloasso", "Credit card"),
|
||||
("scholarship", "Scholarship"),
|
||||
("bank_transfer", "Bank transfer"),
|
||||
("other", "Other (please indicate)"),
|
||||
("free", "The tournament is free"),
|
||||
],
|
||||
default="",
|
||||
max_length=16,
|
||||
verbose_name="type",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-07 08:34
|
||||
|
||||
import registration.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registration", "0012_payment_token_alter_payment_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="participantregistration",
|
||||
name="photo_authorization_final",
|
||||
field=models.FileField(
|
||||
blank=True,
|
||||
default="",
|
||||
upload_to=registration.models.get_random_photo_filename,
|
||||
verbose_name="photo authorization (final)",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="studentregistration",
|
||||
name="parental_authorization_final",
|
||||
field=models.FileField(
|
||||
blank=True,
|
||||
default="",
|
||||
upload_to=registration.models.get_random_parental_filename,
|
||||
verbose_name="parental authorization (final)",
|
||||
),
|
||||
),
|
||||
]
|
@ -1,20 +1,24 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.mail import send_mail
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.template import loader
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.http import urlsafe_base64_encode
|
||||
from django.utils.text import format_lazy
|
||||
from django.utils.timezone import localtime
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
from polymorphic.models import PolymorphicModel
|
||||
from tfjm import helloasso
|
||||
from tfjm.tokens import email_validation_token
|
||||
|
||||
|
||||
@ -77,6 +81,14 @@ class Registration(PolymorphicModel):
|
||||
def participates(self):
|
||||
return isinstance(self, ParticipantRegistration)
|
||||
|
||||
@property
|
||||
def is_student(self):
|
||||
return isinstance(self, StudentRegistration)
|
||||
|
||||
@property
|
||||
def is_coach(self):
|
||||
return isinstance(self, CoachRegistration)
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
return isinstance(self, VolunteerRegistration) and self.admin or self.user.is_superuser
|
||||
@ -88,6 +100,28 @@ class Registration(PolymorphicModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse_lazy("registration:user_detail", args=(self.user_id,))
|
||||
|
||||
def registration_informations(self):
|
||||
return []
|
||||
|
||||
def important_informations(self):
|
||||
informations = []
|
||||
if not self.email_confirmed:
|
||||
text = _("Your email address is not validated. Please click on the link you received by email. "
|
||||
"You can resend a mail by clicking on <a href=\"{send_email_url}\">this link</a>.")
|
||||
send_email_url = reverse_lazy("registration:email_validation_resend", args=(self.user_id,))
|
||||
content = format_lazy(text, send_email_url=send_email_url)
|
||||
informations.append({
|
||||
'title': "Validation e-mail",
|
||||
'type': "warning",
|
||||
'priority': 0,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
informations.extend(self.registration_informations())
|
||||
|
||||
informations.sort(key=lambda info: (info['priority'], info['title']))
|
||||
return informations
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.first_name} {self.user.last_name}"
|
||||
|
||||
@ -176,17 +210,39 @@ class ParticipantRegistration(Registration):
|
||||
default="",
|
||||
)
|
||||
|
||||
photo_authorization_final = models.FileField(
|
||||
verbose_name=_("photo authorization (final)"),
|
||||
upload_to=get_random_photo_filename,
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
|
||||
@property
|
||||
def under_18(self):
|
||||
if isinstance(self, CoachRegistration):
|
||||
return False # In normal case
|
||||
important_date = timezone.now().date()
|
||||
important_date = localtime(timezone.now()).date()
|
||||
if self.team and self.team.participation.tournament:
|
||||
important_date = self.team.participation.tournament.date_start
|
||||
if self.team.participation.final:
|
||||
from participation.models import Tournament
|
||||
important_date = Tournament.final_tournament().date_start
|
||||
return (important_date - self.birth_date).days < 18 * 365.24
|
||||
birth_date = self.birth_date
|
||||
if birth_date.month == 2 and birth_date.day == 29:
|
||||
# If the birth date is the 29th of February, we consider it as the 1st of March
|
||||
birth_date = birth_date.replace(month=3, day=1)
|
||||
over_18_on = birth_date.replace(year=birth_date.year + 18)
|
||||
return important_date < over_18_on
|
||||
|
||||
@property
|
||||
def under_18_final(self):
|
||||
if isinstance(self, CoachRegistration):
|
||||
return False # In normal case
|
||||
from participation.models import Tournament
|
||||
important_date = Tournament.final_tournament().date_start
|
||||
birth_date = self.birth_date
|
||||
if birth_date.month == 2 and birth_date.day == 29:
|
||||
# If the birth date is the 29th of February, we consider it as the 1st of March
|
||||
birth_date = birth_date.replace(month=3, day=1)
|
||||
over_18_on = birth_date.replace(year=birth_date.year + 18)
|
||||
return important_date < over_18_on
|
||||
|
||||
@property
|
||||
def type(self): # pragma: no cover
|
||||
@ -196,6 +252,77 @@ class ParticipantRegistration(Registration):
|
||||
def form_class(self): # pragma: no cover
|
||||
raise NotImplementedError
|
||||
|
||||
def registration_informations(self):
|
||||
informations = []
|
||||
if not self.team:
|
||||
text = _("You are not in a team. You can <a href=\"{create_url}\">create one</a> "
|
||||
"or <a href=\"{join_url}\">join an existing one</a> to participate.")
|
||||
create_url = reverse_lazy("participation:create_team")
|
||||
join_url = reverse_lazy("participation:join_team")
|
||||
content = format_lazy(text, create_url=create_url, join_url=join_url)
|
||||
informations.append({
|
||||
'title': _("No team"),
|
||||
'type': "danger",
|
||||
'priority': 1,
|
||||
'content': content,
|
||||
})
|
||||
else:
|
||||
if self.team.participation.tournament:
|
||||
if not self.photo_authorization:
|
||||
text = _("You have not uploaded your photo authorization. "
|
||||
"You can do it by clicking on <a href=\"{photo_url}\">this link</a>.")
|
||||
photo_url = reverse_lazy("registration:upload_user_photo_authorization", args=(self.id,))
|
||||
content = format_lazy(text, photo_url=photo_url)
|
||||
informations.append({
|
||||
'title': _("Photo authorization"),
|
||||
'type': "danger",
|
||||
'priority': 5,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
if self.team.participation.final:
|
||||
if not self.photo_authorization_final:
|
||||
text = _("You have not uploaded your photo authorization for the final tournament. "
|
||||
"You can do it by clicking on <a href=\"{photo_url}\">this link</a>.")
|
||||
photo_url = reverse_lazy("registration:upload_user_photo_authorization_final", args=(self.id,))
|
||||
content = format_lazy(text, photo_url=photo_url)
|
||||
informations.append({
|
||||
'title': _("Photo authorization"),
|
||||
'type': "danger",
|
||||
'priority': 5,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
informations.extend(self.team.important_informations())
|
||||
|
||||
return informations
|
||||
|
||||
def send_email_final_selection(self):
|
||||
"""
|
||||
The team is selected for final.
|
||||
"""
|
||||
translation.activate('fr')
|
||||
subject = "[TFJM²] " + str(_("Team selected for the final tournament"))
|
||||
site = Site.objects.first()
|
||||
from participation.models import Tournament
|
||||
tournament = Tournament.final_tournament()
|
||||
payment = self.payments.filter(final=True).first() if self.is_student else None
|
||||
message = loader.render_to_string('registration/mails/final_selection.txt',
|
||||
{
|
||||
'user': self.user,
|
||||
'domain': site.domain,
|
||||
'tournament': tournament,
|
||||
'payment': payment,
|
||||
})
|
||||
html = loader.render_to_string('registration/mails/final_selection.html',
|
||||
{
|
||||
'user': self.user,
|
||||
'domain': site.domain,
|
||||
'tournament': tournament,
|
||||
'payment': payment,
|
||||
})
|
||||
self.user.email_user(subject, message, html_message=html)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("participant registration")
|
||||
verbose_name_plural = _("participant registrations")
|
||||
@ -248,6 +375,13 @@ class StudentRegistration(ParticipantRegistration):
|
||||
default="",
|
||||
)
|
||||
|
||||
parental_authorization_final = models.FileField(
|
||||
verbose_name=_("parental authorization (final)"),
|
||||
upload_to=get_random_parental_filename,
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
|
||||
health_sheet = models.FileField(
|
||||
verbose_name=_("health sheet"),
|
||||
upload_to=get_random_health_filename,
|
||||
@ -271,6 +405,83 @@ class StudentRegistration(ParticipantRegistration):
|
||||
from registration.forms import StudentRegistrationForm
|
||||
return StudentRegistrationForm
|
||||
|
||||
def registration_informations(self):
|
||||
informations = super().registration_informations()
|
||||
if self.team and self.team.participation.tournament and self.under_18:
|
||||
if not self.parental_authorization:
|
||||
text = _("You have not uploaded your parental authorization. "
|
||||
"You can do it by clicking on <a href=\"{parental_url}\">this link</a>.")
|
||||
parental_url = reverse_lazy("registration:upload_user_parental_authorization", args=(self.id,))
|
||||
content = format_lazy(text, parental_url=parental_url)
|
||||
informations.append({
|
||||
'title': _("Parental authorization"),
|
||||
'type': "danger",
|
||||
'priority': 5,
|
||||
'content': content,
|
||||
})
|
||||
if not self.health_sheet:
|
||||
text = _("You have not uploaded your health sheet. "
|
||||
"You can do it by clicking on <a href=\"{health_url}\">this link</a>.")
|
||||
health_url = reverse_lazy("registration:upload_user_health_sheet", args=(self.id,))
|
||||
content = format_lazy(text, health_url=health_url)
|
||||
informations.append({
|
||||
'title': _("Health sheet"),
|
||||
'type': "danger",
|
||||
'priority': 5,
|
||||
'content': content,
|
||||
})
|
||||
if not self.vaccine_sheet:
|
||||
text = _("You have not uploaded your vaccine sheet. "
|
||||
"You can do it by clicking on <a href=\"{vaccine_url}\">this link</a>.")
|
||||
vaccine_url = reverse_lazy("registration:upload_user_vaccine_sheet", args=(self.id,))
|
||||
content = format_lazy(text, vaccine_url=vaccine_url)
|
||||
informations.append({
|
||||
'title': _("Vaccine sheet"),
|
||||
'type': "danger",
|
||||
'priority': 5,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
if self.team and self.team.participation.valid:
|
||||
for payment in self.payments.all():
|
||||
if payment.valid is False:
|
||||
text = _("You have to pay {amount} € for your registration, or send a scholarship "
|
||||
"notification or a payment proof. "
|
||||
"You can do it on <a href=\"{url}\">the payment page</a>.")
|
||||
url = reverse_lazy("registration:update_payment", args=(payment.id,))
|
||||
content = format_lazy(text, amount=payment.amount, url=url)
|
||||
informations.append({
|
||||
'title': _("Payment"),
|
||||
'type': "danger",
|
||||
'priority': 3,
|
||||
'content': content,
|
||||
})
|
||||
elif payment.valid is None:
|
||||
text = _("Your payment is under approval.")
|
||||
content = text
|
||||
informations.append({
|
||||
'title': _("Payment"),
|
||||
'type': "warning",
|
||||
'priority': 3,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
if self.team.participation.final:
|
||||
if self.under_18_final and not self.parental_authorization_final:
|
||||
text = _("You have not uploaded your parental authorization for the final tournament. "
|
||||
"You can do it by clicking on <a href=\"{parental_url}\">this link</a>.")
|
||||
parental_url = reverse_lazy("registration:upload_user_parental_authorization_final",
|
||||
args=(self.id,))
|
||||
content = format_lazy(text, parental_url=parental_url)
|
||||
informations.append({
|
||||
'title': _("Parental authorization"),
|
||||
'type': "danger",
|
||||
'priority': 5,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
return informations
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("student registration")
|
||||
verbose_name_plural = _("student registrations")
|
||||
@ -334,21 +545,157 @@ class VolunteerRegistration(Registration):
|
||||
from registration.forms import VolunteerRegistrationForm
|
||||
return VolunteerRegistrationForm
|
||||
|
||||
def important_informations(self):
|
||||
informations = []
|
||||
|
||||
for tournament in self.organized_tournaments.all():
|
||||
if timezone.now() < tournament.inscription_limit \
|
||||
or tournament.participations.filter(valid=True).count() < tournament.max_teams:
|
||||
text = _("Registrations for tournament {tournament} are closing on {date:%Y-%m-%d %H:%M}. "
|
||||
"There are for now {validated_teams} validated teams (+ {pending_teams} pending) "
|
||||
"on {max_teams} expected.")
|
||||
content = format_lazy(text, tournament=tournament.name, date=localtime(tournament.inscription_limit),
|
||||
validated_teams=tournament.participations.filter(valid=True).count(),
|
||||
pending_teams=tournament.participations.filter(valid=False).count(),
|
||||
max_teams=tournament.max_teams)
|
||||
informations.append({
|
||||
'title': _("Registrations"),
|
||||
'type': "info",
|
||||
'priority': 2,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
for pending_participation in tournament.participations.filter(valid=False).all():
|
||||
text = _("The team {trigram} requested to be validated for the tournament of {tournament}. "
|
||||
"You can check the status of the team on the <a href=\"{url}\">team page</a>.")
|
||||
url = reverse_lazy("participation:team_detail", args=(pending_participation.team.id,))
|
||||
content = format_lazy(text, trigram=pending_participation.team.trigram,
|
||||
tournament=tournament.name, url=url)
|
||||
informations.append({
|
||||
'title': _("Pending validation"),
|
||||
'type': "warning",
|
||||
'priority': 4,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
payments = Payment.objects.filter(registrations__team__participation__tournament=tournament).all()
|
||||
valid = payments.filter(valid=True).count()
|
||||
pending = payments.filter(valid=None).count()
|
||||
invalid = payments.filter(valid=False).count()
|
||||
if pending + invalid > 0:
|
||||
text = _("There are {valid} validated payments, {pending} pending and {invalid} invalid for the "
|
||||
"tournament of {tournament}. You can check the status of the payments on the "
|
||||
"<a href=\"{url}\">payments list</a>.")
|
||||
url = reverse_lazy("participation:tournament_payments", args=(tournament.id,))
|
||||
content = format_lazy(text, valid=valid, pending=pending, invalid=invalid, tournament=tournament.name,
|
||||
url=url)
|
||||
informations.append({
|
||||
'title': _("Payments"),
|
||||
'type': "info",
|
||||
'priority': 5,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
if timezone.now() > tournament.solution_limit and timezone.now() < tournament.solutions_draw:
|
||||
text = _("<p>The draw of the solutions for the tournament {tournament} is planned on the "
|
||||
"{date:%Y-%m-%d %H:%M}. You can join it on <a href='{url}'>this link</a>.</p>")
|
||||
url = reverse_lazy("draw:index")
|
||||
content = format_lazy(text, tournament=tournament.name,
|
||||
date=localtime(tournament.solutions_draw),
|
||||
url=url)
|
||||
informations.append({
|
||||
'title': _("Draw of solutions"),
|
||||
'type': "info",
|
||||
'priority': 1,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
for tournament in self.interesting_tournaments:
|
||||
pools = tournament.pools.filter(juries=self).order_by('round').all()
|
||||
for pool in pools:
|
||||
if pool.round == 1 and timezone.now().date() <= tournament.date_start:
|
||||
text = _("<p>You are in the jury of the pool {pool} for the tournament of {tournament}. "
|
||||
"You can find the pool page <a href='{pool_url}'>here</a>.</p>")
|
||||
pool_url = reverse_lazy("participation:pool_detail", args=(pool.id,))
|
||||
content = format_lazy(text, pool=pool.short_name, tournament=tournament.name, pool_url=pool_url)
|
||||
informations.append({
|
||||
'title': _("First round"),
|
||||
'type': "info",
|
||||
'priority': 1,
|
||||
'content': content,
|
||||
})
|
||||
elif pool.round == 2 and timezone.now().date() <= tournament.date_end:
|
||||
text = _("<p>You are in the jury of the pool {pool} for the tournament of {tournament}. "
|
||||
"You can find the pool page <a href='{pool_url}'>here</a>.</p>")
|
||||
pool_url = reverse_lazy("participation:pool_detail", args=(pool.id,))
|
||||
content = format_lazy(text, pool=pool.short_name, tournament=tournament.name, pool_url=pool_url)
|
||||
informations.append({
|
||||
'title': _("Second round"),
|
||||
'type': "info",
|
||||
'priority': 2,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
for note in self.notes.filter(passage__pool=pool).all():
|
||||
if not note.has_any_note():
|
||||
text = _("<p>You don't have given any note as a jury for the passage {passage} "
|
||||
"in the pool {pool} of {tournament}. "
|
||||
"You can set your notes <a href='{passage_url}'>here</a>.</p>")
|
||||
passage_url = reverse_lazy("participation:passage_detail", args=(note.passage.id,))
|
||||
content = format_lazy(text, passage=note.passage.position, pool=pool.short_name,
|
||||
tournament=tournament.name, passage_url=passage_url)
|
||||
informations.append({
|
||||
'title': _("Note"),
|
||||
'type': "warning",
|
||||
'priority': 3 + note.passage.position,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
return informations
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("volunteer registration")
|
||||
verbose_name_plural = _("volunteer registrations")
|
||||
|
||||
|
||||
def get_scholarship_filename(instance, filename):
|
||||
return f"authorization/scholarship/scholarship_{instance.registration.pk}"
|
||||
def get_receipt_filename(instance, filename):
|
||||
return f"authorization/receipt/receipt_{instance.id}"
|
||||
|
||||
|
||||
def get_random_token():
|
||||
return get_random_string(32)
|
||||
|
||||
|
||||
class Payment(models.Model):
|
||||
registration = models.OneToOneField(
|
||||
registrations = models.ManyToManyField(
|
||||
ParticipantRegistration,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="payment",
|
||||
verbose_name=_("registration"),
|
||||
related_name="payments",
|
||||
verbose_name=_("registrations"),
|
||||
)
|
||||
|
||||
grouped = models.BooleanField(
|
||||
verbose_name=_("grouped"),
|
||||
default=False,
|
||||
help_text=_("If set to true, then one payment is made for the full team, "
|
||||
"for example if the school pays for all."),
|
||||
)
|
||||
|
||||
amount = models.PositiveSmallIntegerField(
|
||||
verbose_name=_("total amount"),
|
||||
help_text=_("Corresponds to the total required amount to pay, in euros."),
|
||||
default=0,
|
||||
)
|
||||
|
||||
token = models.CharField(
|
||||
verbose_name=_("token"),
|
||||
max_length=32,
|
||||
default=get_random_token,
|
||||
help_text=_("A token to authorize external users to make this payment."),
|
||||
)
|
||||
|
||||
final = models.BooleanField(
|
||||
verbose_name=_("for final tournament"),
|
||||
default=False,
|
||||
)
|
||||
|
||||
type = models.CharField(
|
||||
@ -356,7 +703,7 @@ class Payment(models.Model):
|
||||
max_length=16,
|
||||
choices=[
|
||||
('', _("No payment")),
|
||||
('helloasso', "Hello Asso"),
|
||||
('helloasso', _("Credit card")),
|
||||
('scholarship', _("Scholarship")),
|
||||
('bank_transfer', _("Bank transfer")),
|
||||
('other', _("Other (please indicate)")),
|
||||
@ -366,10 +713,17 @@ class Payment(models.Model):
|
||||
default="",
|
||||
)
|
||||
|
||||
scholarship_file = models.FileField(
|
||||
verbose_name=_("scholarship file"),
|
||||
help_text=_("only if you have a scholarship."),
|
||||
upload_to=get_scholarship_filename,
|
||||
checkout_intent_id = models.IntegerField(
|
||||
verbose_name=_("Hello Asso checkout intent ID"),
|
||||
blank=True,
|
||||
null=True,
|
||||
default=None,
|
||||
)
|
||||
|
||||
receipt = models.FileField(
|
||||
verbose_name=_("receipt"),
|
||||
help_text=_("only if you have a scholarship or if you chose a bank transfer."),
|
||||
upload_to=get_receipt_filename,
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
@ -387,11 +741,95 @@ class Payment(models.Model):
|
||||
default=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def team(self):
|
||||
return self.registrations.first().team
|
||||
team.fget.short_description = _("team")
|
||||
team.fget.admin_order_field = 'registrations__team__trigram'
|
||||
|
||||
@property
|
||||
def tournament(self):
|
||||
if self.final:
|
||||
from participation.models import Tournament
|
||||
return Tournament.final_tournament()
|
||||
return self.registrations.first().team.participation.tournament
|
||||
tournament.fget.short_description = _("tournament")
|
||||
tournament.fget.admin_order_field = 'registrations__team__participation__tournament'
|
||||
|
||||
def get_checkout_intent(self, none_if_link_disabled=False):
|
||||
if self.checkout_intent_id is None:
|
||||
return None
|
||||
return helloasso.get_checkout_intent(self.checkout_intent_id, none_if_link_disabled=none_if_link_disabled)
|
||||
|
||||
def create_checkout_intent(self):
|
||||
checkout_intent = self.get_checkout_intent(none_if_link_disabled=True)
|
||||
if checkout_intent is not None:
|
||||
return checkout_intent
|
||||
|
||||
tournament = self.tournament
|
||||
year = datetime.now().year
|
||||
base_site = "https://" + Site.objects.first().domain
|
||||
checkout_intent = helloasso.create_checkout_intent(
|
||||
amount=100 * self.amount,
|
||||
name=f"Participation au TFJM² {year} - {tournament.name} - {self.team.trigram}",
|
||||
back_url=base_site + reverse('registration:update_payment', args=(self.id,)),
|
||||
error_url=f"{base_site}{reverse('registration:payment_hello_asso_return', args=(self.id,))}?type=error",
|
||||
return_url=f"{base_site}{reverse('registration:payment_hello_asso_return', args=(self.id,))}?type=return",
|
||||
contains_donation=False,
|
||||
metadata=dict(
|
||||
users=[
|
||||
dict(user_id=registration.user.id,
|
||||
first_name=registration.user.first_name,
|
||||
last_name=registration.user.last_name,
|
||||
email=registration.user.email,)
|
||||
for registration in self.registrations.all()
|
||||
],
|
||||
payment_id=self.id,
|
||||
final=self.final,
|
||||
tournament_id=tournament.id,
|
||||
)
|
||||
)
|
||||
self.checkout_intent_id = checkout_intent["id"]
|
||||
self.save()
|
||||
|
||||
return checkout_intent
|
||||
|
||||
def send_remind_mail(self):
|
||||
translation.activate('fr')
|
||||
subject = "[TFJM²] " + str(_("Reminder for your payment"))
|
||||
site = Site.objects.first()
|
||||
for registration in self.registrations.all():
|
||||
message = loader.render_to_string('registration/mails/payment_reminder.txt',
|
||||
dict(registration=registration, payment=self, domain=site.domain))
|
||||
html = loader.render_to_string('registration/mails/payment_reminder.html',
|
||||
dict(registration=registration, payment=self, domain=site.domain))
|
||||
registration.user.email_user(subject, message, html_message=html)
|
||||
|
||||
def send_helloasso_payment_confirmation_mail(self):
|
||||
translation.activate('fr')
|
||||
subject = "[TFJM²] " + str(_("Payment confirmation"))
|
||||
site = Site.objects.first()
|
||||
for registration in self.registrations.all():
|
||||
message = loader.render_to_string('registration/mails/payment_confirmation.txt',
|
||||
dict(registration=registration, payment=self, domain=site.domain))
|
||||
html = loader.render_to_string('registration/mails/payment_confirmation.html',
|
||||
dict(registration=registration, payment=self, domain=site.domain))
|
||||
registration.user.email_user(subject, message, html_message=html)
|
||||
|
||||
payer = self.get_checkout_intent()['order']['payer']
|
||||
payer_name = f"{payer['firstName']} {payer['lastName']}"
|
||||
if not self.registrations.filter(user__email=payer['email']).exists():
|
||||
message = loader.render_to_string('registration/mails/payment_confirmation.txt',
|
||||
dict(registration=payer_name, payment=self, domain=site.domain))
|
||||
html = loader.render_to_string('registration/mails/payment_confirmation.html',
|
||||
dict(registration=payer_name, payment=self, domain=site.domain))
|
||||
send_mail(subject, message, None, [payer['email']], html_message=html)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse_lazy("registration:user_detail", args=(self.registration.user.id,))
|
||||
return reverse_lazy("registration:update_payment", args=(self.pk,))
|
||||
|
||||
def __str__(self):
|
||||
return _("Payment of {registration}").format(registration=self.registration)
|
||||
return _("Payment of {registrations}").format(registrations=", ".join(map(str, self.registrations.all())))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("payment")
|
||||
|
@ -4,7 +4,7 @@
|
||||
from django.contrib.auth.models import User
|
||||
from tfjm.lists import get_sympa_client
|
||||
|
||||
from .models import Payment, Registration, VolunteerRegistration
|
||||
from .models import Registration, VolunteerRegistration
|
||||
|
||||
|
||||
def set_username(instance, **_):
|
||||
@ -43,14 +43,11 @@ def create_admin_registration(instance, **_):
|
||||
VolunteerRegistration.objects.get_or_create(user=instance, admin=True)
|
||||
|
||||
|
||||
def create_payment(instance: Registration, raw, **_):
|
||||
def update_payment_amount(instance, **_):
|
||||
"""
|
||||
When a user is saved, create the associated payment.
|
||||
For a free tournament, the payment is valid.
|
||||
When a payment got created, ensure that the amount is set.
|
||||
"""
|
||||
if instance.participates and not raw:
|
||||
payment = Payment.objects.get_or_create(registration=instance)[0]
|
||||
if instance.team and instance.team.participation.valid and instance.team.participation.tournament.price == 0:
|
||||
payment.valid = True
|
||||
payment.type = "free"
|
||||
payment.save()
|
||||
if instance.type == 'free' or instance.type == 'scholarship':
|
||||
instance.amount = 0
|
||||
elif instance.pk and instance.registrations.exists():
|
||||
instance.amount = instance.registrations.count() * instance.tournament.price
|
||||
|