diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 9e261bba..7098101a 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -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"), diff --git a/core/static/core/colors.scss b/core/static/core/colors.scss index 5453dd34..35dc6a69 100644 --- a/core/static/core/colors.scss +++ b/core/static/core/colors.scss @@ -29,4 +29,9 @@ $shadow-color: rgb(223, 223, 223); $background-button-color: hsl(0, 0%, 95%); -$deepblue: #354a5f; \ No newline at end of file +$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; +} \ No newline at end of file diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 50892df3..cbe8d326 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -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 { display: inline-block; padding: 1px; @@ -79,8 +105,7 @@ body { } .shadow { - box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0, - rgba(60, 64, 67, 0.15) 0 4px 8px 3px; + @include shadow; } .w_big { @@ -308,6 +333,7 @@ body { font-size: 120%; background-color: unset; position: relative; + &:after { content: ''; position: absolute; @@ -318,14 +344,17 @@ body { border-radius: 2px; transition: all 0.2s ease-in-out; } + &:hover:after { border-bottom-color: darken($primary-neutral-light-color, 20%); } + &.active:after { border-bottom-color: $primary-dark-color; } } } + section { padding: 20px; } diff --git a/core/templates/core/user_preferences.jinja b/core/templates/core/user_preferences.jinja index 722e7c44..13bee656 100644 --- a/core/templates/core/user_preferences.jinja +++ b/core/templates/core/user_preferences.jinja @@ -35,8 +35,9 @@ {% endif %} - {% if student_card %} - {{ student_card }} + {% if student_card_fragment %} +

{% trans %}Student card{% endtrans %}

+ {{ student_card_fragment }}

{% 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 %} diff --git a/core/views/user.py b/core/views/user.py index 2c6b01fc..9f724fca 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -559,10 +559,6 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView): context_object_name = "profile" 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): kwargs = super().get_form_kwargs() pref = self.object.preferences @@ -572,12 +568,10 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView): def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) - if not ( - hasattr(self.object, "trombi_user") and self.request.user.trombi_user.trombi - ): + 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 diff --git a/counter/forms.py b/counter/forms.py index 538b387c..0a8bb3be 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -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() diff --git a/counter/migrations/0026_alter_studentcard_customer.py b/counter/migrations/0026_alter_studentcard_customer.py new file mode 100644 index 00000000..f1f5cd49 --- /dev/null +++ b/counter/migrations/0026_alter_studentcard_customer.py @@ -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", + }, + ), + ] diff --git a/counter/models.py b/counter/models.py index cf285839..292cd59f 100644 --- a/counter/models.py +++ b/counter/models.py @@ -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 diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index 7c36b01b..3df77555 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -31,7 +31,8 @@

{% trans %}Amount: {% endtrans %}{{ customer.amount }} €

{% if counter.type == 'BAR' %} - {{ student_card }} +
{% trans %}Student card{% endtrans %}
+ {{ student_card_fragment }} {% endif %} diff --git a/counter/templates/counter/fragments/create_student_card.jinja b/counter/templates/counter/fragments/create_student_card.jinja index ab846c55..f15716e4 100644 --- a/counter/templates/counter/fragments/create_student_card.jinja +++ b/counter/templates/counter/fragments/create_student_card.jinja @@ -1,29 +1,29 @@
-

{% trans %}Add a student card{% endtrans %}

-
- {% csrf_token %} - {{ form.as_p() }} - - -
-
{% trans %}Registered cards{% endtrans %}
- {% if student_cards %} - - - {% else %} + {% if not customer.student_card %} +
+ {% csrf_token %} + {{ form.as_p() }} + +
{% trans %}No student card registered.{% endtrans %} + {% else %} +

+ + {% trans %}Card registered{% endtrans %} + + +   -   + +

{% endif %}
diff --git a/counter/templates/counter/fragments/delete_student_card.jinja b/counter/templates/counter/fragments/delete_student_card.jinja new file mode 100644 index 00000000..94be1c47 --- /dev/null +++ b/counter/templates/counter/fragments/delete_student_card.jinja @@ -0,0 +1,15 @@ +
+
+ {% csrf_token %} +

{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}

+ + +
+
\ No newline at end of file diff --git a/counter/tests/test_customer.py b/counter/tests/test_customer.py index f7e599e6..21f5604c 100644 --- a/counter/tests/test_customer.py +++ b/counter/tests/test_customer.py @@ -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) @@ -198,14 +198,30 @@ class TestStudentCard(TestCase): StudentCard, customer=cls.customer.customer, uid="8A89B82018B0A0" ) - def setUp(self): - # Auto login on counter + def login_in_counter(self): self.client.post( reverse("counter:login", args=[self.counter.id]), {"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): + self.login_in_counter() response = self.client.post( reverse("counter:details", args=[self.counter.id]), {"code": self.valid_card.uid}, @@ -213,396 +229,167 @@ 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") + self.login_in_counter() + 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.") + self.login_in_counter() + 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}, - ) - def send_valid_request(client, counter_id): return client.post( reverse( - "counter:add_student_card", - kwargs={ - "customer_id": self.customer.customer.pk, - }, + "counter:add_student_card", kwargs={"customer_id": self.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": self.customer.pk}, ), ) + # Send to a counter where you aren't logged in 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 - 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(client, 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) + 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}, + ) + ) + self.customer.customer.refresh_from_db() + assert not hasattr(self.customer.customer, "student_card") + + def test_delete_student_card_from_counter(self): + self.login_in_counter() self.client.post( reverse( "counter:delete_student_card", + kwargs={"customer_id": self.customer.customer.pk}, + ), + http_referer=reverse( + "counter:click", kwargs={ - "customer_id": self.customer.customer.pk, - "card_id": self.customer.customer.student_cards.first().id, + "counter_id": self.counter.id, + "user_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.customer, 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="Carte enregistrée") 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): diff --git a/counter/urls.py b/counter/urls.py index e196894f..fa659ba0 100644 --- a/counter/urls.py +++ b/counter/urls.py @@ -81,7 +81,7 @@ urlpatterns = [ name="add_student_card", ), path( - "customer//card/delete//", + "customer//card/delete/", StudentCardDeleteView.as_view(), name="delete_student_card", ), diff --git a/counter/views/click.py b/counter/views/click.py index 2fa9684d..65e889ff 100644 --- a/counter/views/click.py +++ b/counter/views/click.py @@ -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 diff --git a/counter/views/student_card.py b/counter/views/student_card.py index 99f67316..35226e95 100644 --- a/counter/views/student_card.py +++ b/counter/views/student_card.py @@ -15,32 +15,50 @@ 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 -from core.views import CanEditMixin +from core.views import can_edit from counter.forms import StudentCardForm from counter.models import Customer, StudentCard from counter.utils import is_logged_in_counter -class StudentCardDeleteView(DeleteView, CanEditMixin): - """View used to delete a card from a user.""" +class StudentCardDeleteView(DeleteView): + """View used to delete a card from a user. This is a fragment view !""" model = StudentCard - template_name = "core/delete_confirm.jinja" - pk_url_kwarg = "card_id" + template_name = "counter/fragments/delete_student_card.jinja" - def dispatch(self, request, *args, **kwargs): + def dispatch(self, request: HttpRequest, *args, **kwargs): 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) + 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): - return reverse_lazy( - "core:user_prefs", kwargs={"user_id": self.customer.user.pk} + return reverse( + "counter:add_student_card", kwargs={"customer_id": self.customer.pk} ) @@ -53,23 +71,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 +96,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) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index e122343d..482befd3 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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 \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:22 #: 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" @@ -2574,18 +2574,21 @@ msgstr "Confirmation de suppression" #: core/templates/core/delete_confirm.jinja:16 #: core/templates/core/file_delete_confirm.jinja:29 +#: counter/templates/counter/fragments/delete_student_card.jinja:4 #, python-format msgid "Are you sure you want to delete \"%(obj)s\"?" msgstr "Êtes-vous sûr de vouloir supprimer \"%(obj)s\" ?" #: core/templates/core/delete_confirm.jinja:17 #: core/templates/core/file_delete_confirm.jinja:36 +#: counter/templates/counter/fragments/delete_student_card.jinja:5 msgid "Confirm" msgstr "Confirmation" #: core/templates/core/delete_confirm.jinja:20 #: core/templates/core/file_delete_confirm.jinja:46 #: counter/templates/counter/counter_click.jinja:104 +#: counter/templates/counter/fragments/delete_student_card.jinja:12 #: sas/templates/sas/ask_picture_removal.jinja:20 msgid "Cancel" msgstr "Annuler" @@ -3048,7 +3051,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 +3374,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 +3546,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 +3599,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 +3865,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 +3936,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 +4039,22 @@ 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 "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 #, python-format msgid "Invoices call for %(date)s" @@ -4313,6 +4325,11 @@ msgstr "Administration des comptoirs" msgid "Product types" 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 msgid "The request was badly formatted." msgstr "La requête a été mal formatée."