1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2024-11-27 02:43:01 +00:00

Merge branch 'ouvreureuse' into 'main'

Ouvreureuse

See merge request bde/nk20!256
This commit is contained in:
korenstin 2024-08-02 15:25:16 +02:00
commit fa68adb0c6
15 changed files with 519 additions and 16 deletions

View File

@ -1,9 +1,11 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay # Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction, Opener
class ActivityTypeSerializer(serializers.ModelSerializer): class ActivityTypeSerializer(serializers.ModelSerializer):
@ -59,3 +61,17 @@ class GuestTransactionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = GuestTransaction model = GuestTransaction
fields = '__all__' fields = '__all__'
class OpenerSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Openers.
The djangorestframework plugin will analyse the model `Opener` and parse all fields in the API.
"""
class Meta:
model = Opener
fields = '__all__'
validators = [UniqueTogetherValidator(
queryset=Opener.objects.all(), fields=("opener", "activity"),
message=_("This opener already exists"))]

View File

@ -1,7 +1,7 @@
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay # Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet, OpenerViewSet
def register_activity_urls(router, path): def register_activity_urls(router, path):
@ -12,3 +12,4 @@ def register_activity_urls(router, path):
router.register(path + '/type', ActivityTypeViewSet) router.register(path + '/type', ActivityTypeViewSet)
router.register(path + '/guest', GuestViewSet) router.register(path + '/guest', GuestViewSet)
router.register(path + '/entry', EntryViewSet) router.register(path + '/entry', EntryViewSet)
router.register(path + '/opener', OpenerViewSet)

View File

@ -2,11 +2,14 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from api.viewsets import ReadProtectedModelViewSet from api.viewsets import ReadProtectedModelViewSet
from django.core.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter from rest_framework.filters import SearchFilter
from rest_framework.response import Response
from rest_framework import status
from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer, OpenerSerializer
from ..models import Activity, ActivityType, Entry, Guest from ..models import Activity, ActivityType, Entry, Guest, Opener
class ActivityTypeViewSet(ReadProtectedModelViewSet): class ActivityTypeViewSet(ReadProtectedModelViewSet):
@ -66,3 +69,32 @@ class EntryViewSet(ReadProtectedModelViewSet):
filterset_fields = ['activity', 'time', 'note', 'guest', ] filterset_fields = ['activity', 'time', 'note', 'guest', ]
search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name', search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name',
'$guest__last_name', '$guest__first_name', ] '$guest__last_name', '$guest__first_name', ]
class OpenerViewSet(ReadProtectedModelViewSet):
"""
REST Opener View set.
The djangorestframework plugin will get all `Opener` objects, serialize it to JSON with the given serializer,
then render it on /api/activity/opener/
"""
queryset = Opener.objects
serializer_class = OpenerSerializer
filter_backends = [SearchFilter, DjangoFilterBackend]
search_fields = ['$opener__alias__name', '$opener__alias__normalized_name',
'$activity__name']
filterset_fields = ['opener', 'opener__noteuser__user', 'activity']
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method in ['PUT', 'PATCH']:
# opener-activity can't change
serializer_class.Meta.read_only_fields = ('opener', 'acitivity',)
return serializer_class
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
try:
self.perform_destroy(instance)
except ValidationError as e:
return Response({e.code: str(e)}, status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -43,7 +43,7 @@ class ActivityForm(forms.ModelForm):
class Meta: class Meta:
model = Activity model = Activity
exclude = ('creater', 'valid', 'open', ) exclude = ('creater', 'valid', 'open', 'opener', )
widgets = { widgets = {
"organizer": Autocomplete( "organizer": Autocomplete(
model=Club, model=Club,

View File

@ -0,0 +1,28 @@
# Generated by Django 2.2.28 on 2024-08-01 12:36
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('note', '0006_trust'),
('activity', '0003_auto_20240323_1422'),
]
operations = [
migrations.CreateModel(
name='Opener',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opener', to='activity.Activity', verbose_name='activity')),
('opener', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_responsible', to='note.Note', verbose_name='opener')),
],
options={
'verbose_name': 'opener',
'verbose_name_plural': 'openers',
'unique_together': {('opener', 'activity')},
},
),
]

View File

@ -11,7 +11,7 @@ from django.db import models, transaction
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note.models import NoteUser, Transaction from note.models import NoteUser, Transaction, Note
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@ -310,3 +310,31 @@ class GuestTransaction(Transaction):
@property @property
def type(self): def type(self):
return _('Invitation') return _('Invitation')
class Opener(models.Model):
"""
Allow the user to make activity entries without more rights
"""
activity = models.ForeignKey(
Activity,
on_delete=models.CASCADE,
related_name='opener',
verbose_name=_('activity')
)
opener = models.ForeignKey(
Note,
on_delete=models.CASCADE,
related_name='activity_responsible',
verbose_name=_('Opener')
)
class Meta:
verbose_name = _("Opener")
verbose_name_plural = _("Openers")
unique_together = ("opener", "activity")
def __str__(self):
return _("{opener} is opener of activity {acivity}").format(
opener=str(self.opener), acivity=str(self.activity))

View File

@ -0,0 +1,78 @@
/**
* On form submit, create a new friendship
*/
function form_create_opener (e) {
// Do not submit HTML form
e.preventDefault()
// Get data and send to API
const formData = new FormData(e.target)
$.getJSON('/api/note/alias/'+formData.get('opener') + '/',
function (opener_alias) {
create_opener(formData.get('activity'), opener_alias.note)
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
/**
* Create a trust between users
* @param trusting:Integer trusting note id
* @param trusted:Integer trusted note id
*/
function create_opener(activity, opener) {
$.post('/api/activity/opener/', {
activity: activity,
opener: opener,
csrfmiddlewaretoken: CSRF_TOKEN
}).done(function () {
// Reload tables
$('#opener_table').load(location.pathname + ' #opener_table')
addMsg(gettext('Friendship successfully added'), 'success')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
/**
* On form submit, create a new friendship
function create_opener (e) {
// Do not submit HTML form
e.preventDefault()
// Get data and send to API
const formData = new FormData(e.target)
$.post('/api/activity/opener/', {
csrfmiddlewaretoken: formData.get('csrfmiddlewaretoken'),
activity: formData.get('activity'),
opener: formData.get('opener')
}).done(function () {
// Reload table
$('#opener_table').load(location.pathname + ' #opener_table')
addMsg(gettext('Alias successfully added'), 'success')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}*/
/**
* On click of "delete", delete the trust
* @param button_id:Integer Trust id to remove
*/
function delete_button (button_id) {
$.ajax({
url: '/api/activity/opener/' + button_id + '/',
method: 'DELETE',
headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
}).done(function () {
addMsg(gettext('Friendship successfully deleted'), 'success')
$('#opener_table').load(location.pathname + ' #opener_table')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
$(document).ready(function () {
// Attach event
document.getElementById('form_opener').addEventListener('submit', form_create_opener)
})

View File

@ -5,11 +5,13 @@ from django.utils import timezone
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from note_kfet.middlewares import get_current_request
import django_tables2 as tables import django_tables2 as tables
from django_tables2 import A from django_tables2 import A
from permission.backends import PermissionBackend
from note.templatetags.pretty_money import pretty_money from note.templatetags.pretty_money import pretty_money
from .models import Activity, Entry, Guest from .models import Activity, Entry, Guest, Opener
class ActivityTable(tables.Table): class ActivityTable(tables.Table):
@ -113,3 +115,34 @@ class EntryTable(tables.Table):
'data-last-name': lambda record: record.last_name, 'data-last-name': lambda record: record.last_name,
'data-first-name': lambda record: record.first_name, 'data-first-name': lambda record: record.first_name,
} }
# function delete_button(id) provided in template file
DELETE_TEMPLATE = """
<button id="{{ record.pk }}" class="btn btn-danger btn-sm" onclick="delete_button(this.id)"> {{ delete_trans }}</button>
"""
class OpenerTable(tables.Table):
class Meta:
attrs = {
'class': 'table table condensed table-striped',
'id': "opener_table"
}
model = Opener
fields = ("opener",)
template_name = 'django_tables2/bootstrap4.html'
show_header = False
opener = tables.Column(attrs={'td': {'class': 'text-center'}})
delete_col = tables.TemplateColumn(
template_code=DELETE_TEMPLATE,
extra_context={"delete_trans": _('Delete')},
attrs={
'td': {
'class': lambda record: 'col-sm-1'
+ (' d-none' if not PermissionBackend.check_perm(
get_current_request(), "activity.delete_opener", record)
else '')}},
verbose_name=_("Delete"),)

View File

@ -4,11 +4,31 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %} {% endcomment %}
{% load i18n perms %} {% load i18n perms %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% load static django_tables2 i18n %}
{% block content %} {% block content %}
<h1 class="text-white">{{ title }}</h1> <h1 class="text-white">{{ title }}</h1>
{% include "activity/includes/activity_info.html" %} {% include "activity/includes/activity_info.html" %}
{% if activity.activity_type.manage_entries and ".change__opener"|has_perm:activity %}
<div class="card bg-white mb-3">
<h3 class="card-header text-center">
{% trans "Openers" %}
</h3>
<div class="card-body">
<form class="input-group" method="POST" id="form_opener">
{% csrf_token %}
<input type="hidden" name="activity" value="{{ object.pk }}">
{%include "autocomplete_model.html" %}
<div class="input-group-append">
<input type="submit" class="btn btn-success" value="{% trans "Add" %}">
</div>
</form>
</div>
{% render_table opener %}
</div>
{% endif %}
{% if guests.data %} {% if guests.data %}
<div class="card bg-white mb-3"> <div class="card bg-white mb-3">
<h3 class="card-header text-center"> <h3 class="card-header text-center">
@ -22,6 +42,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblock %} {% endblock %}
{% block extrajavascript %} {% block extrajavascript %}
<script src="{% static "activity/js/opener.js" %}"></script>
<script src="{% static "js/autocomplete_model.js" %}"></script>
<script> <script>
function remove_guest(guest_id) { function remove_guest(guest_id) {
$.ajax({ $.ajax({

View File

@ -24,8 +24,8 @@ from permission.backends import PermissionBackend
from permission.views import ProtectQuerysetMixin, ProtectedCreateView from permission.views import ProtectQuerysetMixin, ProtectedCreateView
from .forms import ActivityForm, GuestForm from .forms import ActivityForm, GuestForm
from .models import Activity, Entry, Guest from .models import Activity, Entry, Guest, Opener
from .tables import ActivityTable, EntryTable, GuestTable from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable
class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView): class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView):
@ -114,8 +114,24 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))) .filter(PermissionBackend.filter_queryset(self.request, Guest, "view")))
context["guests"] = table context["guests"] = table
table = OpenerTable(data=self.object.opener.filter(activity=self.object)
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view")))
context["opener"] = table
context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start) context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
context["widget"] = {
"name": "opener",
"resetable": True,
"attrs": {
"class": "autocomplete form-control",
"id": "opener",
"api_url": "/api/note/alias/?note__polymorphic_ctype__model=noteuser",
"name_field": "name",
"placeholder": ""
}
}
return context return context

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2024-08-01 12:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0012_club_add_registration_form'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='promotion',
field=models.PositiveSmallIntegerField(default=2024, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
),
]

View File

@ -18,6 +18,7 @@ def create_special_notes(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('note', '0001_initial'), ('note', '0001_initial'),
('logs', '0001_initial'),
] ]
operations = [ operations = [

View File

@ -3111,6 +3111,199 @@
"description": "Voir ceux nous ayant pour ami, pour toujours" "description": "Voir ceux nous ayant pour ami, pour toujours"
} }
}, },
{
"model": "permission.permission",
"pk": 199,
"fields": {
"model": [
"activity",
"activity"
],
"query": "{\"opener__in\": [\"user\", \"note\", \"activity_responsible\", [\"all\"]], \"open\": true, \"activity_type__manage_entries\":true}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir les activités ouvertes dont l'utilisateur⋅rice est ouvreur⋅se"
}
},
{
"model": "permission.permission",
"pk": 200,
"fields": {
"model": [
"activity",
"activity"
],
"query": "{\"opener__in\": [\"user\", \"note\", \"activity_responsible\", [\"all\"]], \"open\": true, \"activity_type__manage_entries\":true}",
"type": "change",
"mask": 2,
"field": "open",
"permanent": false,
"description": "Fermer les activités ouvertes dont l'utilisateur⋅rice est ouvreur⋅se"
}
},
{
"model": "permission.permission",
"pk": 201,
"fields": {
"model": [
"activity",
"entry"
],
"query": "{\"activity__opener__in\": [\"user\", \"note\", \"activity_responsible\", [\"all\"]], \"activity__open\": true, \"activity__activity_type__manage_entries\":true}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Faire les entrées des activités ouvertes dont l'utilisateur⋅rice est ouvreur⋅se"
}
},
{
"model": "permission.permission",
"pk": 202,
"fields": {
"model": [
"activity",
"entry"
],
"query": "{\"activity__opener__in\": [\"user\", \"note\", \"activity_responsible\", [\"all\"]], \"activity__open\": true, \"activity__activity_type__manage_entries\":true}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir les entrées des activités ouvertes dont l'utilisateur⋅rice est ouvreur⋅se"
}
},
{
"model": "permission.permission",
"pk": 203,
"fields": {
"model": [
"activity",
"guest"
],
"query": "{\"activity__pk__in\": [\"user\", \"note\", \"activity_responsible\", [\"all\"]], \"activity__open\": true, \"activity__activity_type__manage_entries\":true}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir les invité⋅es des activités ouvertes dont l'utilisateur⋅rice est ouvreur⋅se"
}
},
{
"model": "permission.permission",
"pk": 204,
"fields": {
"model": [
"activity",
"guesttransaction"
],
"query": "[\"NOT\", {\"pk__isnull\": [\"user\", \"note\", \"activity_responsible\", [\"filter\", {\"activity__open\": true, \"activity__activity_type__manage_entries\":true}], [\"exists\"]]}]",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer une transaction d'invitation lorsque l'utilisateur⋅rice est ouvreur⋅se d'une activité ouverte"
}
},
{
"model": "permission.permission",
"pk": 205,
"fields": {
"model": [
"note",
"specialtransaction"
],
"query": "[\"NOT\", {\"pk__isnull\": [\"user\", \"note\", \"activity_responsible\", [\"filter\", {\"activity__open\": true, \"activity__activity_type__manage_entries\":true}], [\"exists\"]]}]",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer un crédit ou un retrait quelconque lorsque l'utilisateur⋅rice est ouvreur⋅se d'une activité ouverte"
}
},
{
"model": "permission.permission",
"pk": 206,
"fields": {
"model": [
"note",
"notespecial"
],
"query": "[\"NOT\", {\"pk__isnull\": [\"user\", \"note\", \"activity_responsible\", [\"filter\", {\"activity__open\": true, \"activity__activity_type__manage_entries\":true}], [\"exists\"]]}]",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Afficher l'interface crédit/retrait lorsque l'utilisateur⋅rice est ouvreur⋅se d'une activité ouverte"
}
},
{
"model": "permission.permission",
"pk": 207,
"fields": {
"model": [
"activity",
"opener"
],
"query": "{}",
"type": "view",
"mask": 2,
"field": "",
"permanent": false,
"description": "Voir les ouvreur⋅ses des activités"
}
},
{
"model": "permission.permission",
"pk": 208,
"fields": {
"model": [
"activity",
"opener"
],
"query": "{}",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Ajouter des ouvreur⋅ses aux activités"
}
},
{
"model": "permission.permission",
"pk": 209,
"fields": {
"model": [
"activity",
"opener"
],
"query": "{}",
"type": "delete",
"mask": 2,
"field": "",
"permanent": false,
"description": "Supprimer des ouvreur⋅ses aux activités"
}
},
{
"model": "permission.permission",
"pk": 210,
"fields": {
"model": [
"activity",
"activity"
],
"query": "{}",
"type": "change",
"mask": 2,
"field": "opener",
"permanent": false,
"description": "Voir le tableau des ouvreur⋅ses"
}
},
{ {
"model": "permission.role", "model": "permission.role",
"pk": 1, "pk": 1,
@ -3152,7 +3345,15 @@
191, 191,
195, 195,
196, 196,
198 198,
199,
200,
201,
202,
203,
204,
205,
206
] ]
} }
}, },
@ -3414,7 +3615,11 @@
46, 46,
148, 148,
149, 149,
182 182,
207,
208,
209,
210
] ]
} }
}, },

View File

@ -8,7 +8,7 @@ peuvent être diffusées via des calendriers ou la mailing list d'événements.
Modèles Modèles
------- -------
L'application comporte 5 modèles : activités, types d'activité, invités, entrées et transactions d'invitation. L'application comporte 6 modèles : activités, types d'activité, invités, entrées, transactions d'invitation et les ouvreur⋅ses.
Types d'activité Types d'activité
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
@ -71,6 +71,17 @@ comportent qu'un champ supplémentaire, de type ``OneToOneField`` vers ``Guest``
Ce modèle aurait pu appartenir à l'application ``note``, mais afin de rester modulaire et que l'application ``note`` Ce modèle aurait pu appartenir à l'application ``note``, mais afin de rester modulaire et que l'application ``note``
ne dépende pas de cette application, on procède de cette manière. ne dépende pas de cette application, on procède de cette manière.
Ouvreur⋅ses
~~~~~~~~~~~
Depuis la page d'une activité, il est possible d'ajouter des personnes en tant qu'« ouvreur⋅se ». Cela permet à une
personne sans aucun droit note de pouvoir faire les entrées d'une ``Activity``. Ce rôle n'est valable que pendant que
l'activité est ouverte et sur aucune autre activité. Les ouvreur⋅ses ont aussi accès à l'interface des transactions.
Ce modèle regroupe :
* Activité (clé étrangère)
* Note (clé étrangère)
Graphe Graphe
~~~~~~ ~~~~~~
@ -108,3 +119,6 @@ apparaîssent, afin de régler la taxe d'invitation : l'un prélève directement
permettent un paiement par espèces ou par carte bancaire. En réalité, les deux derniers boutons enregistrent permettent un paiement par espèces ou par carte bancaire. En réalité, les deux derniers boutons enregistrent
automatiquement un crédit sur la note de l'hôte, puis une transaction (de type ``GuestTransaction``) est faite depuis automatiquement un crédit sur la note de l'hôte, puis une transaction (de type ``GuestTransaction``) est faite depuis
la note de l'hôte vers la note de l'organisateur de l'événement. la note de l'hôte vers la note de l'organisateur de l'événement.
Si une personne souhaite faire les entrées, il est possible de l'ajouter dans la liste des ouvreur⋅ses depuis la page
de l'activité.

View File

@ -20,6 +20,7 @@ msgstr ""
#: apps/activity/apps.py:10 apps/activity/models.py:127 #: apps/activity/apps.py:10 apps/activity/models.py:127
#: apps/activity/models.py:167 #: apps/activity/models.py:167
#: apps/activity/models.py:323
msgid "activity" msgid "activity"
msgstr "activité" msgstr "activité"
@ -238,6 +239,16 @@ msgstr "invité·e·s"
msgid "Invitation" msgid "Invitation"
msgstr "Invitation" msgstr "Invitation"
#: apps/activity/models.py:330
#: apps/activity/models.py:334
msgid "Opener"
msgstr "Ouvreur⋅se"
#: apps/activity/models.py:335
#: apps/activity/templates/activity_detail.html:16
msgid "Openers"
msgstr "Ouvreur⋅ses"
#: apps/activity/tables.py:27 #: apps/activity/tables.py:27
msgid "The activity is currently open." msgid "The activity is currently open."
msgstr "Cette activité est actuellement ouverte." msgstr "Cette activité est actuellement ouverte."