Merge pull request #924 from ae-utbm/unique-student-card

Make student card unique per user
This commit is contained in:
NaNoMelo 2024-12-15 17:06:35 +01:00 committed by GitHub
commit 0f003870bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 355 additions and 436 deletions

View File

@ -69,7 +69,7 @@ class Command(BaseCommand):
# sqlite doesn't support this operation # sqlite doesn't support this operation
return return
sqlcmd = StringIO() sqlcmd = StringIO()
call_command("sqlsequencereset", *args, stdout=sqlcmd) call_command("sqlsequencereset", "--no-color", *args, stdout=sqlcmd)
cursor = connection.cursor() cursor = connection.cursor()
cursor.execute(sqlcmd.getvalue()) cursor.execute(sqlcmd.getvalue())
@ -137,11 +137,10 @@ class Command(BaseCommand):
) )
self.reset_index("club") 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 = [ 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="Eboutic", club=main_club, type="EBOUTIC"),
Counter(name="AE", club=main_club, type="OFFICE"), Counter(name="AE", club=main_club, type="OFFICE"),
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"), Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),

View File

@ -30,3 +30,8 @@ $shadow-color: rgb(223, 223, 223);
$background-button-color: hsl(0, 0%, 95%); $background-button-color: hsl(0, 0%, 95%);
$deepblue: #354a5f; $deepblue: #354a5f;
@mixin shadow {
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
}

View File

@ -42,6 +42,32 @@ body {
} }
} }
[tooltip] {
position: relative;
}
[tooltip]::before {
@include shadow;
opacity: 0;
z-index: 1;
content: attr(tooltip);
background: hsl(219.6, 20.8%, 96%);
color: $black-color;
border: 0.5px solid hsl(0, 0%, 50%);
;
border-radius: 5px;
padding: 5px;
top: 1em;
position: absolute;
margin-top: 5px;
white-space: nowrap;
transition: opacity 500ms ease-out;
}
[tooltip]:hover::before {
opacity: 1;
}
.ib { .ib {
display: inline-block; display: inline-block;
padding: 1px; padding: 1px;
@ -79,8 +105,7 @@ body {
} }
.shadow { .shadow {
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0, @include shadow;
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
} }
.w_big { .w_big {
@ -308,6 +333,7 @@ body {
font-size: 120%; font-size: 120%;
background-color: unset; background-color: unset;
position: relative; position: relative;
&:after { &:after {
content: ''; content: '';
position: absolute; position: absolute;
@ -318,14 +344,17 @@ body {
border-radius: 2px; border-radius: 2px;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
} }
&:hover:after { &:hover:after {
border-bottom-color: darken($primary-neutral-light-color, 20%); border-bottom-color: darken($primary-neutral-light-color, 20%);
} }
&.active:after { &.active:after {
border-bottom-color: $primary-dark-color; border-bottom-color: $primary-dark-color;
} }
} }
} }
section { section {
padding: 20px; padding: 20px;
} }

View File

@ -35,8 +35,9 @@
{% endif %} {% endif %}
{% if student_card %} {% if student_card_fragment %}
{{ student_card }} <h3>{% trans %}Student card{% endtrans %}</h3>
{{ student_card_fragment }}
<p class="justify"> <p class="justify">
{% trans %}You can add a card by asking at a counter or add it yourself here. If you want to manually {% 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 %} 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

@ -559,10 +559,6 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
context_object_name = "profile" context_object_name = "profile"
current_tab = "prefs" current_tab = "prefs"
def get_object(self, queryset=None):
user = get_object_or_404(User, pk=self.kwargs["user_id"])
return user
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
pref = self.object.preferences pref = self.object.preferences
@ -572,12 +568,10 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
if not ( if not hasattr(self.object, "trombi_user"):
hasattr(self.object, "trombi_user") and self.request.user.trombi_user.trombi
):
kwargs["trombi_form"] = UserTrombiForm() kwargs["trombi_form"] = UserTrombiForm()
if hasattr(self.object, "customer"): if hasattr(self.object, "customer"):
kwargs["student_card"] = StudentCardFormView.get_template_data( kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
self.object.customer self.object.customer
).render(self.request) ).render(self.request)
return kwargs return kwargs

View File

@ -50,9 +50,7 @@ class StudentCardForm(forms.ModelForm):
class Meta: class Meta:
model = StudentCard model = StudentCard
fields = ["uid"] fields = ["uid"]
widgets = { widgets = {"uid": NFCTextInput}
"uid": NFCTextInput,
}
def clean(self): def clean(self):
cleaned_data = super().clean() 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 = models.CharField(
_("uid"), max_length=UID_SIZE, unique=True, validators=[MinLengthValidator(4)] _("uid"), max_length=UID_SIZE, unique=True, validators=[MinLengthValidator(4)]
) )
customer = models.ForeignKey( customer = models.OneToOneField(
Customer, Customer,
related_name="student_cards", related_name="student_card",
verbose_name=_("student cards"), verbose_name=_("student card"),
null=False,
blank=False,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
class Meta:
verbose_name = _("student card")
verbose_name_plural = _("student cards")
def __str__(self): def __str__(self):
return self.uid return self.uid
@staticmethod @staticmethod
def is_valid(uid): def is_valid(uid: str) -> bool:
return ( return (
(uid.isupper() or uid.isnumeric()) (uid.isupper() or uid.isnumeric())
and len(uid) == StudentCard.UID_SIZE and len(uid) == StudentCard.UID_SIZE

View File

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

View File

@ -1,5 +1,5 @@
<div id="student_card_form"> <div id="student_card_form">
<h3>{% trans %}Add a student card{% endtrans %}</h3> {% if not customer.student_card %}
<form <form
hx-post="{{ action }}" hx-post="{{ action }}"
hx-swap="outerHTML" hx-swap="outerHTML"
@ -8,22 +8,22 @@
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} {{ form.as_p() }}
<input type="submit" value="{% trans %}Go{% endtrans %}"/> <input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form> </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 %}
<em class="no-cards">{% trans %}No student card registered.{% endtrans %}</em> <em class="no-cards">{% trans %}No student card registered.{% endtrans %}</em>
{% else %}
<p>
<span tooltip="{% trans uid=customer.student_card.uid %}uid: {{ uid }} {% endtrans %}">
{% trans %}Card registered{% endtrans %}
<i class="fa fa-check" style="color: green"></i>
</span>
&nbsp; - &nbsp;
<button
hx-get="{{ url('counter:delete_student_card', customer_id=customer.pk) }}"
hx-swap="outerHTML"
hx-target="#student_card_form"
>
{% trans %}Delete{% endtrans %}
</button>
</p>
{% endif %} {% endif %}
</div> </div>

View File

@ -0,0 +1,15 @@
<div id="student_card_form">
<form hx-post="{{ action }}" hx-swap="outerHTML" hx-target="#student_card_form">
{% csrf_token %}
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
<input
hx-get="{{ action_cancel }}"
hx-swap="outerHTML"
hx-target="#student_card_form"
type="submit"
name="cancel"
value="{% trans %}Cancel{% endtrans %}"
/>
</form>
</div>

View File

@ -1,3 +1,4 @@
import itertools
import json import json
import string import string
from datetime import timedelta from datetime import timedelta
@ -175,7 +176,6 @@ class TestStudentCard(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.customer = subscriber_user.make() cls.customer = subscriber_user.make()
cls.customer.save()
cls.barmen = subscriber_user.make(password=make_password("plop")) cls.barmen = subscriber_user.make(password=make_password("plop"))
cls.board_admin = board_user.make() cls.board_admin = board_user.make()
cls.club_admin = baker.make(User) cls.club_admin = baker.make(User)
@ -198,14 +198,30 @@ class TestStudentCard(TestCase):
StudentCard, customer=cls.customer.customer, uid="8A89B82018B0A0" StudentCard, customer=cls.customer.customer, uid="8A89B82018B0A0"
) )
def setUp(self): def login_in_counter(self):
# Auto login on counter
self.client.post( self.client.post(
reverse("counter:login", args=[self.counter.id]), reverse("counter:login", args=[self.counter.id]),
{"username": self.barmen.username, "password": "plop"}, {"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): def test_search_user_with_student_card(self):
self.login_in_counter()
response = self.client.post( response = self.client.post(
reverse("counter:details", args=[self.counter.id]), reverse("counter:details", args=[self.counter.id]),
{"code": self.valid_card.uid}, {"code": self.valid_card.uid},
@ -213,396 +229,167 @@ class TestStudentCard(TestCase):
assert response.url == reverse( assert response.url == reverse(
"counter:click", "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): def test_add_student_card_from_counter(self):
# Test card with mixed letters and numbers self.login_in_counter()
for uid in ["8B90734A802A8F", "ABCAAAFAAFAAAB", "15248196326518"]:
customer = subscriber_user.make().customer
response = self.client.post( response = self.client.post(
reverse( reverse(
"counter:add_student_card", "counter:add_student_card", kwargs={"customer_id": customer.pk}
kwargs={
"customer_id": self.customer.customer.pk,
},
), ),
{"uid": "8B90734A802A8F"}, {"uid": uid},
HTTP_REFERER=reverse( HTTP_REFERER=reverse(
"counter:click", "counter:click",
kwargs={ kwargs={"counter_id": self.counter.id, "user_id": customer.pk},
"counter_id": self.counter.id,
"user_id": self.customer.customer.pk,
},
), ),
) )
assert response.status_code == 302 assert response.status_code == 302
self.assertContains(self.client.get(response.url), text="8B90734A802A8F") customer.refresh_from_db()
assert hasattr(customer, "student_card")
# Test card with only numbers assert customer.student_card.uid == uid
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")
def test_add_student_card_from_counter_fail(self): def test_add_student_card_from_counter_fail(self):
# UID too short self.login_in_counter()
customer = subscriber_user.make().customer
for uid, error_msg in self.invalid_uids():
response = self.client.post( response = self.client.post(
reverse( reverse(
"counter:add_student_card", "counter:add_student_card", kwargs={"customer_id": customer.pk}
kwargs={
"customer_id": self.customer.customer.pk,
},
), ),
{"uid": "8B90734A802A8"}, {"uid": uid},
HTTP_REFERER=reverse( HTTP_REFERER=reverse(
"counter:click", "counter:click",
kwargs={ kwargs={"counter_id": self.counter.id, "user_id": customer.pk},
"counter_id": self.counter.id,
"user_id": self.customer.customer.pk,
},
), ),
) )
self.assertContains(response, text="Cet UID est invalide") self.assertContains(response, text="Cet UID est invalide")
self.assertContains(response, text=error_msg)
# UID too long customer.refresh_from_db()
response = self.client.post( assert not hasattr(customer, "student_card")
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.")
def test_add_student_card_from_counter_unauthorized(self): 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},
)
def send_valid_request(client, counter_id): def send_valid_request(client, counter_id):
return client.post( return client.post(
reverse( reverse(
"counter:add_student_card", "counter:add_student_card", kwargs={"customer_id": self.customer.pk}
kwargs={
"customer_id": self.customer.customer.pk,
},
), ),
{"uid": "8B90734A802A8F"}, {"uid": "8B90734A802A8F"},
HTTP_REFERER=reverse( HTTP_REFERER=reverse(
"counter:click", "counter:click",
kwargs={ kwargs={"counter_id": counter_id, "user_id": self.customer.pk},
"counter_id": counter_id,
"user_id": self.customer.customer.pk,
},
), ),
) )
# Send to a counter where you aren't logged in
assert send_valid_request(self.client, self.counter.id).status_code == 403 assert send_valid_request(self.client, self.counter.id).status_code == 403
self.login_in_counter()
barman = subscriber_user.make()
self.counter.sellers.add(barman)
# We want to test sending requests from another counter while
# we are currently registered to another counter
# so we connect to a counter and
# 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()
# Send to a counter where you aren't logged in
assert send_valid_request(client, self.counter.id).status_code == 403
# Send to a non bar counter # Send to a non bar counter
self.client.force_login(self.club_admin) client.force_login(self.club_admin)
assert send_valid_request(self.client, self.club_counter.id).status_code == 403 assert send_valid_request(client, self.club_counter.id).status_code == 403
def test_delete_student_card_with_owner(self): def test_delete_student_card_with_owner(self):
self.client.force_login(self.customer) self.client.force_login(self.customer)
self.client.post( self.client.post(
reverse( reverse(
"counter:delete_student_card", "counter:delete_student_card",
kwargs={ kwargs={"customer_id": self.customer.customer.pk},
"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_with_board_member(self): def test_delete_student_card_with_admin_user(self):
self.client.force_login(self.board_admin) """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( self.client.post(
reverse( reverse(
"counter:delete_student_card", "counter:delete_student_card",
kwargs={ kwargs={"customer_id": self.customer.customer.pk},
"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_with_root(self): def test_delete_student_card_from_counter(self):
self.client.force_login(self.root) self.login_in_counter()
self.client.post( self.client.post(
reverse( reverse(
"counter:delete_student_card", "counter:delete_student_card",
kwargs={"customer_id": self.customer.customer.pk},
),
http_referer=reverse(
"counter:click",
kwargs={ kwargs={
"customer_id": self.customer.customer.pk, "counter_id": self.counter.id,
"card_id": self.customer.customer.student_cards.first().id, "user_id": self.customer.customer.pk,
}, },
),
) )
) self.customer.customer.refresh_from_db()
assert not self.customer.customer.student_cards.exists() assert not hasattr(self.customer.customer, "student_card")
def test_delete_student_card_fail(self): def test_delete_student_card_fail(self):
"""Test that non-admin users cannot delete student cards"""
self.client.force_login(self.subscriber) self.client.force_login(self.subscriber)
response = self.client.post( response = self.client.post(
reverse( reverse(
"counter:delete_student_card", "counter:delete_student_card",
kwargs={ kwargs={"customer_id": self.customer.customer.pk},
"customer_id": self.customer.customer.pk,
"card_id": self.customer.customer.student_cards.first().id,
},
) )
) )
assert response.status_code == 403 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): def test_add_student_card_from_user_preferences(self):
# Test with owner of the card users = [self.customer, self.board_admin, self.root]
self.client.force_login(self.customer) 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( response = self.client.post(
reverse( reverse(
"counter:add_student_card", "counter:add_student_card",
kwargs={"customer_id": self.customer.customer.pk}, kwargs={"customer_id": self.customer.customer.pk},
), ),
{"uid": "8B90734A802A8F"}, {"uid": uid},
)
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 assert response.status_code == 302
response = self.client.get(response.url) response = self.client.get(response.url)
self.assertContains(response, text="04786547890123")
# Test card with only letters self.customer.customer.refresh_from_db()
response = self.client.post( assert self.customer.customer.student_card.uid == uid
reverse( self.assertContains(response, text="Carte enregistrée")
"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")
def test_add_student_card_from_user_preferences_fail(self): def test_add_student_card_from_user_preferences_fail(self):
self.client.force_login(self.customer) customer = subscriber_user.make()
# UID too short self.client.force_login(customer)
response = self.client.post( for uid, error_msg in self.invalid_uids():
reverse( url = reverse(
"counter:add_student_card", "counter:add_student_card", kwargs={"customer_id": customer.customer.pk}
kwargs={"customer_id": self.customer.customer.pk},
),
{"uid": "8B90734A802A8"},
) )
response = self.client.post(url, {"uid": uid})
self.assertContains(response, text="Cet UID est invalide") self.assertContains(response, text="Cet UID est invalide")
self.assertContains(response, text=error_msg)
# UID too long customer.refresh_from_db()
response = self.client.post( assert not hasattr(customer.customer, "student_card")
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
class TestCustomerAccountId(TestCase): class TestCustomerAccountId(TestCase):

View File

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

View File

@ -415,7 +415,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
kwargs["basket_total"] = self.sum_basket(self.request) kwargs["basket_total"] = self.sum_basket(self.request)
kwargs["refill_form"] = self.refill_form or RefillForm() kwargs["refill_form"] = self.refill_form or RefillForm()
kwargs["barmens_can_refill"] = self.object.can_refill() 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 self.customer
).render(self.request) ).render(self.request)
return kwargs return kwargs

View File

@ -15,32 +15,50 @@
from django.core.exceptions import PermissionDenied 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.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 django.views.generic.edit import DeleteView, FormView
from core.utils import FormFragmentTemplateData from core.utils import FormFragmentTemplateData
from core.views import CanEditMixin from core.views import can_edit
from counter.forms import StudentCardForm from counter.forms import StudentCardForm
from counter.models import Customer, StudentCard from counter.models import Customer, StudentCard
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
class StudentCardDeleteView(DeleteView, CanEditMixin): class StudentCardDeleteView(DeleteView):
"""View used to delete a card from a user.""" """View used to delete a card from a user. This is a fragment view !"""
model = StudentCard model = StudentCard
template_name = "core/delete_confirm.jinja" template_name = "counter/fragments/delete_student_card.jinja"
pk_url_kwarg = "card_id"
def dispatch(self, request, *args, **kwargs): def dispatch(self, request: HttpRequest, *args, **kwargs):
self.customer = get_object_or_404(Customer, pk=kwargs["customer_id"]) self.customer = get_object_or_404(Customer, pk=kwargs["customer_id"])
if not is_logged_in_counter(request) and not can_edit(
self.get_object(), request.user
):
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["action"] = self.request.path
context["action_cancel"] = self.get_success_url()
return context
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): def get_success_url(self, **kwargs):
return reverse_lazy( return reverse(
"core:user_prefs", kwargs={"user_id": self.customer.user.pk} "counter:add_student_card", kwargs={"customer_id": self.customer.pk}
) )
@ -53,23 +71,22 @@ class StudentCardFormView(FormView):
@classmethod @classmethod
def get_template_data( def get_template_data(
cls, customer: Customer cls, customer: Customer
) -> FormFragmentTemplateData[form_class]: ) -> FormFragmentTemplateData[StudentCardForm]:
"""Get necessary data to pre-render the fragment""" """Get necessary data to pre-render the fragment"""
return FormFragmentTemplateData[cls.form_class]( return FormFragmentTemplateData(
form=cls.form_class(), form=cls.form_class(),
template=cls.template_name, template=cls.template_name,
context={ context={
"action": reverse_lazy( "action": reverse(
"counter:add_student_card", kwargs={"customer_id": customer.pk} "counter:add_student_card", kwargs={"customer_id": customer.pk}
), ),
"customer": customer, "customer": customer,
"student_cards": customer.student_cards.all(),
}, },
) )
def dispatch(self, request: HttpRequest, *args, **kwargs): def dispatch(self, request: HttpRequest, *args, **kwargs):
self.customer = get_object_or_404( 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( if not is_logged_in_counter(request) and not StudentCard.can_create(
@ -79,11 +96,12 @@ class StudentCardFormView(FormView):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def form_valid(self, form): def form_valid(self, form: StudentCardForm) -> HttpResponse:
data = form.clean() data = form.clean()
res = super(FormView, self).form_valid(form) StudentCard.objects.update_or_create(
StudentCard(customer=self.customer, uid=data["uid"]).save() customer=self.customer, defaults={"uid": data["uid"]}
return res )
return super().form_valid(form)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "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" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@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:34
#: core/templates/core/user_clubs.jinja:63 #: core/templates/core/user_clubs.jinja:63
#: core/templates/core/user_edit.jinja:62 #: core/templates/core/user_edit.jinja:62
#: counter/templates/counter/fragments/create_student_card.jinja:21 #: counter/templates/counter/fragments/create_student_card.jinja:22
#: counter/templates/counter/last_ops.jinja:35 #: counter/templates/counter/last_ops.jinja:35
#: counter/templates/counter/last_ops.jinja:65 #: counter/templates/counter/last_ops.jinja:65
#: election/templates/election/election_detail.jinja:191 #: 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" 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" 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" msgid "Begin date"
msgstr "Date de début" 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 #: election/views.py:170 subscription/forms.py:21
msgid "End date" msgid "End date"
msgstr "Date de fin" msgstr "Date de fin"
@ -2574,18 +2574,21 @@ msgstr "Confirmation de suppression"
#: core/templates/core/delete_confirm.jinja:16 #: core/templates/core/delete_confirm.jinja:16
#: core/templates/core/file_delete_confirm.jinja:29 #: core/templates/core/file_delete_confirm.jinja:29
#: counter/templates/counter/fragments/delete_student_card.jinja:4
#, python-format #, python-format
msgid "Are you sure you want to delete \"%(obj)s\"?" msgid "Are you sure you want to delete \"%(obj)s\"?"
msgstr "Êtes-vous sûr de vouloir supprimer \"%(obj)s\" ?" msgstr "Êtes-vous sûr de vouloir supprimer \"%(obj)s\" ?"
#: core/templates/core/delete_confirm.jinja:17 #: core/templates/core/delete_confirm.jinja:17
#: core/templates/core/file_delete_confirm.jinja:36 #: core/templates/core/file_delete_confirm.jinja:36
#: counter/templates/counter/fragments/delete_student_card.jinja:5
msgid "Confirm" msgid "Confirm"
msgstr "Confirmation" msgstr "Confirmation"
#: core/templates/core/delete_confirm.jinja:20 #: core/templates/core/delete_confirm.jinja:20
#: core/templates/core/file_delete_confirm.jinja:46 #: core/templates/core/file_delete_confirm.jinja:46
#: counter/templates/counter/counter_click.jinja:104 #: counter/templates/counter/counter_click.jinja:104
#: counter/templates/counter/fragments/delete_student_card.jinja:12
#: sas/templates/sas/ask_picture_removal.jinja:20 #: sas/templates/sas/ask_picture_removal.jinja:20
msgid "Cancel" msgid "Cancel"
msgstr "Annuler" msgstr "Annuler"
@ -3048,7 +3051,7 @@ msgstr "Facture eboutic"
msgid "Etickets" msgid "Etickets"
msgstr "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" msgid "User has no account"
msgstr "L'utilisateur n'a pas de compte" msgstr "L'utilisateur n'a pas de compte"
@ -3371,7 +3374,7 @@ msgstr "Cotisations"
msgid "Subscription stats" msgid "Subscription stats"
msgstr "Statistiques de cotisation" 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 #: counter/views/mixins.py:89
msgid "Counters" msgid "Counters"
msgstr "Comptoirs" msgstr "Comptoirs"
@ -3543,7 +3546,7 @@ msgstr "Parrain / Marraine"
msgid "Godchild" msgid "Godchild"
msgstr "Fillot / Fillote" 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" msgid "Select user"
msgstr "Choisir un utilisateur" msgstr "Choisir un utilisateur"
@ -3596,11 +3599,11 @@ msgstr "Galaxie"
msgid "counter" msgid "counter"
msgstr "comptoir" msgstr "comptoir"
#: counter/forms.py:61 #: counter/forms.py:59
msgid "This UID is invalid" msgid "This UID is invalid"
msgstr "Cet UID est invalide" msgstr "Cet UID est invalide"
#: counter/forms.py:109 #: counter/forms.py:107
msgid "User not found" msgid "User not found"
msgstr "Utilisateur non trouvé" msgstr "Utilisateur non trouvé"
@ -3862,9 +3865,13 @@ msgstr "secret"
msgid "uid" msgid "uid"
msgstr "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" msgid "student cards"
msgstr "cartes étudiante" msgstr "cartes étudiantes"
#: counter/templates/counter/activity.jinja:5 #: counter/templates/counter/activity.jinja:5
#: counter/templates/counter/activity.jinja:13 #: counter/templates/counter/activity.jinja:13
@ -3929,7 +3936,7 @@ msgstr "Vente"
#: counter/templates/counter/counter_click.jinja:50 #: counter/templates/counter/counter_click.jinja:50
#: counter/templates/counter/counter_click.jinja:115 #: 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 #: counter/templates/counter/invoices_call.jinja:16
#: launderette/templates/launderette/launderette_admin.jinja:35 #: launderette/templates/launderette/launderette_admin.jinja:35
#: launderette/templates/launderette/launderette_click.jinja:13 #: launderette/templates/launderette/launderette_click.jinja:13
@ -4032,17 +4039,22 @@ msgid "There is no eticket in this website."
msgstr "Il n'y a pas de eticket sur ce site web." msgstr "Il n'y a pas de eticket sur ce site web."
#: counter/templates/counter/fragments/create_student_card.jinja:2 #: counter/templates/counter/fragments/create_student_card.jinja:2
msgid "Add a student card" msgid "Student card"
msgstr "Ajouter une carte étudiante" msgstr "Carte étudiante"
#: counter/templates/counter/fragments/create_student_card.jinja:13 #: 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." msgid "No student card registered."
msgstr "Aucune carte étudiante enregistrée." msgstr "Aucune carte étudiante enregistrée."
#: counter/templates/counter/fragments/create_student_card.jinja:16
msgid "Card registered"
msgstr "Carte enregistrée"
#: counter/templates/counter/fragments/create_student_card.jinja:17
#, python-format
msgid "uid: %(uid)s "
msgstr "uid: %(uid)s"
#: counter/templates/counter/invoices_call.jinja:8 #: counter/templates/counter/invoices_call.jinja:8
#, python-format #, python-format
msgid "Invoices call for %(date)s" msgid "Invoices call for %(date)s"
@ -4313,6 +4325,11 @@ msgstr "Administration des comptoirs"
msgid "Product types" msgid "Product types"
msgstr "Types de produit" msgstr "Types de produit"
#: counter/views/student_card.py:54
#, 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 #: eboutic/forms.py:88
msgid "The request was badly formatted." msgid "The request was badly formatted."
msgstr "La requête a été mal formatée." msgstr "La requête a été mal formatée."