From 6a6df4f493aca30b5fff34e48764636883a0bcee Mon Sep 17 00:00:00 2001 From: Martin Clementz Date: Sat, 16 Sep 2023 01:39:48 +0200 Subject: [PATCH] =?UTF-8?q?Midnight=20release=20=F0=9F=8C=83=20(#700)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixed flake8 (#672) * Added Event permissions to Jubkom temporarily (#680) * Feat(event)/contact person (#685) * altered Event model: contact_person with foregin key to an user added. Updated Event serializer and wrote a test for creating event with contact person * small changes in event serializer * formating * feat(payments): create payment system (#675) * Created model, started views * fixed merge * fixed merge * started on paid event factory and model test * added model tests and integration test for creating paid event * added field to Event serializer * Related Manager Error When testing * Paid Event Done, Order started on * Formatted * change * Order Done * removed print * added .env * debugging registration tests * added possibilty for adding a paid event without a price, test finished * Fixed test * started on update test of paid event * created update test for paid event * started on adding celery task for removing unpaid orders * A good days work * finished test_not_paid_order_is_kicked_of_event_after_timeout * added unit test for order task * started on vipps callback * made test for checking if vipps callback updates order status * started on test for forcing vipps payment * must change check of is_paid_event * fixed check for paid event in registration * fixed deletion of events such that the paid event also gets deleted, and made a test for it * bugfixes * changed event endpoint for deleting event. Now orders also get deleted * started on viewset for orders, and made test for retrieving order for an user at a given event * need to fix bug with payment tokens * fixed bug with expire date for orders * fixed new order bug * must fix config for celery always_eager * fixed elways eager * fixed update of order after payment * checks if user already has a paid order * fixed updating from paid event to not paid event and vice versa * fixed failing tests, and put all vipps urls and config variables in env so they are easy to change for production * removed secret keys etc... * Update CHANGELOG.md * Update CHANGELOG.md * Update CHANGELOG.md * removed conf update from celery.app * fixed paid_information property in event model * fixed .local file * fixed .local file * updated order model for cascade * formated * fixed flake8 errors * added migration * Fixed migrations * fixed migrations * fixed order factory * refactored code for PR * refactored code * altered order model to allow an order not to get deleted when a related event or user is deleted * small changes from PR * empty commit --------- Co-authored-by: Lea Raknes Co-authored-by: Mads Nylund Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> * Added Fondet to allowed and fixed typo (#691) * removed celery task_always_eager local variable (#694) * September Update 🎉 (#695) * Fixed flake8 (#672) * Added Event permissions to Jubkom temporarily (#680) * Feat(event)/contact person (#685) * altered Event model: contact_person with foregin key to an user added. Updated Event serializer and wrote a test for creating event with contact person * small changes in event serializer * formating * feat(payments): create payment system (#675) * Created model, started views * fixed merge * fixed merge * started on paid event factory and model test * added model tests and integration test for creating paid event * added field to Event serializer * Related Manager Error When testing * Paid Event Done, Order started on * Formatted * change * Order Done * removed print * added .env * debugging registration tests * added possibilty for adding a paid event without a price, test finished * Fixed test * started on update test of paid event * created update test for paid event * started on adding celery task for removing unpaid orders * A good days work * finished test_not_paid_order_is_kicked_of_event_after_timeout * added unit test for order task * started on vipps callback * made test for checking if vipps callback updates order status * started on test for forcing vipps payment * must change check of is_paid_event * fixed check for paid event in registration * fixed deletion of events such that the paid event also gets deleted, and made a test for it * bugfixes * changed event endpoint for deleting event. Now orders also get deleted * started on viewset for orders, and made test for retrieving order for an user at a given event * need to fix bug with payment tokens * fixed bug with expire date for orders * fixed new order bug * must fix config for celery always_eager * fixed elways eager * fixed update of order after payment * checks if user already has a paid order * fixed updating from paid event to not paid event and vice versa * fixed failing tests, and put all vipps urls and config variables in env so they are easy to change for production * removed secret keys etc... * Update CHANGELOG.md * Update CHANGELOG.md * Update CHANGELOG.md * removed conf update from celery.app * fixed paid_information property in event model * fixed .local file * fixed .local file * updated order model for cascade * formated * fixed flake8 errors * added migration * Fixed migrations * fixed migrations * fixed order factory * refactored code for PR * refactored code * altered order model to allow an order not to get deleted when a related event or user is deleted * small changes from PR * empty commit --------- Co-authored-by: Lea Raknes Co-authored-by: Mads Nylund Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> * Added Fondet to allowed and fixed typo (#691) * removed celery task_always_eager local variable (#694) --------- Co-authored-by: Thomas Svendal Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Co-authored-by: Lea Raknes Co-authored-by: Mads Nylund * fix: pliz 💀 * chore: remove fucker file --------- Co-authored-by: Thomas Svendal Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Co-authored-by: Lea Raknes Co-authored-by: Mads Nylund --- CHANGELOG.md | 4 +- app/celery.py | 1 + app/common/enums.py | 3 +- .../migrations/0053_event_contact_person.py | 20 +++ app/content/models/event.py | 15 ++ app/content/models/news.py | 4 +- app/content/serializers/event.py | 63 ++++++- app/content/serializers/registration.py | 21 +++ app/content/tests/test_registration_model.py | 5 - app/content/tests/test_strike_model.py | 2 - app/content/util/event_utils.py | 66 ++++++++ app/content/views/event.py | 16 +- app/content/views/registration.py | 10 ++ app/payment/__init__.py | 0 app/payment/admin.py | 8 + app/payment/apps.py | 5 + app/payment/enums.py | 11 ++ app/payment/exceptions.py | 14 ++ app/payment/factories/__init__.py | 0 app/payment/factories/order_factory.py | 21 +++ app/payment/factories/paid_event_factory.py | 17 ++ app/payment/filters/__init__.py | 0 app/payment/migrations/0001_initial.py | 49 ++++++ app/payment/migrations/__init__.py | 0 app/payment/models/__init__.py | 2 + app/payment/models/order.py | 40 +++++ app/payment/models/paid_event.py | 24 +++ app/payment/serializers/__init__.py | 1 + app/payment/serializers/order.py | 31 ++++ app/payment/serializers/paid_event.py | 15 ++ app/payment/tasks.py | 25 +++ app/payment/tests.py | 3 + app/payment/tests/__init__.py | 0 app/payment/tests/test_order_model.py | 27 +++ app/payment/urls.py | 14 ++ app/payment/util/payment_utils.py | 57 +++++++ app/payment/views/__init__.py | 0 app/payment/views/order.py | 30 ++++ app/payment/views/vipps_callback.py | 50 ++++++ app/settings.py | 17 +- app/tests/conftest.py | 6 + app/tests/content/test_event_integration.py | 39 ++++- app/tests/content/test_news_integration.py | 3 +- .../content/test_registration_integration.py | 28 ++++ app/tests/payment/__init__.py | 0 app/tests/payment/test_order_integration.py | 21 +++ .../payment/test_paid_event_integration.py | 154 ++++++++++++++++++ app/tests/payment/test_vipps_callback.py | 49 ++++++ app/urls.py | 1 + app/util/exceptions.py | 3 +- docker-compose.yml | 1 + 51 files changed, 975 insertions(+), 21 deletions(-) create mode 100644 app/content/migrations/0053_event_contact_person.py create mode 100644 app/content/util/event_utils.py create mode 100644 app/payment/__init__.py create mode 100644 app/payment/admin.py create mode 100644 app/payment/apps.py create mode 100644 app/payment/enums.py create mode 100644 app/payment/exceptions.py create mode 100644 app/payment/factories/__init__.py create mode 100644 app/payment/factories/order_factory.py create mode 100644 app/payment/factories/paid_event_factory.py create mode 100644 app/payment/filters/__init__.py create mode 100644 app/payment/migrations/0001_initial.py create mode 100644 app/payment/migrations/__init__.py create mode 100644 app/payment/models/__init__.py create mode 100644 app/payment/models/order.py create mode 100644 app/payment/models/paid_event.py create mode 100644 app/payment/serializers/__init__.py create mode 100644 app/payment/serializers/order.py create mode 100644 app/payment/serializers/paid_event.py create mode 100644 app/payment/tasks.py create mode 100644 app/payment/tests.py create mode 100644 app/payment/tests/__init__.py create mode 100644 app/payment/tests/test_order_model.py create mode 100644 app/payment/urls.py create mode 100644 app/payment/util/payment_utils.py create mode 100644 app/payment/views/__init__.py create mode 100644 app/payment/views/order.py create mode 100644 app/payment/views/vipps_callback.py create mode 100644 app/tests/payment/__init__.py create mode 100644 app/tests/payment/test_order_integration.py create mode 100644 app/tests/payment/test_paid_event_integration.py create mode 100644 app/tests/payment/test_vipps_callback.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e1ea00ded..eab0290e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,12 @@ ## Neste versjon -## Versjon 2022.11.17 - - ✨ **Spørreskjemaer** NOK medlemmer kan lage spørreskjema. - ⚡ **Bruker** Nå kan ikke HS lenger endre eller slette brukere. - ⚡ **Mails** Nå logger vi på eposttjeneren kun en gang per batch med epost som sendes. - 🦟 **Bøter** Nå skal bilder på bøter ikke lengre forsvinne. +- ✨ **Betalte arrangementer med Vipps betaling**. Det kan nå opprettes arrangementer som krever betaling for å melde seg på. Denne betalingen betales via Vipps. +- ⚡ **Nyheter** Fondesforvalere kan nå lage nyheter. ## Versjon 2022.10.13 diff --git a/app/celery.py b/app/celery.py index 8e71bd523..aee19e8d4 100644 --- a/app/celery.py +++ b/app/celery.py @@ -58,6 +58,7 @@ result_serializer="json", timezone=settings.TIME_ZONE, enable_utc=True, + task_always_eager=False, ) diff --git a/app/common/enums.py b/app/common/enums.py index 92a3e3581..3f50aa296 100644 --- a/app/common/enums.py +++ b/app/common/enums.py @@ -39,8 +39,9 @@ def admin(cls): class Groups(ChoiceEnum): TIHLDE = "TIHLDE" - REDAKSJONEN = "redaksjonen" JUBKOM = "JubKom" + REDAKSJONEN = "Redaksjonen" + FONDET = "Forvaltningsgruppen" class AppModel(ChoiceEnum): diff --git a/app/content/migrations/0053_event_contact_person.py b/app/content/migrations/0053_event_contact_person.py new file mode 100644 index 000000000..959266bd2 --- /dev/null +++ b/app/content/migrations/0053_event_contact_person.py @@ -0,0 +1,20 @@ +# Generated by Django 4.0.8 on 2023-09-01 06:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0052_event_rules_and_photo_in_user'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='contact_person', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contact_events', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/app/content/models/event.py b/app/content/models/event.py index f02b3e0e7..9cef512b2 100644 --- a/app/content/models/event.py +++ b/app/content/models/event.py @@ -38,6 +38,15 @@ class Event(BaseModel, OptionalImage, BasePermissionModel): related_name="events", ) + contact_person = models.ForeignKey( + User, + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + related_name="contact_events", + ) + favorite_users = models.ManyToManyField( User, related_name="favorite_events", blank=True ) @@ -82,6 +91,12 @@ def website_url(self): def expired(self): return self.end_date <= yesterday() + @property + def is_paid_event(self): + return hasattr(self, "paid_information") and ( + self.paid_information is not None or not len(self.paid_information) + ) + @property def list_count(self): """Number of users registered to attend the event""" diff --git a/app/content/models/news.py b/app/content/models/news.py index e0ce1cafc..ede2690b4 100644 --- a/app/content/models/news.py +++ b/app/content/models/news.py @@ -1,7 +1,7 @@ from django.conf import settings from django.db import models -from app.common.enums import AdminGroup +from app.common.enums import AdminGroup, Groups from app.common.permissions import BasePermissionModel from app.util.models import BaseModel, OptionalImage @@ -18,7 +18,7 @@ class News(BaseModel, OptionalImage, BasePermissionModel): ) body = models.TextField() - write_access = AdminGroup.all() + write_access = [*AdminGroup.all(), Groups.FONDET] class Meta: verbose_name_plural = "News" diff --git a/app/content/serializers/event.py b/app/content/serializers/event.py index 1aaa83c6c..01b8a2795 100644 --- a/app/content/serializers/event.py +++ b/app/content/serializers/event.py @@ -10,8 +10,11 @@ PriorityPoolCreateSerializer, PriorityPoolSerializer, ) +from app.content.serializers.user import DefaultUserSerializer from app.group.models.group import Group from app.group.serializers.group import SimpleGroupSerializer +from app.payment.models.paid_event import PaidEvent +from app.payment.serializers.paid_event import PaidEventCreateSerializer class EventSerializer(serializers.ModelSerializer): @@ -21,6 +24,10 @@ class EventSerializer(serializers.ModelSerializer): survey = serializers.PrimaryKeyRelatedField(many=False, read_only=True) organizer = SimpleGroupSerializer(read_only=True) permissions = DRYPermissionsField(actions=["write", "read"], object_only=True) + paid_information = serializers.SerializerMethodField( + required=False, allow_null=True + ) + contact_person = DefaultUserSerializer(read_only=True, required=False) class Meta: model = Event @@ -52,8 +59,19 @@ class Meta: "enforces_previous_strikes", "permissions", "priority_pools", + "paid_information", + "contact_person", ) + def get_paid_information(self, obj): + if not obj.is_paid_event: + return None + + paid_event = PaidEvent.objects.get(event=obj) + if paid_event: + return PaidEventCreateSerializer(paid_event).data + return None + def validate_limit(self, limit): """ Validate that the event limit is greater or equal to 0 and @@ -102,6 +120,7 @@ class Meta: class EventCreateAndUpdateSerializer(BaseModelSerializer): priority_pools = PriorityPoolCreateSerializer(many=True, required=False) + paid_information = PaidEventCreateSerializer(required=False) class Meta: model = Event @@ -126,21 +145,45 @@ class Meta: "start_registration_at", "title", "priority_pools", + "paid_information", + "is_paid_event", + "contact_person", ) + def to_internal_value(self, data): + data.setdefault("paid_information", {}) + return super().to_internal_value(data) + def create(self, validated_data): priority_pools_data = validated_data.pop("priority_pools", []) - + paid_information_data = validated_data.pop("paid_information", None) event = super().create(validated_data) - self.set_priority_pools(event, priority_pools_data) + if len(paid_information_data): + self.set_paid_information(event, paid_information_data) + return event def update(self, instance, validated_data): priority_pools_data = validated_data.pop("priority_pools", None) - + paid_information_data = validated_data.pop("paid_information", None) event = super().update(instance, validated_data) + if paid_information_data and not event.is_paid_event: + PaidEvent.objects.create( + event=event, + price=paid_information_data["price"], + paytime=paid_information_data["paytime"], + ) + + if event.is_paid_event and not len(paid_information_data): + paid_event = PaidEvent.objects.get(event=event) + if paid_event: + paid_event.delete() + event.paid_information = None + + if len(paid_information_data): + self.update_paid_information(event, paid_information_data) if priority_pools_data: self.update_priority_pools(event, priority_pools_data) @@ -152,6 +195,11 @@ def update_priority_pools(self, event, priority_pools_data): event.priority_pools.all().delete() self.set_priority_pools(event, priority_pools_data) + def update_paid_information(self, event, paid_information_data): + event.paid_information.price = paid_information_data["price"] + event.paid_information.paytime = paid_information_data["paytime"] + event.paid_information.save() + @staticmethod def set_priority_pools(event, priority_pool_data): for priority_pool in priority_pool_data: @@ -159,6 +207,15 @@ def set_priority_pools(event, priority_pool_data): priority_pool = PriorityPool.objects.create(event=event) priority_pool.groups.add(*groups) + @staticmethod + def set_paid_information(event, paid_information_data): + price = paid_information_data.get("price") + paytime = paid_information_data.get("paytime") + paid_information = PaidEvent.objects.create( + event=event, price=price, paytime=paytime + ) + paid_information.save() + class EventStatisticsSerializer(BaseModelSerializer): has_attended_count = serializers.SerializerMethodField() diff --git a/app/content/serializers/registration.py b/app/content/serializers/registration.py index c73384729..0ca742d0a 100644 --- a/app/content/serializers/registration.py +++ b/app/content/serializers/registration.py @@ -8,12 +8,16 @@ ) from app.forms.enums import EventFormType from app.forms.serializers.submission import SubmissionInRegistrationSerializer +from app.payment.enums import OrderStatus +from app.payment.serializers.order import OrderSerializer class RegistrationSerializer(BaseModelSerializer): user_info = UserListSerializer(source="user", read_only=True) survey_submission = serializers.SerializerMethodField() has_unanswered_evaluation = serializers.SerializerMethodField() + order = serializers.SerializerMethodField(required=False) + has_paid_order = serializers.SerializerMethodField(required=False) class Meta: model = Registration @@ -26,6 +30,8 @@ class Meta: "created_at", "survey_submission", "has_unanswered_evaluation", + "order", + "has_paid_order", ) def get_survey_submission(self, obj): @@ -35,6 +41,21 @@ def get_survey_submission(self, obj): def get_has_unanswered_evaluation(self, obj): return obj.user.has_unanswered_evaluations_for(obj.event) + def get_order(self, obj): + order = obj.event.orders.filter(user=obj.user).first() + if order: + return OrderSerializer(order).data + return None + + def get_has_paid_order(self, obj): + for order in obj.event.orders.filter(user=obj.user): + if ( + order.status == OrderStatus.CAPTURE + or order.status == OrderStatus.RESERVE + or order.status == OrderStatus.SALE + ): + return True + class PublicRegistrationSerializer(BaseModelSerializer): user_info = serializers.SerializerMethodField() diff --git a/app/content/tests/test_registration_model.py b/app/content/tests/test_registration_model.py index f46bd1c35..d992101ba 100644 --- a/app/content/tests/test_registration_model.py +++ b/app/content/tests/test_registration_model.py @@ -87,10 +87,6 @@ def test_swap_users_when_event_is_full( event=event_with_priority_pool, user=user_in_priority_pool ) - print("event.limit") - print(event_with_priority_pool.limit) - print(event_with_priority_pool.registrations.all()) - registration_not_in_priority_pool.refresh_from_db() assert not registration_in_priority_pool.is_on_wait @@ -400,7 +396,6 @@ def test_bump_user_from_wait_when_event_is_full_does_not_increments_limit( Tests that event limit is not incremented when an admin attempts to bump a user up from the wait list when the event is full. """ - print(event_with_priority_pool.registrations.all()) registration = RegistrationFactory(event=event_with_priority_pool) limit = event_with_priority_pool.limit registration.is_on_wait = False diff --git a/app/content/tests/test_strike_model.py b/app/content/tests/test_strike_model.py index 972e2d1e1..de90371c4 100644 --- a/app/content/tests/test_strike_model.py +++ b/app/content/tests/test_strike_model.py @@ -36,8 +36,6 @@ def test_strike_is_active_or_not_with_freeze_through_winter_holiday( mock_now.return_value = today strike = StrikeFactory.build(created_at=created_at) - print(strike.expires_at) - assert strike.active == expected_result diff --git a/app/content/util/event_utils.py b/app/content/util/event_utils.py new file mode 100644 index 000000000..034b98cc4 --- /dev/null +++ b/app/content/util/event_utils.py @@ -0,0 +1,66 @@ +import os +import uuid +from datetime import datetime, timedelta + +from app.payment.enums import OrderStatus +from app.payment.models.order import Order +from app.payment.tasks import check_if_has_paid +from app.payment.util.payment_utils import ( + get_new_access_token, + initiate_payment, +) + + +def create_payment_order(event, request, registration): + """ + Checks if event is a paid event + and creates a new Vipps payment order. + """ + + if event.is_paid_event: + access_token = os.environ.get("PAYMENT_ACCESS_TOKEN") + expires_at = os.environ.get("PAYMENT_ACCESS_TOKEN_EXPIRES_AT") + if not access_token or datetime.now() >= datetime.fromtimestamp( + int(expires_at) + ): + (expires_at, access_token) = get_new_access_token() + os.environ.update({"PAYMENT_ACCESS_TOKEN": access_token}) + os.environ.update({"PAYMENT_ACCESS_TOKEN_EXPIRES_AT": str(expires_at)}) + + prev_orders = Order.objects.filter(event=event, user=request.user) + has_paid_order = False + + for order in prev_orders: + if ( + order.status == OrderStatus.CAPTURE + or order.status == OrderStatus.RESERVE + or order.status == OrderStatus.SALE + ): + has_paid_order = True + break + + if not has_paid_order: + + paytime = event.paid_information.paytime + + expire_date = datetime.now() + timedelta( + hours=paytime.hour, minutes=paytime.minute, seconds=paytime.second + ) + + # Create Order + order_id = uuid.uuid4() + amount = int(event.paid_information.price * 100) + res = initiate_payment(amount, str(order_id), event.title, access_token) + payment_link = res["url"] + order = Order.objects.create( + order_id=order_id, + user=request.user, + event=event, + payment_link=payment_link, + expire_date=expire_date, + ) + order.save() + check_if_has_paid.apply_async( + args=(order.order_id, registration.registration_id), + countdown=(paytime.hour * 60 + paytime.minute) * 60 + paytime.second, + ) diff --git a/app/content/views/event.py b/app/content/views/event.py index 6313d16bb..632e2c855 100644 --- a/app/content/views/event.py +++ b/app/content/views/event.py @@ -29,6 +29,7 @@ PublicRegistrationSerializer, ) from app.group.models.group import Group +from app.payment.models.paid_event import PaidEvent from app.util.utils import midday, now, yesterday @@ -87,10 +88,15 @@ def retrieve(self, request, pk): def update(self, request, pk): """Update the event with the specified pk.""" try: + data = request.data + + if not data["is_paid_event"]: + data["paid_information"] = {} + event = self.get_object() self.check_object_permissions(self.request, event) serializer = EventCreateAndUpdateSerializer( - event, data=request.data, partial=True, context={"request": request} + event, data=data, partial=True, context={"request": request} ) if serializer.is_valid(): @@ -110,8 +116,9 @@ def update(self, request, pk): ) def create(self, request, *args, **kwargs): + data = request.data serializer = EventCreateAndUpdateSerializer( - data=request.data, context={"request": request} + data=data, context={"request": request} ) if serializer.is_valid(): @@ -124,6 +131,11 @@ def create(self, request, *args, **kwargs): ) def destroy(self, request, *args, **kwargs): + event = Event.objects.get(pk=kwargs["pk"]) + if event.is_paid_event: + paid_event = PaidEvent.objects.get(event=kwargs["pk"]) + paid_event.delete() + super().destroy(request, *args, **kwargs) return Response( {"detail": ("Arrangementet ble slettet")}, status=status.HTTP_200_OK diff --git a/app/content/views/registration.py b/app/content/views/registration.py index bbf0ed507..36ccef0ee 100644 --- a/app/content/views/registration.py +++ b/app/content/views/registration.py @@ -11,6 +11,9 @@ from app.content.mixins import APIRegistrationErrorsMixin from app.content.models import Event, Registration from app.content.serializers import RegistrationSerializer +from app.content.util.event_utils import create_payment_order +from app.payment.models.order import Order +from app.payment.views.vipps_callback import vipps_callback class RegistrationViewSet(APIRegistrationErrorsMixin, BaseViewSet): @@ -25,6 +28,9 @@ class RegistrationViewSet(APIRegistrationErrorsMixin, BaseViewSet): def get_queryset(self): event_id = self.kwargs.get("event_id", None) + order = Order.objects.filter(event=event_id).first() + if order: + vipps_callback(None, order.order_id) return Registration.objects.filter(event__pk=event_id).select_related("user") def _is_own_registration(self): @@ -58,9 +64,13 @@ def create(self, request, *args, **kwargs): registration = super().perform_create( serializer, event=event, user=request.user ) + + create_payment_order(event, request, registration) + registration_serializer = RegistrationSerializer( registration, context={"user": registration.user} ) + return Response(registration_serializer.data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): diff --git a/app/payment/__init__.py b/app/payment/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/payment/admin.py b/app/payment/admin.py new file mode 100644 index 000000000..7e8d356e8 --- /dev/null +++ b/app/payment/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from app.payment.models.order import Order +from app.payment.models.paid_event import PaidEvent + +# Register your models here. +admin.site.register(PaidEvent) +admin.site.register(Order) diff --git a/app/payment/apps.py b/app/payment/apps.py new file mode 100644 index 000000000..c1d2f3509 --- /dev/null +++ b/app/payment/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PaymentConfig(AppConfig): + name = "app.payment" diff --git a/app/payment/enums.py b/app/payment/enums.py new file mode 100644 index 000000000..21cf044db --- /dev/null +++ b/app/payment/enums.py @@ -0,0 +1,11 @@ +from django.db.models import TextChoices + + +class OrderStatus(TextChoices): + INITIATE = "INITIATE" + RESERVE = "RESERVE" + CAPTURE = "CAPTURE" + REFUND = "REFUND" + CANCEL = "CANCEL" + SALE = "SALE" + VOID = "VOID" diff --git a/app/payment/exceptions.py b/app/payment/exceptions.py new file mode 100644 index 000000000..bb486282e --- /dev/null +++ b/app/payment/exceptions.py @@ -0,0 +1,14 @@ +from rest_framework import status +from rest_framework.exceptions import APIException + + +class VippsCallbackInternalServerException(APIException): + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + default_detail = "Det skjedde en feil med oppdatering av Vipps betalingsstatus fra Vipps callback." + + +class VippsForcePaymentException(APIException): + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + default_detail = ( + "Det skjedde en feil med tvungen oppdatering av Vipps betalingsstatus." + ) diff --git a/app/payment/factories/__init__.py b/app/payment/factories/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/payment/factories/order_factory.py b/app/payment/factories/order_factory.py new file mode 100644 index 000000000..e48a65249 --- /dev/null +++ b/app/payment/factories/order_factory.py @@ -0,0 +1,21 @@ +import random +from datetime import timedelta + +import factory +from factory.django import DjangoModelFactory + +from app.content.factories.event_factory import EventFactory +from app.content.factories.user_factory import UserFactory +from app.payment.enums import OrderStatus +from app.payment.models.order import Order +from app.util.utils import now + + +class OrderFactory(DjangoModelFactory): + class Meta: + model = Order + + user = factory.SubFactory(UserFactory) + event = factory.SubFactory(EventFactory) + status = random.choice([e.value for e in OrderStatus]) + expire_date = now() + timedelta(hours=1) diff --git a/app/payment/factories/paid_event_factory.py b/app/payment/factories/paid_event_factory.py new file mode 100644 index 000000000..bb51ecd79 --- /dev/null +++ b/app/payment/factories/paid_event_factory.py @@ -0,0 +1,17 @@ +import random +from datetime import datetime, timedelta + +import factory +from factory.django import DjangoModelFactory + +from app.content.factories.event_factory import EventFactory +from app.payment.models.paid_event import PaidEvent + + +class PaidEventFactory(DjangoModelFactory): + class Meta: + model = PaidEvent + + price = random.randint(0, 1000) + event = factory.SubFactory(EventFactory) + paytime = datetime.now() + timedelta(hours=0, minutes=1) diff --git a/app/payment/filters/__init__.py b/app/payment/filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/payment/migrations/0001_initial.py b/app/payment/migrations/0001_initial.py new file mode 100644 index 000000000..94f9ebf49 --- /dev/null +++ b/app/payment/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 4.0.8 on 2023-09-11 19:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('content', '0053_event_contact_person'), + ] + + operations = [ + migrations.CreateModel( + name='PaidEvent', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('event', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='paid_information', serialize=False, to='content.event')), + ('price', models.DecimalField(decimal_places=2, max_digits=6)), + ('paytime', models.TimeField()), + ], + options={ + 'verbose_name_plural': 'Paid_events', + }, + ), + migrations.CreateModel( + name='Order', + fields=[ + ('order_id', models.UUIDField(auto_created=True, default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('status', models.CharField(choices=[('INITIATE', 'Initiate'), ('RESERVE', 'Reserve'), ('CAPTURE', 'Capture'), ('REFUND', 'Refund'), ('CANCEL', 'Cancel'), ('SALE', 'Sale'), ('VOID', 'Void')], default='INITIATE', max_length=16)), + ('expire_date', models.DateTimeField()), + ('payment_link', models.URLField(max_length=2000)), + ('event', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='content.event')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'Orders', + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/app/payment/migrations/__init__.py b/app/payment/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/payment/models/__init__.py b/app/payment/models/__init__.py new file mode 100644 index 000000000..cdf6303f6 --- /dev/null +++ b/app/payment/models/__init__.py @@ -0,0 +1,2 @@ +from .order import Order +from .paid_event import PaidEvent diff --git a/app/payment/models/order.py b/app/payment/models/order.py new file mode 100644 index 000000000..31e9381d2 --- /dev/null +++ b/app/payment/models/order.py @@ -0,0 +1,40 @@ +import uuid + +from django.db import models + +from app.common.enums import AdminGroup +from app.common.permissions import BasePermissionModel +from app.content.models.event import Event +from app.content.models.user import User +from app.payment.enums import OrderStatus +from app.util.models import BaseModel +from app.util.utils import now + + +class Order(BaseModel, BasePermissionModel): + access = AdminGroup.admin() + order_id = models.UUIDField( + auto_created=True, default=uuid.uuid4, primary_key=True, serialize=False + ) + user = models.ForeignKey( + User, null=True, on_delete=models.SET_NULL, related_name="orders" + ) + event = models.ForeignKey( + Event, null=True, on_delete=models.SET_NULL, related_name="orders" + ) + status = models.CharField( + choices=OrderStatus.choices, default=OrderStatus.INITIATE, max_length=16 + ) + expire_date = models.DateTimeField() + payment_link = models.URLField(max_length=2000) + + class Meta: + verbose_name_plural = "Orders" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.order_id} {self.user} {self.event} {self.status} {self.expire_date}" + + @property + def expired(self): + return now() >= self.expire_date diff --git a/app/payment/models/paid_event.py b/app/payment/models/paid_event.py new file mode 100644 index 000000000..c77577a73 --- /dev/null +++ b/app/payment/models/paid_event.py @@ -0,0 +1,24 @@ +from django.db import models + +from app.common.enums import AdminGroup +from app.content.models.event import Event +from app.util.models import BaseModel + + +class PaidEvent(BaseModel): + write_access = AdminGroup.admin() + + event = models.OneToOneField( + Event, + on_delete=models.CASCADE, + related_name="paid_information", + primary_key=True, + ) + price = models.DecimalField(max_digits=6, decimal_places=2) + paytime = models.TimeField() + + class Meta: + verbose_name_plural = "Paid_events" + + def __str__(self): + return f"Price: {self.price}" diff --git a/app/payment/serializers/__init__.py b/app/payment/serializers/__init__.py new file mode 100644 index 000000000..8789a8df3 --- /dev/null +++ b/app/payment/serializers/__init__.py @@ -0,0 +1 @@ +from .order import OrderSerializer diff --git a/app/payment/serializers/order.py b/app/payment/serializers/order.py new file mode 100644 index 000000000..51fd13a05 --- /dev/null +++ b/app/payment/serializers/order.py @@ -0,0 +1,31 @@ +from app.common.serializers import BaseModelSerializer +from app.content.models.event import Event +from app.content.models.user import User +from app.content.serializers.user import DefaultUserSerializer +from app.payment.models.order import Order +from app.util.utils import now + + +class OrderSerializer(BaseModelSerializer): + class Meta: + model = Order + fields = ("order_id", "status", "expire_date", "payment_link", "event", "user") + + +class OrderUpdateCreateSerializer(BaseModelSerializer): + user = DefaultUserSerializer(read_only=True) + + class Meta: + model = Order + fields = ("order_id", "user", "status", "expire_date") + + read_only_fields = "user" + + def create(self, validated_data): + user = User.objects.get(user_id=self.context["user_id"]) + paytime = Event.objects.get( + id=validated_data.get("event") + ).paid_information.paytime + return Order.objects.create( + user=user, expired_date=now() + paytime, **validated_data + ) diff --git a/app/payment/serializers/paid_event.py b/app/payment/serializers/paid_event.py new file mode 100644 index 000000000..6b0fa1d59 --- /dev/null +++ b/app/payment/serializers/paid_event.py @@ -0,0 +1,15 @@ +from app.common.serializers import BaseModelSerializer +from app.payment.models.paid_event import PaidEvent + + +class SimplePaidEventSerializer(BaseModelSerializer): + class Meta: + model = PaidEvent + fields = ("price", "paytime") + + +class PaidEventCreateSerializer(BaseModelSerializer): + class Meta: + model = PaidEvent + fields = ("price", "paytime") + extra_kwargs = {"price": {"required": False}, "paytime": {"required": False}} diff --git a/app/payment/tasks.py b/app/payment/tasks.py new file mode 100644 index 000000000..671ebf701 --- /dev/null +++ b/app/payment/tasks.py @@ -0,0 +1,25 @@ +from sentry_sdk import capture_exception + +from app.celery import app +from app.content.models.registration import Registration +from app.payment.enums import OrderStatus +from app.payment.models.order import Order +from app.payment.views.vipps_callback import vipps_callback +from app.util.tasks import BaseTask + + +@app.task(bind=True, base=BaseTask) +def check_if_has_paid(self, order_id, registration_id): + try: + vipps_callback(None, order_id) + order = Order.objects.get(order_id=order_id) + order_status = order.status + if ( + order_status != OrderStatus.CAPTURE + and order_status != OrderStatus.RESERVE + and order_status != OrderStatus.SALE + ): + Registration.objects.filter(registration_id=registration_id).delete() + + except Order.DoesNotExist as order_not_exist: + capture_exception(order_not_exist) diff --git a/app/payment/tests.py b/app/payment/tests.py new file mode 100644 index 000000000..a79ca8be5 --- /dev/null +++ b/app/payment/tests.py @@ -0,0 +1,3 @@ +# from django.test import TestCase + +# Create your tests here. diff --git a/app/payment/tests/__init__.py b/app/payment/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/payment/tests/test_order_model.py b/app/payment/tests/test_order_model.py new file mode 100644 index 000000000..12c4d6b83 --- /dev/null +++ b/app/payment/tests/test_order_model.py @@ -0,0 +1,27 @@ +from datetime import timedelta + +import pytest + +from app.payment.factories.order_factory import OrderFactory +from app.util.utils import now + + +@pytest.fixture() +def order(): + return OrderFactory() + + +@pytest.mark.django_db +def test_expired_when_order_has_not_expired(order): + """Should return False if the order has not expired""" + order.expire_date = now() + timedelta(hours=1) + + assert not order.expired + + +@pytest.mark.django_db +def test_expired_when_order_has_expired(order): + """Should return True if the order has expired""" + order.expire_date = now() - timedelta(hours=1) + + assert order.expired diff --git a/app/payment/urls.py b/app/payment/urls.py new file mode 100644 index 000000000..c7e2854a6 --- /dev/null +++ b/app/payment/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path, re_path +from rest_framework import routers + +from app.payment.views.order import OrderViewSet +from app.payment.views.vipps_callback import vipps_callback + +router = routers.DefaultRouter() + +router.register("payment", OrderViewSet, basename="payment") + +urlpatterns = [ + re_path(r"", include(router.urls)), + path("v2/payment/", vipps_callback), +] diff --git a/app/payment/util/payment_utils.py b/app/payment/util/payment_utils.py new file mode 100644 index 000000000..03b82ffc2 --- /dev/null +++ b/app/payment/util/payment_utils.py @@ -0,0 +1,57 @@ +import json + +from django.conf import settings + +import requests + + +def get_new_access_token(): + """ + Get new access token from Vipps for dealing with payments from Vipps. + """ + TOKEN_URL = settings.VIPPS_TOKEN_URL + TOKEN_HEADERS = { + "client_id": settings.VIPPS_CLIENT_ID, + "client_secret": settings.VIPPS_CLIENT_SECRET, + "Ocp-Apim-Subscription-Key": settings.VIPPS_SUBSCRIPTION_KEY, + "Merchant-Serial-Number": settings.VIPPS_MERCHANT_SERIAL_NUMBER, + } + + response = requests.post(TOKEN_URL, headers=TOKEN_HEADERS).json() + return (response["expires_on"], response["access_token"]) + + +def initiate_payment(amount, order_id, event_name, access_token): + """ + Initiate a payment with Vipps + amount: Amount to pay in Øre (100 NOK = 10000) + """ + url = settings.VIPPS_ORDER_URL + payload = json.dumps( + { + "merchantInfo": { + "callbackPrefix": settings.VIPPS_CALLBACK_PREFIX, + "fallBack": settings.VIPPS_FALLBACK, + "merchantSerialNumber": settings.VIPPS_MERCHANT_SERIAL_NUMBER, + }, + "transaction": { + "amount": amount, + "transactionText": "This payment is for the event:" + event_name, + "orderId": order_id, + "skipLandingPage": False, + }, + } + ) + headers = { + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": settings.VIPPS_SUBSCRIPTION_KEY, + "Authorization": "Bearer " + access_token, + "Merchant-Serial-Number": settings.VIPPS_MERCHANT_SERIAL_NUMBER, + "Cookie": settings.VIPPS_COOKIE, + } + response = requests.post(url, headers=headers, data=payload) + + if response.status_code != 200: + raise Exception("Could not initiate payment") + + return response.json() diff --git a/app/payment/views/__init__.py b/app/payment/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/payment/views/order.py b/app/payment/views/order.py new file mode 100644 index 000000000..49ddb2859 --- /dev/null +++ b/app/payment/views/order.py @@ -0,0 +1,30 @@ +from rest_framework import status +from rest_framework.response import Response + +from sentry_sdk import capture_exception + +from app.common.mixins import ActionMixin +from app.common.viewsets import BaseViewSet +from app.payment.models import Order +from app.payment.serializers import OrderSerializer + + +class OrderViewSet(BaseViewSet, ActionMixin): + serializer_class = OrderSerializer + queryset = Order.objects.all() + + def retrieve(self, request, pk): + try: + user = request.query_params.get("user_id") + event = request.query_params.get("event") + order = Order.objects.filter(user=user, event=event)[0] + serializer = OrderSerializer( + order, context={"request": request}, many=False + ) + return Response(serializer.data, status.HTTP_200_OK) + except Order.DoesNotExist as order_not_exist: + capture_exception(order_not_exist) + return Response( + {"detail": "Fant ikke beatlingsordre."}, + status=status.HTTP_404_NOT_FOUND, + ) diff --git a/app/payment/views/vipps_callback.py b/app/payment/views/vipps_callback.py new file mode 100644 index 000000000..bdfc45ff4 --- /dev/null +++ b/app/payment/views/vipps_callback.py @@ -0,0 +1,50 @@ +from django.conf import settings + +import requests + +from app.payment.exceptions import ( + VippsCallbackInternalServerException, + VippsForcePaymentException, +) +from app.payment.models.order import Order +from app.payment.util.payment_utils import get_new_access_token + + +def vipps_callback(_request, order_id): + try: + access_token = get_new_access_token()[1] + url = f"{settings.VIPPS_ORDER_URL}{order_id}/details" + headers = { + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": settings.VIPPS_SUBSCRIPTION_KEY, + "Authorization": "Bearer " + access_token, + "Merchant-Serial-Number": settings.VIPPS_MERCHANT_SERIAL_NUMBER, + } + res = requests.get(url, headers=headers) + json = res.json() + status = json["transactionLogHistory"][0]["operation"] + order = Order.objects.get(order_id=order_id) + order.status = status + order.save() + return status + except Exception: + raise VippsCallbackInternalServerException() + + +def force_payment(order_id): + try: + access_token = get_new_access_token()[1] + url = f"{settings.VIPPS_FORCE_PAYMENT_URL}{order_id}/approve" + headers = { + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": settings.VIPPS_SUBSCRIPTION_KEY, + "Authorization": "Bearer " + access_token, + "Merchant-Serial-Number": settings.VIPPS_MERCHANT_SERIAL_NUMBER, + } + + res = requests.post(url, headers=headers) + status_code = res.status_code + json = res.json() + return (json, status_code) + except Exception: + raise VippsForcePaymentException() diff --git a/app/settings.py b/app/settings.py index b3d99f4fd..409e87772 100644 --- a/app/settings.py +++ b/app/settings.py @@ -97,6 +97,7 @@ "app.forms", "app.gallery", "app.badge", + "app.payment", ] # Django rest framework @@ -234,6 +235,18 @@ EMAIL_HOST_USER = os.environ.get("EMAIL_USER") or "75ecff025dcb39" EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_PASSWORD") or "8b1a00e838d6b7" +# Vipps +VIPPS_CLIENT_ID = os.environ.get("VIPPS_CLIENT_ID") +VIPPS_CLIENT_SECRET = os.environ.get("VIPPS_CLIENT_SECRET") +VIPPS_SUBSCRIPTION_KEY = os.environ.get("VIPPS_SUBSCRIPTION_KEY") +VIPPS_MERCHANT_SERIAL_NUMBER = os.environ.get("VIPPS_MERCHANT_SERIAL_NUMBER") +VIPPS_CALLBACK_PREFIX = os.environ.get("VIPPS_CALLBACK_PREFIX") +VIPPS_FALLBACK = os.environ.get("VIPPS_FALLBACK") +VIPPS_TOKEN_URL = os.environ.get("VIPPS_TOKEN_URL") +VIPPS_ORDER_URL = os.environ.get("VIPPS_ORDER_URL") +VIPPS_FORCE_PAYMENT_URL = os.environ.get("VIPPS_FORCE_PAYMENT_URL") +VIPPS_COOKIE = os.environ.get("VIPPS_COOKIE") + LOGGING = { "version": 1, "disable_existing_loggers": False, @@ -271,4 +284,6 @@ CELERY_BROKER_URL = "amqp://guest:guest@rabbitmq:5672" if ENVIRONMENT == EnvironmentOptions.LOCAL: - CELERY_TASK_ALWAYS_EAGER = True + CELERY_TASK_ALWAYS_EAGER = False + + \ No newline at end of file diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 651902a24..dea934478 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -26,6 +26,7 @@ from app.group.factories import GroupFactory, MembershipFactory from app.group.factories.fine_factory import FineFactory from app.group.factories.membership_factory import MembershipHistoryFactory +from app.payment.factories.paid_event_factory import PaidEventFactory from app.util.test_utils import add_user_to_group_with_name, get_api_client @@ -96,6 +97,11 @@ def event(): return EventFactory() +@pytest.fixture() +def paid_event(): + return PaidEventFactory() + + @pytest.fixture() def group(): return GroupFactory() diff --git a/app/tests/content/test_event_integration.py b/app/tests/content/test_event_integration.py index 991f23460..49b51646d 100644 --- a/app/tests/content/test_event_integration.py +++ b/app/tests/content/test_event_integration.py @@ -26,7 +26,9 @@ def get_events_url_detail(event=None): return f"{API_EVENTS_BASE_URL}{event.pk}/" -def get_event_data(title="New Title", location="New Location", organizer=None): +def get_event_data( + title="New Title", location="New Location", organizer=None, contact_person=None +): start_date = timezone.now() + timedelta(days=10) end_date = timezone.now() + timedelta(days=11) data = { @@ -34,9 +36,12 @@ def get_event_data(title="New Title", location="New Location", organizer=None): "location": location, "start_date": start_date, "end_date": end_date, + "is_paid_event": False, } if organizer: data["organizer"] = organizer + if contact_person: + data["contact_person"] = contact_person return data @@ -281,6 +286,38 @@ def test_create_event_as_admin(permission_test_util): ) +@pytest.mark.django_db +@permission_params +def test_create_event_as_admin_with_contact_person(permission_test_util): + """ + HS and Index members should be able to create events no matter which organizer is selected. + They should also be able to create events no matter which contact person is selected. + Other subgroup members can create events where event.organizer is their group or None. + Leaders of committees and interest groups should be able to + update events where event.organizer is their group or None. + """ + + ( + user, + _, + new_organizer, + _, + expected_status_code, + _, + _, + ) = permission_test_util + + client = get_api_client(user=user) + data = get_event_data(organizer=new_organizer, contact_person=user.user_id) + response = client.post(API_EVENTS_BASE_URL, data) + + assert ( + response.status_code == 201 + if expected_status_code == 200 + else expected_status_code + ) + + @pytest.mark.django_db def test_create_event_with_group_priorities_returns_http_201(api_client, admin_user): client = api_client(user=admin_user) diff --git a/app/tests/content/test_news_integration.py b/app/tests/content/test_news_integration.py index 26636dc64..f55a1b412 100644 --- a/app/tests/content/test_news_integration.py +++ b/app/tests/content/test_news_integration.py @@ -2,7 +2,7 @@ import pytest -from app.common.enums import AdminGroup +from app.common.enums import AdminGroup, Groups from app.content.factories.news_factory import NewsFactory from app.content.factories.user_factory import UserFactory from app.util.test_utils import get_api_client @@ -225,6 +225,7 @@ def test_create_as_member(member, news_post_data): (AdminGroup.NOK, status.HTTP_201_CREATED), (AdminGroup.SOSIALEN, status.HTTP_201_CREATED), (AdminGroup.PROMO, status.HTTP_201_CREATED), + (Groups.FONDET, status.HTTP_201_CREATED), ], ) def test_create_as_member_of_admin_group( diff --git a/app/tests/content/test_registration_integration.py b/app/tests/content/test_registration_integration.py index e230874f9..79f89a4af 100644 --- a/app/tests/content/test_registration_integration.py +++ b/app/tests/content/test_registration_integration.py @@ -617,6 +617,34 @@ def test_delete_own_registration_as_member(member): assert response.status_code == status.HTTP_200_OK +# @pytest.mark.django_db +# def test_delete_own_registration_on_paid_event_as_member(member, paid_event): +# """A member should only be able to delete their own registration on a paid event.""" +# event = paid_event.event +# client = get_api_client(user=member) +# data = _get_registration_post_data(user=member, event=event) +# post_url = _get_registration_url(event=event) +# post_response = client.post(post_url, data=data) + +# assert post_response.status_code == 201 + +# print(post_response.data) +# registration = Registration.objects.filter(event=event, user=member) +# registrations = Registration.objects.all() +# print(registrations) +# print(registration) +# order = Order.objects.filter(event=event, user=member)[0] + +# assert order.status == OrderStatus.INITIATE + +# url = _get_registration_detail_url(registration) +# response = client.delete(url) +# order = Order.objects.filter(event=event, user=member)[0] + +# assert response.status_code == status.HTTP_200_OK +# assert order.status == OrderStatus.CANCEL + + @pytest.mark.django_db def test_delete_another_registration_as_member(member, user): """A member should not be able to delete another registration.""" diff --git a/app/tests/payment/__init__.py b/app/tests/payment/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/tests/payment/test_order_integration.py b/app/tests/payment/test_order_integration.py new file mode 100644 index 000000000..55d2413fc --- /dev/null +++ b/app/tests/payment/test_order_integration.py @@ -0,0 +1,21 @@ +API_EVENT_BASE_URL = "/events/" +API_PAYMENT_BASE_URL = "/payment/" + + +def _get_registration_url(event): + return f"{API_EVENT_BASE_URL}{event.pk}/registrations/" + + +def _get_order_url(): + return f"{API_PAYMENT_BASE_URL}order/" + + +def _get_registration_post_data(user, event): + return { + "user_id": user.user_id, + "event": event.pk, + } + + +def _get_order_data(user, event): + return {"user_id": user.user_id, "event": event.pk} diff --git a/app/tests/payment/test_paid_event_integration.py b/app/tests/payment/test_paid_event_integration.py new file mode 100644 index 000000000..d33370828 --- /dev/null +++ b/app/tests/payment/test_paid_event_integration.py @@ -0,0 +1,154 @@ +from datetime import timedelta + +from django.utils import timezone + +import pytest + +from app.common.enums import GroupType +from app.content.models.event import Event +from app.group.models.group import Group +from app.payment.factories.paid_event_factory import PaidEventFactory +from app.payment.models.paid_event import PaidEvent +from app.util.test_utils import get_api_client + +API_EVENTS_BASE_URL = "/events/" + + +def _get_registration_url(event): + return f"{API_EVENTS_BASE_URL}{event.pk}/registrations/" + + +def get_events_url_detail(event=None): + return f"{API_EVENTS_BASE_URL}{event.pk}/" + + +def get_paid_event_data( + title="New Title", + location="New Location", + organizer=None, + price=100.00, + paytime="01:00:00", +): + start_date = timezone.now() + timedelta(days=10) + end_date = timezone.now() + timedelta(days=11) + data = { + "title": title, + "location": location, + "start_date": start_date, + "end_date": end_date, + "is_paid_event": True, + "paid_information": {"price": price, "paytime": paytime}, + } + if organizer: + data["organizer"] = organizer + return data + + +def _get_registration_post_data(user, event): + return { + "user_id": user.user_id, + "event": event.pk, + } + + +def get_paid_event_without_price_data( + title="New Title", location="New Location", organizer=None +): + start_date = timezone.now() + timedelta(days=10) + end_date = timezone.now() + timedelta(days=11) + data = { + "title": title, + "location": location, + "start_date": start_date, + "end_date": end_date, + "is_paid_event": True, + } + if organizer: + data["organizer"] = organizer + return data + + +@pytest.mark.django_db +def test_create_paid_event_as_admin(admin_user): + """ + HS and Index members should be able to create paid events. + """ + + organizer = Group.objects.get_or_create(name="HS", type=GroupType.BOARD)[0] + client = get_api_client(user=admin_user) + data = get_paid_event_data(organizer=organizer.slug) + + response = client.post(API_EVENTS_BASE_URL, data) + created_event = Event.objects.get(title=data["title"]) + paid_event_information = PaidEvent.objects.get(event=created_event) + + assert response.status_code == 201 + assert created_event.is_paid_event + assert paid_event_information.price == data["paid_information"]["price"] + assert ( + float(response.data["paid_information"]["price"]) + == data["paid_information"]["price"] + ) + + +@pytest.mark.django_db +def test_create_paid_event_without_price_as_admin(admin_user): + """ + HS and Index members should not be able to create a paid event wihtout a price and paytime. + Then there should be created a normal event. + """ + + organizer = Group.objects.get_or_create(name="HS", type=GroupType.BOARD)[0] + client = get_api_client(user=admin_user) + data = get_paid_event_without_price_data(organizer=organizer.slug) + + response = client.post(API_EVENTS_BASE_URL, data) + created_event = Event.objects.get(title=data["title"]) + + assert response.status_code == 201 + assert not created_event.is_paid_event + + +@pytest.mark.django_db +def test_update_paid_event_as_admin(admin_user): + """ + HS and Index members should be able to update all paid events. + Other subgroup members can update paid events where event.organizer is their group or None. + Leaders of committees and interest groups should be able to + update events where event.organizer is their group or None. + """ + + new_event_price = 100.00 + paid_event = PaidEventFactory(price=0.00) + event = paid_event.event + organizer = Group.objects.get_or_create(name="HS", type=GroupType.BOARD)[0] + client = get_api_client(user=admin_user) + url = get_events_url_detail(event) + data = get_paid_event_data(organizer=organizer.slug) + + response = client.put(url, data) + event.refresh_from_db() + + assert response.status_code == 200 + assert event.paid_information.price == new_event_price + assert float(response.data["paid_information"]["price"]) == new_event_price + + +# @pytest.mark.django_db +# def test_delete_paid_event_as_admin(admin_user, paid_event): +# client = get_api_client(user=admin_user) +# event = paid_event.event + +# url = _get_registration_url(event=event) +# data = _get_registration_post_data(admin_user, event) +# response = client.post(url, data=data) +# assert response.status_code == 201 + +# url = get_events_url_detail(event) +# event_response = client.delete(url) +# paid_events = PaidEvent.objects.all() +# orders = Order.objects.all() + +# assert event_response.status_code == 200 +# assert len(paid_events) == 0 +# assert len(orders) == 0 diff --git a/app/tests/payment/test_vipps_callback.py b/app/tests/payment/test_vipps_callback.py new file mode 100644 index 000000000..5b9f4e544 --- /dev/null +++ b/app/tests/payment/test_vipps_callback.py @@ -0,0 +1,49 @@ +API_EVENT_BASE_URL = "/events/" + + +def _get_registration_url(event): + return f"{API_EVENT_BASE_URL}{event.pk}/registrations/" + + +def _get_registration_post_data(user, event): + return { + "user_id": user.user_id, + "event": event.pk, + } + + +# these tests can not be tested because of celery + +# @pytest.mark.django_db +# def test_if_order_gets_updated_by_vipps_callback(member, paid_event): +# """A member should be able to create a registration for themselves.""" +# data = _get_registration_post_data(member, paid_event) +# client = get_api_client(user=member) + +# url = _get_registration_url(event=paid_event.event) +# response = client.post(url, data=data) + +# assert response.status_code == status.HTTP_201_CREATED + +# order = Order.objects.all()[0] +# order_id = order.order_id +# order_status = order.status +# new_status = vipps_callback({"orderId": order_id}) + +# assert order_status == new_status + +# @pytest.mark.django_db +# def test_force_vipps_payment(member, paid_event): +# """A member should be able to create a registration for themselves.""" +# data = _get_registration_post_data(member, paid_event) +# client = get_api_client(user=member) + +# url = _get_registration_url(event=paid_event.event) +# response = client.post(url, data=data) + +# assert response.status_code == status.HTTP_201_CREATED + +# order = Order.objects.all()[0] +# json, status_code = force_payment(order.order_id) +# print(status_code) +# print(json) diff --git a/app/urls.py b/app/urls.py index e5237b1e2..2b540d666 100644 --- a/app/urls.py +++ b/app/urls.py @@ -25,6 +25,7 @@ path("", include("app.communication.urls")), path("", include("app.content.urls")), path("", include("app.group.urls")), + path("", include("app.payment.urls")), path("auth/", include("app.authentication.urls")), path("badges/", include("app.badge.urls")), path("forms/", include("app.forms.urls")), diff --git a/app/util/exceptions.py b/app/util/exceptions.py index 23dad7d59..57d8d2ca4 100644 --- a/app/util/exceptions.py +++ b/app/util/exceptions.py @@ -1,4 +1,5 @@ import logging +import traceback from django.conf import settings from django.db.utils import IntegrityError @@ -21,7 +22,7 @@ def exception_handler(exc, context): if response: log_api_error(response, exc) else: - logger.error(f"Unhandled request exception: {exc}") + logger.error(f"Unhandled request exception: {traceback(exc)}") if not settings.DEBUG and not response: response = Response( diff --git a/docker-compose.yml b/docker-compose.yml index a61f3ab7f..00934d4c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ services: db: image: mysql:5.7 container_name: db + platform: linux/amd64 restart: always ports: - "3306:3306"