diff --git a/apps/note/admin.py b/apps/note/admin.py index 433ef2dc44194483f54f56a057fb9bdce863e716..eee49feb0b92491d748fda1d94202aa1ca89a0ad 100644 --- a/apps/note/admin.py +++ b/apps/note/admin.py @@ -119,10 +119,6 @@ class TransactionAdmin(PolymorphicParentModelAdmin): list_display = ('created_at', 'poly_source', 'poly_destination', 'quantity', 'amount', 'valid') list_filter = ('valid',) - readonly_fields = ( - 'source', - 'destination', - ) def poly_source(self, obj): """ @@ -145,10 +141,7 @@ class TransactionAdmin(PolymorphicParentModelAdmin): Only valid can be edited after creation Else the amount of money would not be transferred """ - if obj: # user is editing an existing object - return 'created_at', 'source', 'destination', 'quantity', \ - 'amount' - return [] + return 'created_at', 'source', 'destination', 'quantity', 'amount' if obj else () @admin.register(MembershipTransaction, site=admin_site) @@ -157,6 +150,13 @@ class MembershipTransactionAdmin(PolymorphicChildModelAdmin): Admin customisation for MembershipTransaction """ + def get_readonly_fields(self, request, obj=None): + """ + Only valid can be edited after creation + Else the amount of money would not be transferred + """ + return ('created_at', 'source', 'destination', 'quantity', 'amount') if obj else () + @admin.register(RecurrentTransaction, site=admin_site) class RecurrentTransactionAdmin(PolymorphicChildModelAdmin): @@ -164,6 +164,13 @@ class RecurrentTransactionAdmin(PolymorphicChildModelAdmin): Admin customisation for RecurrentTransaction """ + def get_readonly_fields(self, request, obj=None): + """ + Only valid can be edited after creation + Else the amount of money would not be transferred + """ + return ('created_at', 'source', 'destination', 'quantity', 'amount') if obj else () + @admin.register(SpecialTransaction, site=admin_site) class SpecialTransactionAdmin(PolymorphicChildModelAdmin): @@ -171,6 +178,13 @@ class SpecialTransactionAdmin(PolymorphicChildModelAdmin): Admin customisation for SpecialTransaction """ + def get_readonly_fields(self, request, obj=None): + """ + Only valid can be edited after creation + Else the amount of money would not be transferred + """ + return ('created_at', 'source', 'destination', 'quantity', 'amount') if obj else () + @admin.register(TransactionTemplate, site=admin_site) class TransactionTemplateAdmin(admin.ModelAdmin): diff --git a/apps/note/api/serializers.py b/apps/note/api/serializers.py index d6572c62c75a0bb0bd3b0593990ebac3efebd042..a9c2a107caa600ea3dd2c4e2c59d890e58c1fd17 100644 --- a/apps/note/api/serializers.py +++ b/apps/note/api/serializers.py @@ -1,6 +1,7 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from django.conf import settings from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -124,9 +125,9 @@ class ConsumerSerializer(serializers.ModelSerializer): Display information about the associated note """ # If the user has no right to see the note, then we only display the note identifier - if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", obj.note): - return NotePolymorphicSerializer().to_representation(obj.note) - return dict(id=obj.note.id, name=str(obj.note)) + return NotePolymorphicSerializer().to_representation(obj.note)\ + if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", obj.note)\ + else dict(id=obj.note.id, name=str(obj.note)) def get_email_confirmed(self, obj): if isinstance(obj.note, NoteUser): @@ -231,12 +232,10 @@ class TransactionPolymorphicSerializer(PolymorphicSerializer): SpecialTransaction: SpecialTransactionSerializer, } - try: + if "activity" in settings.INSTALLED_APPS: from activity.models import GuestTransaction from activity.api.serializers import GuestTransactionSerializer model_serializer_mapping[GuestTransaction] = GuestTransactionSerializer - except ImportError: # Activity app is not loaded - pass def validate(self, attrs): resource_type = attrs.pop(self.resource_type_field_name) diff --git a/apps/note/api/views.py b/apps/note/api/views.py index 488e0d07a11f317a3745d8ce34241a972149c895..9b2130255964475effd601edbb72d781c9875236 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -45,7 +45,7 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet): | Q(alias__normalized_name__iregex="^" + alias.lower()) ) - return queryset + return queryset.order_by("id") class AliasViewSet(ReadProtectedModelViewSet): @@ -72,7 +72,6 @@ class AliasViewSet(ReadProtectedModelViewSet): try: self.perform_destroy(instance) except ValidationError as e: - print(e) return Response({e.code: e.message}, status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_204_NO_CONTENT) @@ -101,7 +100,7 @@ class AliasViewSet(ReadProtectedModelViewSet): ), all=True) - return queryset + return queryset.order_by("name") class ConsumerViewSet(ReadOnlyProtectedModelViewSet): @@ -120,7 +119,7 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet): queryset = super().get_queryset() alias = self.request.query_params.get("alias", ".*") - queryset = queryset.order_by('name').prefetch_related('note') + queryset = queryset.prefetch_related('note') # We match first an alias if it is matched without normalization, # then if the normalized pattern matches a normalized alias. queryset = queryset.filter( @@ -138,7 +137,7 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet): ), all=True) - return queryset.distinct() + return queryset.order_by('name').distinct() class TemplateCategoryViewSet(ReadProtectedModelViewSet): @@ -147,7 +146,7 @@ class TemplateCategoryViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `TemplateCategory` objects, serialize it to JSON with the given serializer, then render it on /api/note/transaction/category/ """ - queryset = TemplateCategory.objects.all() + queryset = TemplateCategory.objects.order_by("name").all() serializer_class = TemplateCategorySerializer filter_backends = [SearchFilter] search_fields = ['$name', ] @@ -159,7 +158,7 @@ class TransactionTemplateViewSet(viewsets.ModelViewSet): The djangorestframework plugin will get all `TransactionTemplate` objects, serialize it to JSON with the given serializer, then render it on /api/note/transaction/template/ """ - queryset = TransactionTemplate.objects.all() + queryset = TransactionTemplate.objects.order_by("name").all() serializer_class = TransactionTemplateSerializer filter_backends = [SearchFilter, DjangoFilterBackend] filterset_fields = ['name', 'amount', 'display', 'category', ] @@ -172,7 +171,7 @@ class TransactionViewSet(ReadProtectedModelViewSet): The djangorestframework plugin will get all `Transaction` objects, serialize it to JSON with the given serializer, then render it on /api/note/transaction/transaction/ """ - queryset = Transaction.objects.all() + queryset = Transaction.objects.order_by("-created_at").all() serializer_class = TransactionPolymorphicSerializer filter_backends = [SearchFilter] search_fields = ['$reason', ] diff --git a/apps/note/signals.py b/apps/note/signals.py index 0baa39e6243a583c06287fdb4515d94c23d2dd2f..06bb480b2f6b641645f90fdfc967bf4c6cbca4c8 100644 --- a/apps/note/signals.py +++ b/apps/note/signals.py @@ -6,11 +6,7 @@ def save_user_note(instance, raw, **_kwargs): """ Hook to create and save a note when an user is updated """ - if raw: - # When provisionning data, do not try to autocreate - return - - if instance.is_superuser or instance.profile.registration_valid: + if not raw and (instance.is_superuser or instance.profile.registration_valid): # Create note only when the registration is validated from note.models import NoteUser NoteUser.objects.get_or_create(user=instance) diff --git a/apps/note/tests/test_transactions.py b/apps/note/tests/test_transactions.py new file mode 100644 index 0000000000000000000000000000000000000000..7192d8edee0f4ba6e85714db22d367e761ec2192 --- /dev/null +++ b/apps/note/tests/test_transactions.py @@ -0,0 +1,365 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +from django.urls import reverse +from member.models import Club, Membership +from note.models import NoteUser, Transaction, TemplateCategory, TransactionTemplate, RecurrentTransaction, \ + MembershipTransaction, SpecialTransaction, NoteSpecial, Alias +from permission.models import Role + + +class TestTransactions(TestCase): + fixtures = ('initial', ) + + def setUp(self) -> None: + self.user = User.objects.create_superuser( + username="toto", + password="totototo", + email="toto@example.com", + ) + + sess = self.client.session + sess["permission_mask"] = 42 + sess.save() + self.client.force_login(self.user) + + membership = Membership.objects.create(club=Club.objects.get(name="BDE"), user=self.user) + membership.roles.add(Role.objects.get(name="Respo info")) + membership.save() + Membership.objects.create(club=Club.objects.get(name="Kfet"), user=self.user) + self.user.note.refresh_from_db() + + self.second_user = User.objects.create( + username="toto2", + ) + # Non superusers have no note until the registration get validated + NoteUser.objects.create(user=self.second_user) + + self.club = Club.objects.create( + name="clubtoto", + ) + + self.transaction = Transaction.objects.create( + source=self.second_user.note, + destination=self.user.note, + amount=4200, + reason="Test transaction", + ) + self.user.note.refresh_from_db() + self.second_user.note.refresh_from_db() + + self.category = TemplateCategory.objects.create(name="Test") + self.template = TransactionTemplate.objects.create( + name="Test", + destination=self.club.note, + category=self.category, + amount=100, + description="Test template", + ) + + def test_admin_pages(self): + """ + Load some admin pages to check that they render successfully. + """ + response = self.client.get(reverse("admin:index") + "note/note/") + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:index") + "note/transaction/") + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:index") + "note/transaction/" + str(self.transaction.pk) + "/change/") + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:index") + "note/transaction/add/?ct_id=" + + str(ContentType.objects.get_for_model(Transaction).id)) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:index") + "note/transaction/add/?ct_id=" + + str(ContentType.objects.get_for_model(RecurrentTransaction).id)) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:index") + "note/transaction/add/?ct_id=" + + str(ContentType.objects.get_for_model(MembershipTransaction).id)) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:index") + "note/transaction/add/?ct_id=" + + str(ContentType.objects.get_for_model(SpecialTransaction).id)) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:index") + "note/transactiontemplate/") + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:index") + "note/templatecategory/") + self.assertEqual(response.status_code, 200) + + def test_render_transfer_page(self): + response = self.client.get(reverse("note:transfer")) + self.assertEqual(response.status_code, 200) + + def test_transfer_api(self): + old_user_balance = self.user.note.balance + old_second_user_balance = self.second_user.note.balance + quantity = 3 + amount = 314 + total = quantity * amount + response = self.client.post("/api/note/transaction/transaction/", data=dict( + quantity=quantity, + amount=amount, + reason="Transaction through API", + valid=True, + polymorphic_ctype=ContentType.objects.get_for_model(Transaction).id, + resourcetype="Transaction", + source=self.user.note.id, + source_alias=self.user.username, + destination=self.second_user.note.id, + destination_alias=self.second_user.username, + )) + self.assertEqual(response.status_code, 201) # 201 = Created + self.assertTrue(Transaction.objects.filter(reason="Transaction through API").exists()) + + self.user.note.refresh_from_db() + self.second_user.note.refresh_from_db() + + self.assertTrue(self.user.note.balance == old_user_balance - total) + self.assertTrue(self.second_user.note.balance == old_second_user_balance + total) + + self.test_render_transfer_page() + + def test_credit_api(self): + old_user_balance = self.user.note.balance + amount = 4242 + special_type = NoteSpecial.objects.first() + response = self.client.post("/api/note/transaction/transaction/", data=dict( + quantity=1, + amount=amount, + reason="Credit through API", + valid=True, + polymorphic_ctype=ContentType.objects.get_for_model(SpecialTransaction).id, + resourcetype="SpecialTransaction", + source=special_type.id, + source_alias=str(special_type), + destination=self.user.note.id, + destination_alias=self.user.username, + last_name="TOTO", + first_name="Toto", + )) + + self.assertEqual(response.status_code, 201) # 201 = Created + self.assertTrue(Transaction.objects.filter(reason="Credit through API").exists()) + self.user.note.refresh_from_db() + self.assertTrue(self.user.note.balance == old_user_balance + amount) + + self.test_render_transfer_page() + + def test_debit_api(self): + old_user_balance = self.user.note.balance + amount = 4242 + special_type = NoteSpecial.objects.first() + response = self.client.post("/api/note/transaction/transaction/", data=dict( + quantity=1, + amount=amount, + reason="Debit through API", + valid=True, + polymorphic_ctype=ContentType.objects.get_for_model(SpecialTransaction).id, + resourcetype="SpecialTransaction", + source=self.user.note.id, + source_alias=self.user.username, + destination=special_type.id, + destination_alias=str(special_type), + last_name="TOTO", + first_name="Toto", + )) + self.assertEqual(response.status_code, 201) # 201 = Created + self.assertTrue(Transaction.objects.filter(reason="Debit through API").exists()) + self.user.note.refresh_from_db() + self.assertTrue(self.user.note.balance == old_user_balance - amount) + + self.test_render_transfer_page() + + def test_render_consos_page(self): + response = self.client.get(reverse("note:consos")) + self.assertEqual(response.status_code, 200) + + def test_consumption_api(self): + old_user_balance = self.user.note.balance + old_club_balance = self.club.note.balance + quantity = 2 + template = self.template + total = quantity * template.amount + response = self.client.post("/api/note/transaction/transaction/", data=dict( + quantity=quantity, + amount=template.amount, + reason="Consumption through API (" + template.name + ")", + valid=True, + polymorphic_ctype=ContentType.objects.get_for_model(RecurrentTransaction).id, + resourcetype="RecurrentTransaction", + source=self.user.note.id, + source_alias=self.user.username, + destination=self.club.note.id, + destination_alias=self.second_user.username, + template=template.id, + )) + self.assertEqual(response.status_code, 201) # 201 = Created + self.assertTrue(Transaction.objects.filter(destination=self.club.note).exists()) + + self.user.note.refresh_from_db() + self.club.note.refresh_from_db() + + self.assertTrue(self.user.note.balance == old_user_balance - total) + self.assertTrue(self.club.note.balance == old_club_balance + total) + + self.test_render_consos_page() + + def test_invalidate_transaction(self): + old_second_user_balance = self.second_user.note.balance + old_user_balance = self.user.note.balance + total = self.transaction.total + response = self.client.patch("/api/note/transaction/transaction/" + str(self.transaction.pk) + "/", data=dict( + valid=False, + resourcetype="Transaction", + invalidity_reason="Test invalidate", + ), content_type="application/json") + self.assertEqual(response.status_code, 200) + self.assertTrue(Transaction.objects.filter(valid=False, invalidity_reason="Test invalidate").exists()) + + self.second_user.note.refresh_from_db() + self.user.note.refresh_from_db() + + self.assertTrue(self.second_user.note.balance == old_second_user_balance + total) + self.assertTrue(self.user.note.balance == old_user_balance - total) + + self.test_render_transfer_page() + self.test_render_consos_page() + + # Now we check if we can revalidate + old_second_user_balance = self.second_user.note.balance + old_user_balance = self.user.note.balance + total = self.transaction.total + response = self.client.patch("/api/note/transaction/transaction/" + str(self.transaction.pk) + "/", data=dict( + valid=True, + resourcetype="Transaction", + ), content_type="application/json") + self.assertEqual(response.status_code, 200) + self.assertTrue(Transaction.objects.filter(valid=True, pk=self.transaction.pk).exists()) + + self.second_user.note.refresh_from_db() + self.user.note.refresh_from_db() + + self.assertTrue(self.second_user.note.balance == old_second_user_balance - total) + self.assertTrue(self.user.note.balance == old_user_balance + total) + + self.test_render_transfer_page() + self.test_render_consos_page() + + def test_render_template_list(self): + response = self.client.get(reverse("note:template_list") + "?search=test") + self.assertEqual(response.status_code, 200) + + def test_render_template_create(self): + response = self.client.get(reverse("note:template_create")) + self.assertEqual(response.status_code, 200) + response = self.client.post(reverse("note:template_create"), data=dict( + name="Test create button", + destination=self.club.note.pk, + category=self.category.pk, + amount=4200, + description="We have created a button", + highlighted=True, + display=True, + )) + self.assertRedirects(response, reverse("note:template_list"), 302, 200) + self.assertTrue(TransactionTemplate.objects.filter(name="Test create button").exists()) + + def test_render_template_update(self): + response = self.client.get(self.template.get_absolute_url()) + self.assertEqual(response.status_code, 200) + response = self.client.post(self.template.get_absolute_url(), data=dict( + name="Test update button", + destination=self.club.note.pk, + category=self.category.pk, + amount=4200, + description="We have updated a button", + highlighted=True, + display=True, + )) + self.assertRedirects(response, reverse("note:template_list"), 302, 200) + self.assertTrue(TransactionTemplate.objects.filter(name="Test update button", pk=self.template.pk).exists()) + + # Check that the price history renders properly + response = self.client.post(self.template.get_absolute_url(), data=dict( + name="Test price history", + destination=self.club.note.pk, + category=self.category.pk, + amount=4200, + description="We have updated a button", + highlighted=True, + display=True, + )) + self.assertRedirects(response, reverse("note:template_list"), 302, 200) + self.assertTrue(TransactionTemplate.objects.filter(name="Test price history", pk=self.template.pk).exists()) + response = self.client.get(reverse("note:template_update", args=(self.template.pk,))) + self.assertEqual(response.status_code, 200) + + def test_render_search_transactions(self): + response = self.client.get(reverse("note:transactions", args=(self.user.note.pk,)), data=dict( + source=self.second_user.note.alias_set.first().id, + destination=self.user.note.alias_set.first().id, + type=[ContentType.objects.get_for_model(Transaction).id], + reason="test", + valid=True, + amount_gte=0, + amount_lte=42424242, + created_after="2000-01-01 00:00", + created_before="2042-12-31 21:42", + )) + self.assertEqual(response.status_code, 200) + + def test_delete_transaction(self): + # Transactions can't be deleted with a normal usage, but it is possible through the admin interface. + old_second_user_balance = self.second_user.note.balance + old_user_balance = self.user.note.balance + total = self.transaction.total + + self.transaction.delete() + self.second_user.note.refresh_from_db() + self.user.note.refresh_from_db() + + self.assertTrue(self.second_user.note.balance == old_second_user_balance + total) + self.assertTrue(self.user.note.balance == old_user_balance - total) + + def test_calculate_last_negative_duration(self): + self.assertIsNone(self.user.note.last_negative_duration) + self.assertIsNotNone(self.second_user.note.last_negative_duration) + self.assertIsNone(self.club.note.last_negative_duration) + + Transaction.objects.create( + source=self.club.note, + destination=self.user.note, + amount=2 * self.club.note.balance + 100, + reason="Club balance is negative", + ) + + self.club.note.refresh_from_db() + self.assertIsNotNone(self.club.note.last_negative_duration) + + def test_api_search(self): + response = self.client.get("/api/note/note/") + self.assertEqual(response.status_code, 200) + response = self.client.get("/api/note/alias/?alias=.*") + self.assertEqual(response.status_code, 200) + response = self.client.get("/api/note/consumer/") + self.assertEqual(response.status_code, 200) + response = self.client.get("/api/note/transaction/transaction/") + self.assertEqual(response.status_code, 200) + response = self.client.get("/api/note/transaction/template/") + self.assertEqual(response.status_code, 200) + + def test_api_alias(self): + response = self.client.post("/api/note/alias/", data=dict( + name="testalias", + note=self.user.note.id, + )) + self.assertEqual(response.status_code, 201) + self.assertTrue(Alias.objects.filter(name="testalias").exists()) + alias = Alias.objects.get(name="testalias") + response = self.client.patch("/api/note/alias/" + str(alias.pk) + "/", dict(name="test_updated_alias"), + content_type="application/json") + self.assertEqual(response.status_code, 200) + self.assertTrue(Alias.objects.filter(name="test_updated_alias").exists()) + response = self.client.delete("/api/note/alias/" + str(alias.pk) + "/") + self.assertEqual(response.status_code, 204) diff --git a/apps/note/views.py b/apps/note/views.py index 9b383b1f0205c44ef36c983c853302c76cad9911..f5e18290560b1c6c7cde1e6df2f05cd3c7a07ce8 100644 --- a/apps/note/views.py +++ b/apps/note/views.py @@ -206,10 +206,7 @@ class TransactionSearchView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView context["form"] = form form.full_clean() - if form.is_valid(): - data = form.cleaned_data - else: - data = {} + data = form.cleaned_data if form.is_valid() else {} transactions = Transaction.objects.annotate(total_amount=F("quantity") * F("amount")).filter( PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))\ diff --git a/apps/permission/tests/test_permission_denied.py b/apps/permission/tests/test_permission_denied.py index 6dc8b888610aa9a8e3113e899775933c0bb7dea8..95cc14cd7d601a42ef405af1ca91e32a7281e310 100644 --- a/apps/permission/tests/test_permission_denied.py +++ b/apps/permission/tests/test_permission_denied.py @@ -149,3 +149,9 @@ class TestPermissionDenied(TestCase): def test_list_soge_credits(self): response = self.client.get(reverse("treasury:soge_credits")) self.assertEqual(response.status_code, 403) + + +class TestLoginRedirect(TestCase): + def test_consos_page(self): + response = self.client.get(reverse("note:consos")) + self.assertRedirects(response, reverse("login") + "?next=" + reverse("note:consos"), 302, 200)