feat: make student card unique per user

This commit is contained in:
imperosol 2024-12-08 16:07:25 +01:00 committed by Sli
parent 3b7e338808
commit 466fe58763
13 changed files with 250 additions and 426 deletions

View File

@ -69,7 +69,7 @@ class Command(BaseCommand):
# sqlite doesn't support this operation
return
sqlcmd = StringIO()
call_command("sqlsequencereset", *args, stdout=sqlcmd)
call_command("sqlsequencereset", "--no-color", *args, stdout=sqlcmd)
cursor = connection.cursor()
cursor.execute(sqlcmd.getvalue())
@ -137,11 +137,10 @@ class Command(BaseCommand):
)
self.reset_index("club")
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
self.reset_index("counter")
counters = [
*[
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR")
for bar_id, bar_name in settings.SITH_COUNTER_BARS
],
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
Counter(name="AE", club=main_club, type="OFFICE"),
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),

View File

@ -35,8 +35,8 @@
{% endif %}
{% if student_card %}
{{ student_card }}
{% if student_card_fragment %}
{{ student_card_fragment }}
<p class="justify">
{% trans %}You can add a card by asking at a counter or add it yourself here. If you want to manually
add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}

View File

@ -571,7 +571,7 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
if not hasattr(self.object, "trombi_user"):
kwargs["trombi_form"] = UserTrombiForm()
if hasattr(self.object, "customer"):
kwargs["student_card"] = StudentCardFormView.get_template_data(
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
self.object.customer
).render(self.request)
return kwargs

View File

@ -50,9 +50,7 @@ class StudentCardForm(forms.ModelForm):
class Meta:
model = StudentCard
fields = ["uid"]
widgets = {
"uid": NFCTextInput,
}
widgets = {"uid": NFCTextInput}
def clean(self):
cleaned_data = super().clean()

View File

@ -0,0 +1,53 @@
# Generated by Django 4.2.17 on 2024-12-08 13:30
from operator import attrgetter
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Count
def delete_duplicates(apps: StateApps, schema_editor):
"""Delete cards of users with more than one student cards.
For all users who have more than one registered student card, all
the cards except the last one are deleted.
"""
Customer = apps.get_model("counter", "Customer")
StudentCard = apps.get_model("counter", "StudentCard")
customers = (
Customer.objects.annotate(nb_cards=Count("student_cards"))
.filter(nb_cards__gt=1)
.prefetch_related("student_cards")
)
to_delete = [
card.id
for customer in customers
for card in sorted(customer.student_cards.all(), key=attrgetter("id"))[:-1]
]
StudentCard.objects.filter(id__in=to_delete).delete()
class Migration(migrations.Migration):
dependencies = [("counter", "0025_remove_product_parent_product_and_more")]
operations = [
migrations.RunPython(delete_duplicates, migrations.RunPython.noop),
migrations.AlterField(
model_name="studentcard",
name="customer",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="student_card",
to="counter.customer",
verbose_name="student card",
),
),
migrations.AlterModelOptions(
name="studentcard",
options={
"verbose_name": "student card",
"verbose_name_plural": "student cards",
},
),
]

View File

@ -1138,20 +1138,22 @@ class StudentCard(models.Model):
uid = models.CharField(
_("uid"), max_length=UID_SIZE, unique=True, validators=[MinLengthValidator(4)]
)
customer = models.ForeignKey(
customer = models.OneToOneField(
Customer,
related_name="student_cards",
verbose_name=_("student cards"),
null=False,
blank=False,
related_name="student_card",
verbose_name=_("student card"),
on_delete=models.CASCADE,
)
class Meta:
verbose_name = _("student card")
verbose_name_plural = _("student cards")
def __str__(self):
return self.uid
@staticmethod
def is_valid(uid):
def is_valid(uid: str) -> bool:
return (
(uid.isupper() or uid.isnumeric())
and len(uid) == StudentCard.UID_SIZE

View File

@ -31,7 +31,7 @@
<p>{% trans %}Amount: {% endtrans %}{{ customer.amount }} €</p>
{% if counter.type == 'BAR' %}
{{ student_card }}
{{ student_card_fragment }}
{% endif %}
</div>

View File

@ -1,29 +1,22 @@
<div id="student_card_form">
<h3>{% trans %}Add a student card{% endtrans %}</h3>
<form
hx-post="{{ action }}"
hx-swap="outerHTML"
hx-target="#student_card_form"
>
{% csrf_token %}
{{ form.as_p() }}
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
<h6>{% trans %}Registered cards{% endtrans %}</h6>
{% if student_cards %}
<ul>
{% for card in student_cards %}
<li>
{{ card.uid }}
<a href="{{ url('counter:delete_student_card', customer_id=customer.pk, card_id=card.id) }}">
{% trans %}Delete{% endtrans %}
</a>
</li>
{% endfor %}
</ul>
{% else %}
<h3>{% trans %}Student card{% endtrans %}</h3>
{% if not customer.student_card %}
<form
hx-post="{{ action }}"
hx-swap="outerHTML"
hx-target="#student_card_form"
>
{% csrf_token %}
{{ form.as_p() }}
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
<em class="no-cards">{% trans %}No student card registered.{% endtrans %}</em>
{% else %}
<p>
{% trans %}Registered{% endtrans %} <i class="fa fa-check"></i> &nbsp; - &nbsp;
<a href="{{ url('counter:delete_student_card', customer_id=customer.pk) }}">
{% trans %}Delete{% endtrans %}
</a>
</p>
{% endif %}
</div>

View File

@ -1,3 +1,4 @@
import itertools
import json
import string
from datetime import timedelta
@ -175,7 +176,6 @@ class TestStudentCard(TestCase):
@classmethod
def setUpTestData(cls):
cls.customer = subscriber_user.make()
cls.customer.save()
cls.barmen = subscriber_user.make(password=make_password("plop"))
cls.board_admin = board_user.make()
cls.club_admin = baker.make(User)
@ -205,6 +205,22 @@ class TestStudentCard(TestCase):
{"username": self.barmen.username, "password": "plop"},
)
def invalid_uids(self) -> list[tuple[str, str]]:
"""Return a list of invalid uids, with the associated error message"""
return [
("8B90734A802A8", ""), # too short
(
"8B90734A802A8FA",
"Assurez-vous que cette valeur comporte au plus 14 caractères (actuellement 15).",
), # too long
("8b90734a802a9f", ""), # has lowercases
(" " * 14, "Ce champ est obligatoire."), # empty
(
self.customer.customer.student_card.uid,
"Un objet Carte étudiante avec ce champ Uid existe déjà.",
),
]
def test_search_user_with_student_card(self):
response = self.client.post(
reverse("counter:details", args=[self.counter.id]),
@ -213,396 +229,144 @@ class TestStudentCard(TestCase):
assert response.url == reverse(
"counter:click",
kwargs={"counter_id": self.counter.id, "user_id": self.customer.id},
kwargs={"counter_id": self.counter.id, "user_id": self.customer.pk},
)
def test_add_student_card_from_counter(self):
# Test card with mixed letters and numbers
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={
"customer_id": self.customer.customer.pk,
},
),
{"uid": "8B90734A802A8F"},
HTTP_REFERER=reverse(
"counter:click",
kwargs={
"counter_id": self.counter.id,
"user_id": self.customer.customer.pk,
},
),
)
assert response.status_code == 302
self.assertContains(self.client.get(response.url), text="8B90734A802A8F")
# Test card with only numbers
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={
"customer_id": self.customer.customer.pk,
},
),
{"uid": "04786547890123"},
HTTP_REFERER=reverse(
"counter:click",
kwargs={
"counter_id": self.counter.id,
"user_id": self.customer.customer.pk,
},
),
)
assert response.status_code == 302
self.assertContains(self.client.get(response.url), text="04786547890123")
# Test card with only letters
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={
"customer_id": self.customer.customer.pk,
},
),
{"uid": "ABCAAAFAAFAAAB"},
HTTP_REFERER=reverse(
"counter:click",
kwargs={
"counter_id": self.counter.id,
"user_id": self.customer.customer.pk,
},
),
)
assert response.status_code == 302
self.assertContains(self.client.get(response.url), text="ABCAAAFAAFAAAB")
for uid in ["8B90734A802A8F", "ABCAAAFAAFAAAB", "15248196326518"]:
customer = subscriber_user.make().customer
response = self.client.post(
reverse(
"counter:add_student_card", kwargs={"customer_id": customer.pk}
),
{"uid": uid},
HTTP_REFERER=reverse(
"counter:click",
kwargs={"counter_id": self.counter.id, "user_id": customer.pk},
),
)
assert response.status_code == 302
customer.refresh_from_db()
assert hasattr(customer, "student_card")
assert customer.student_card.uid == uid
def test_add_student_card_from_counter_fail(self):
# UID too short
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={
"customer_id": self.customer.customer.pk,
},
),
{"uid": "8B90734A802A8"},
HTTP_REFERER=reverse(
"counter:click",
kwargs={
"counter_id": self.counter.id,
"user_id": self.customer.customer.pk,
},
),
)
self.assertContains(response, text="Cet UID est invalide")
# UID too long
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={
"customer_id": self.customer.customer.pk,
},
),
{"uid": "8B90734A802A8FA"},
HTTP_REFERER=reverse(
"counter:click",
kwargs={
"counter_id": self.counter.id,
"user_id": self.customer.customer.pk,
},
),
)
self.assertContains(response, text="Cet UID est invalide")
self.assertContains(
response,
text="Assurez-vous que cette valeur comporte au plus 14 caractères (actuellement 15).",
)
# Test with already existing card
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={
"customer_id": self.customer.customer.pk,
},
),
{"uid": self.valid_card.uid},
HTTP_REFERER=reverse(
"counter:click",
kwargs={
"counter_id": self.counter.id,
"user_id": self.customer.customer.pk,
},
),
)
self.assertContains(response, text="Cet UID est invalide")
self.assertContains(
response, text="Un objet Student card avec ce champ Uid existe déjà."
)
# Test with lowercase
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={
"customer_id": self.customer.customer.pk,
},
),
{"uid": "8b90734a802a9f"},
HTTP_REFERER=reverse(
"counter:click",
kwargs={
"counter_id": self.counter.id,
"user_id": self.customer.customer.pk,
},
),
)
self.assertContains(response, text="Cet UID est invalide")
# Test with white spaces
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={
"customer_id": self.customer.customer.pk,
},
),
{"uid": " "},
HTTP_REFERER=reverse(
"counter:click",
kwargs={
"counter_id": self.counter.id,
"user_id": self.customer.customer.pk,
},
),
)
self.assertContains(response, text="Cet UID est invalide")
self.assertContains(response, text="Ce champ est obligatoire.")
customer = subscriber_user.make().customer
for uid, error_msg in self.invalid_uids():
response = self.client.post(
reverse(
"counter:add_student_card", kwargs={"customer_id": customer.pk}
),
{"uid": uid},
HTTP_REFERER=reverse(
"counter:click",
kwargs={"counter_id": self.counter.id, "user_id": customer.pk},
),
)
self.assertContains(response, text="Cet UID est invalide")
self.assertContains(response, text=error_msg)
customer.refresh_from_db()
assert not hasattr(customer, "student_card")
def test_add_student_card_from_counter_unauthorized(self):
# Send to a counter where you aren't logged in
self.client.post(
reverse("counter:logout", args=[self.counter.id]),
{"user_id": self.barmen.id},
)
barman = subscriber_user.make()
self.counter.sellers.add(barman)
customer = self.customer.customer
# There is someone logged to a counter
# with the client of this TestCase instance,
# so we create a new client, in order to check
# that using a client not logged to a counter
# where another client is logged still isn't authorized.
client = Client()
def send_valid_request(client, counter_id):
def send_valid_request(counter_id):
return client.post(
reverse(
"counter:add_student_card",
kwargs={
"customer_id": self.customer.customer.pk,
},
"counter:add_student_card", kwargs={"customer_id": customer.pk}
),
{"uid": "8B90734A802A8F"},
HTTP_REFERER=reverse(
"counter:click",
kwargs={
"counter_id": counter_id,
"user_id": self.customer.customer.pk,
},
kwargs={"counter_id": counter_id, "user_id": customer.pk},
),
)
assert send_valid_request(self.client, self.counter.id).status_code == 403
# Send to a counter where you aren't logged in
assert send_valid_request(self.counter.id).status_code == 403
# Send to a non bar counter
self.client.force_login(self.club_admin)
assert send_valid_request(self.client, self.club_counter.id).status_code == 403
client.force_login(self.club_admin)
assert send_valid_request(self.club_counter.id).status_code == 403
def test_delete_student_card_with_owner(self):
self.client.force_login(self.customer)
self.client.post(
reverse(
"counter:delete_student_card",
kwargs={
"customer_id": self.customer.customer.pk,
"card_id": self.customer.customer.student_cards.first().id,
},
kwargs={"customer_id": self.customer.customer.pk},
)
)
assert not self.customer.customer.student_cards.exists()
self.customer.customer.refresh_from_db()
assert not hasattr(self.customer.customer, "student_card")
def test_delete_student_card_with_board_member(self):
self.client.force_login(self.board_admin)
self.client.post(
reverse(
"counter:delete_student_card",
kwargs={
"customer_id": self.customer.customer.pk,
"card_id": self.customer.customer.student_cards.first().id,
},
def test_delete_student_card_with_admin_user(self):
"""Test that AE board members and root users can delete student cards"""
for user in self.board_admin, self.root:
self.client.force_login(user)
self.client.post(
reverse(
"counter:delete_student_card",
kwargs={"customer_id": self.customer.customer.pk},
)
)
)
assert not self.customer.customer.student_cards.exists()
def test_delete_student_card_with_root(self):
self.client.force_login(self.root)
self.client.post(
reverse(
"counter:delete_student_card",
kwargs={
"customer_id": self.customer.customer.pk,
"card_id": self.customer.customer.student_cards.first().id,
},
)
)
assert not self.customer.customer.student_cards.exists()
self.customer.customer.refresh_from_db()
assert not hasattr(self.customer.customer, "student_card")
def test_delete_student_card_fail(self):
"""Test that non-admin users cannot delete student cards"""
self.client.force_login(self.subscriber)
response = self.client.post(
reverse(
"counter:delete_student_card",
kwargs={
"customer_id": self.customer.customer.pk,
"card_id": self.customer.customer.student_cards.first().id,
},
kwargs={"customer_id": self.customer.customer.pk},
)
)
assert response.status_code == 403
assert self.customer.customer.student_cards.exists()
self.subscriber.customer.refresh_from_db()
assert not hasattr(self.subscriber.customer, "student_card")
def test_add_student_card_from_user_preferences(self):
# Test with owner of the card
self.client.force_login(self.customer)
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={"customer_id": self.customer.customer.pk},
),
{"uid": "8B90734A802A8F"},
)
users = [self.subscriber, self.board_admin, self.root]
uids = ["8B90734A802A8F", "ABCAAAFAAFAAAB", "15248196326518"]
for user, uid in itertools.product(users, uids):
self.customer.customer.student_card.delete()
self.client.force_login(user)
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={"customer_id": self.customer.customer.pk},
),
{"uid": uid},
)
assert response.status_code == 302
response = self.client.get(response.url)
assert response.status_code == 302
response = self.client.get(response.url)
self.assertContains(response, text="8B90734A802A8F")
# Test with board member
self.client.force_login(self.board_admin)
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={"customer_id": self.customer.customer.pk},
),
{"uid": "8B90734A802A8A"},
)
assert response.status_code == 302
response = self.client.get(response.url)
self.assertContains(response, text="8B90734A802A8A")
# Test card with only numbers
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={"customer_id": self.customer.customer.pk},
),
{"uid": "04786547890123"},
)
assert response.status_code == 302
response = self.client.get(response.url)
self.assertContains(response, text="04786547890123")
# Test card with only letters
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={"customer_id": self.customer.customer.pk},
),
{"uid": "ABCAAAFAAFAAAB"},
)
assert response.status_code == 302
response = self.client.get(response.url)
self.assertContains(response, text="ABCAAAFAAFAAAB")
# Test with root
self.client.force_login(self.root)
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={"customer_id": self.customer.customer.pk},
),
{"uid": "8B90734A802A8B"},
)
assert response.status_code == 302
response = self.client.get(response.url)
self.assertContains(response, text="8B90734A802A8B")
self.customer.customer.refresh_from_db()
assert self.customer.customer.student_card.uid == uid
self.assertContains(response, text="Enregistré")
def test_add_student_card_from_user_preferences_fail(self):
self.client.force_login(self.customer)
# UID too short
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={"customer_id": self.customer.customer.pk},
),
{"uid": "8B90734A802A8"},
)
self.assertContains(response, text="Cet UID est invalide")
# UID too long
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={"customer_id": self.customer.customer.pk},
),
{"uid": "8B90734A802A8FA"},
)
self.assertContains(response, text="Cet UID est invalide")
# Test with already existing card
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={"customer_id": self.customer.customer.pk},
),
{"uid": self.valid_card.uid},
)
self.assertContains(
response, text="Un objet Student card avec ce champ Uid existe déjà."
)
# Test with lowercase
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={"customer_id": self.customer.customer.pk},
),
{"uid": "8b90734a802a9f"},
)
self.assertContains(response, text="Cet UID est invalide")
# Test with white spaces
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={"customer_id": self.customer.customer.pk},
),
{"uid": " " * 14},
)
self.assertContains(response, text="Cet UID est invalide")
# Test with unauthorized user
self.client.force_login(self.subscriber)
response = self.client.post(
reverse(
"counter:add_student_card",
kwargs={"customer_id": self.customer.customer.pk},
),
{"uid": "8B90734A802A8F"},
)
assert response.status_code == 403
customer = subscriber_user.make()
self.client.force_login(customer)
for uid, error_msg in self.invalid_uids():
url = reverse(
"counter:add_student_card", kwargs={"customer_id": customer.customer.pk}
)
response = self.client.post(url, {"uid": uid})
self.assertContains(response, text="Cet UID est invalide")
self.assertContains(response, text=error_msg)
customer.refresh_from_db()
assert not hasattr(customer.customer, "student_card")
class TestCustomerAccountId(TestCase):

View File

@ -81,7 +81,7 @@ urlpatterns = [
name="add_student_card",
),
path(
"customer/<int:customer_id>/card/delete/<int:card_id>/",
"customer/<int:customer_id>/card/delete/",
StudentCardDeleteView.as_view(),
name="delete_student_card",
),

View File

@ -415,7 +415,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
kwargs["basket_total"] = self.sum_basket(self.request)
kwargs["refill_form"] = self.refill_form or RefillForm()
kwargs["barmens_can_refill"] = self.object.can_refill()
kwargs["student_card"] = StudentCardFormView.get_template_data(
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
self.customer
).render(self.request)
return kwargs

View File

@ -15,9 +15,10 @@
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.generic.edit import DeleteView, FormView
from core.utils import FormFragmentTemplateData
@ -32,16 +33,21 @@ class StudentCardDeleteView(DeleteView, CanEditMixin):
model = StudentCard
template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "card_id"
def dispatch(self, request, *args, **kwargs):
self.customer = get_object_or_404(Customer, pk=kwargs["customer_id"])
return super().dispatch(request, *args, **kwargs)
def get_object(self, queryset=None):
if not hasattr(self.customer, "student_card"):
raise Http404(
_("%(name)s has no registered student card")
% {"name": self.customer.user.get_full_name()}
)
return self.customer.student_card
def get_success_url(self, **kwargs):
return reverse_lazy(
"core:user_prefs", kwargs={"user_id": self.customer.user.pk}
)
return reverse("core:user_prefs", kwargs={"user_id": self.customer.user_id})
class StudentCardFormView(FormView):
@ -53,23 +59,22 @@ class StudentCardFormView(FormView):
@classmethod
def get_template_data(
cls, customer: Customer
) -> FormFragmentTemplateData[form_class]:
) -> FormFragmentTemplateData[StudentCardForm]:
"""Get necessary data to pre-render the fragment"""
return FormFragmentTemplateData[cls.form_class](
return FormFragmentTemplateData(
form=cls.form_class(),
template=cls.template_name,
context={
"action": reverse_lazy(
"action": reverse(
"counter:add_student_card", kwargs={"customer_id": customer.pk}
),
"customer": customer,
"student_cards": customer.student_cards.all(),
},
)
def dispatch(self, request: HttpRequest, *args, **kwargs):
self.customer = get_object_or_404(
Customer.objects.prefetch_related("student_cards"), pk=kwargs["customer_id"]
Customer.objects.select_related("student_card"), pk=kwargs["customer_id"]
)
if not is_logged_in_counter(request) and not StudentCard.can_create(
@ -79,11 +84,12 @@ class StudentCardFormView(FormView):
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
def form_valid(self, form: StudentCardForm) -> HttpResponse:
data = form.clean()
res = super(FormView, self).form_valid(form)
StudentCard(customer=self.customer, uid=data["uid"]).save()
return res
StudentCard.objects.update_or_create(
customer=self.customer, defaults={"uid": data["uid"]}
)
return super().form_valid(form)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)

View File

@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-09 12:28+0100\n"
"POT-Creation-Date: 2024-12-11 09:34+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -369,7 +369,7 @@ msgstr "Compte en banque : "
#: core/templates/core/user_clubs.jinja:34
#: core/templates/core/user_clubs.jinja:63
#: core/templates/core/user_edit.jinja:62
#: counter/templates/counter/fragments/create_student_card.jinja:21
#: counter/templates/counter/fragments/create_student_card.jinja:18
#: counter/templates/counter/last_ops.jinja:35
#: counter/templates/counter/last_ops.jinja:65
#: election/templates/election/election_detail.jinja:191
@ -950,11 +950,11 @@ msgstr "Une action est requise"
msgid "You must specify at least an user or an email address"
msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
#: club/forms.py:149 counter/forms.py:193
#: club/forms.py:149 counter/forms.py:189
msgid "Begin date"
msgstr "Date de début"
#: club/forms.py:152 com/views.py:84 com/views.py:202 counter/forms.py:196
#: club/forms.py:152 com/views.py:84 com/views.py:202 counter/forms.py:192
#: election/views.py:170 subscription/forms.py:21
msgid "End date"
msgstr "Date de fin"
@ -3048,7 +3048,7 @@ msgstr "Facture eboutic"
msgid "Etickets"
msgstr "Etickets"
#: core/templates/core/user_account.jinja:69 core/views/user.py:639
#: core/templates/core/user_account.jinja:69 core/views/user.py:633
msgid "User has no account"
msgstr "L'utilisateur n'a pas de compte"
@ -3371,7 +3371,7 @@ msgstr "Cotisations"
msgid "Subscription stats"
msgstr "Statistiques de cotisation"
#: core/templates/core/user_tools.jinja:48 counter/forms.py:166
#: core/templates/core/user_tools.jinja:48 counter/forms.py:162
#: counter/views/mixins.py:89
msgid "Counters"
msgstr "Comptoirs"
@ -3543,7 +3543,7 @@ msgstr "Parrain / Marraine"
msgid "Godchild"
msgstr "Fillot / Fillote"
#: core/views/forms.py:310 counter/forms.py:80 trombi/views.py:151
#: core/views/forms.py:310 counter/forms.py:78 trombi/views.py:151
msgid "Select user"
msgstr "Choisir un utilisateur"
@ -3596,11 +3596,11 @@ msgstr "Galaxie"
msgid "counter"
msgstr "comptoir"
#: counter/forms.py:61
#: counter/forms.py:59
msgid "This UID is invalid"
msgstr "Cet UID est invalide"
#: counter/forms.py:109
#: counter/forms.py:107
msgid "User not found"
msgstr "Utilisateur non trouvé"
@ -3862,9 +3862,13 @@ msgstr "secret"
msgid "uid"
msgstr "uid"
#: counter/models.py:1144
#: counter/models.py:1144 counter/models.py:1149
msgid "student card"
msgstr "carte étudiante"
#: counter/models.py:1150
msgid "student cards"
msgstr "cartes étudiante"
msgstr "cartes étudiantes"
#: counter/templates/counter/activity.jinja:5
#: counter/templates/counter/activity.jinja:13
@ -3929,7 +3933,7 @@ msgstr "Vente"
#: counter/templates/counter/counter_click.jinja:50
#: counter/templates/counter/counter_click.jinja:115
#: counter/templates/counter/fragments/create_student_card.jinja:10
#: counter/templates/counter/fragments/create_student_card.jinja:11
#: counter/templates/counter/invoices_call.jinja:16
#: launderette/templates/launderette/launderette_admin.jinja:35
#: launderette/templates/launderette/launderette_click.jinja:13
@ -4032,17 +4036,17 @@ msgid "There is no eticket in this website."
msgstr "Il n'y a pas de eticket sur ce site web."
#: counter/templates/counter/fragments/create_student_card.jinja:2
msgid "Add a student card"
msgstr "Ajouter une carte étudiante"
msgid "Student card"
msgstr "Carte étudiante"
#: counter/templates/counter/fragments/create_student_card.jinja:13
msgid "Registered cards"
msgstr "Cartes enregistrées"
#: counter/templates/counter/fragments/create_student_card.jinja:27
msgid "No student card registered."
msgstr "Aucune carte étudiante enregistrée."
#: counter/templates/counter/fragments/create_student_card.jinja:16
msgid "Registered"
msgstr "Enregistré"
#: counter/templates/counter/invoices_call.jinja:8
#, python-format
msgid "Invoices call for %(date)s"
@ -4313,6 +4317,11 @@ msgstr "Administration des comptoirs"
msgid "Product types"
msgstr "Types de produit"
#: counter/views/student_card.py:44
#, python-format
msgid "%(name)s has no registered student card"
msgstr "%(name)s n'a pas de carte étudiante enregistrée"
#: eboutic/forms.py:88
msgid "The request was badly formatted."
msgstr "La requête a été mal formatée."