diff --git a/CHANGELOG.md b/CHANGELOG.md index 60d7968d6..eab0290e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,13 @@ --- ## Neste versjon + - ✨ **Spørreskjemaer** NOK medlemmer kan lage spørreskjema. -- ⚡ **Bruker** Nå kan ikke HS lenger endre eller slette brukere. +- ⚡ **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/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/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 01b8a2795..b18230a8e 100644 --- a/app/content/serializers/event.py +++ b/app/content/serializers/event.py @@ -60,6 +60,7 @@ class Meta: "permissions", "priority_pools", "paid_information", + "is_paid_event", "contact_person", ) diff --git a/app/content/views/registration.py b/app/content/views/registration.py index 36ccef0ee..d899e2031 100644 --- a/app/content/views/registration.py +++ b/app/content/views/registration.py @@ -65,7 +65,11 @@ def create(self, request, *args, **kwargs): serializer, event=event, user=request.user ) - create_payment_order(event, request, registration) + try: + create_payment_order(event, request, registration) + except Exception as e: + registration.delete() + raise e registration_serializer = RegistrationSerializer( registration, context={"user": registration.user} diff --git a/app/content/views/user.py b/app/content/views/user.py index 727c2197b..c37403e8c 100644 --- a/app/content/views/user.py +++ b/app/content/views/user.py @@ -37,9 +37,10 @@ MembershipHistorySerializer, MembershipSerializer, ) +from app.payment.views import OrderListSerializer from app.util.export_user_data import export_user_data from app.util.utils import CaseInsensitiveBooleanQueryParam - +from app.payment.util.order_utils import filter_user_event_orders class UserViewSet(BaseViewSet, ActionMixin): """API endpoint to display one user""" @@ -285,6 +286,18 @@ def get_user_events(self, request, *args, **kwargs): data=events, serializer=EventListSerializer, context={"request": request} ) + @action(detail=False, methods=["get"], url_path="me/payment_orders") + def get_user_payment_orders(self, request, *args, **kwargs): + payment_orders = request.user.orders.all() + + filtered_orders = filter_user_event_orders(payment_orders) + + return self.paginate_response( + data=filtered_orders, + serializer=OrderListSerializer, + context={"request": request}, + ) + @action(detail=False, methods=["get"], url_path="me/forms") def get_user_forms(self, request, *args, **kwargs): forms = request.user.forms diff --git a/app/group/admin.py b/app/group/admin.py index c9242acf4..44eda2aa4 100644 --- a/app/group/admin.py +++ b/app/group/admin.py @@ -24,6 +24,11 @@ class MembershipAdmin(admin.ModelAdmin): "user", ) + search_fields = [ + "user__first_name", + "user__last_name" + ] + @admin.register(models.MembershipHistory) class MembershipHistoryAdmin(admin.ModelAdmin): diff --git a/app/payment/factories/__init__.py b/app/payment/factories/__init__.py index e69de29bb..055d4ffe4 100644 --- a/app/payment/factories/__init__.py +++ b/app/payment/factories/__init__.py @@ -0,0 +1,2 @@ +from app.payment.factories.order_factory import OrderFactory +from app.payment.factories.paid_event_factory import PaidEventFactory diff --git a/app/payment/factories/order_factory.py b/app/payment/factories/order_factory.py index e48a65249..3a1036d71 100644 --- a/app/payment/factories/order_factory.py +++ b/app/payment/factories/order_factory.py @@ -1,4 +1,5 @@ import random +import string from datetime import timedelta import factory @@ -19,3 +20,4 @@ class Meta: event = factory.SubFactory(EventFactory) status = random.choice([e.value for e in OrderStatus]) expire_date = now() + timedelta(hours=1) + payment_link = "".join(random.choices(string.ascii_letters, k=36)) diff --git a/app/payment/filters/__init__.py b/app/payment/filters/__init__.py index e69de29bb..b599e0f8a 100644 --- a/app/payment/filters/__init__.py +++ b/app/payment/filters/__init__.py @@ -0,0 +1 @@ +from app.payment.filters.order import OrderFilter diff --git a/app/payment/filters/order.py b/app/payment/filters/order.py new file mode 100644 index 000000000..aaed69fe7 --- /dev/null +++ b/app/payment/filters/order.py @@ -0,0 +1,26 @@ +from django.db.models import Exists, OuterRef +from django_filters import filters +from django_filters.rest_framework.filterset import FilterSet + +from app.payment.models import Order, PaidEvent + + +class OrderFilter(FilterSet): + """ + Filters payment orders by user, paid events and status. + """ + + user = filters.CharFilter(method="filter_user") + event = filters.NumberFilter(method="filter_paid_event") + + class Meta: + model = Order + fields = ["user", "event", "status"] + + def filter_user(self, queryset, name, value): + if value and self.request.user: + return queryset.filter(user__user_id=self.request.user.user_id) + return queryset + + def filter_paid_event(self, queryset, name, value): + return queryset.filter(Exists(PaidEvent.objects.filter(event=value))) diff --git a/app/payment/migrations/0001_initial.py b/app/payment/migrations/0001_initial.py index 61fc25d8e..94f9ebf49 100644 --- a/app/payment/migrations/0001_initial.py +++ b/app/payment/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.0.8 on 2023-05-08 17:11 +# Generated by Django 4.0.8 on 2023-09-11 19:01 from django.conf import settings from django.db import migrations, models @@ -11,8 +11,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('content', '0052_event_rules_and_photo_in_user'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('content', '0053_event_contact_person'), ] operations = [ @@ -21,7 +21,7 @@ class Migration(migrations.Migration): 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.PROTECT, primary_key=True, related_name='paid_information', serialize=False, to='content.event')), + ('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()), ], @@ -38,8 +38,8 @@ class Migration(migrations.Migration): ('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(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='content.event')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to=settings.AUTH_USER_MODEL)), + ('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', diff --git a/app/payment/migrations/0002_alter_paidevent_event.py b/app/payment/migrations/0002_alter_paidevent_event.py deleted file mode 100644 index 129baadaf..000000000 --- a/app/payment/migrations/0002_alter_paidevent_event.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.0.8 on 2023-07-18 10:50 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('content', '0052_event_rules_and_photo_in_user'), - ('payment', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='paidevent', - name='event', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='paid_information', serialize=False, to='content.event'), - ), - ] diff --git a/app/payment/migrations/0003_alter_order_event_alter_order_user.py b/app/payment/migrations/0003_alter_order_event_alter_order_user.py deleted file mode 100644 index 143ab81ac..000000000 --- a/app/payment/migrations/0003_alter_order_event_alter_order_user.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 4.0.8 on 2023-08-29 07:15 - -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'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('payment', '0002_alter_paidevent_event'), - ] - - operations = [ - migrations.AlterField( - model_name='order', - name='event', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='content.event'), - ), - migrations.AlterField( - model_name='order', - name='user', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/app/payment/models/order.py b/app/payment/models/order.py index 31e9381d2..12dc3c823 100644 --- a/app/payment/models/order.py +++ b/app/payment/models/order.py @@ -32,8 +32,8 @@ 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}" + def __str__(self): + return f"{self.user.first_name} {self.user.last_name} - {self.status} - {self.created_at}" @property def expired(self): diff --git a/app/payment/serializers/__init__.py b/app/payment/serializers/__init__.py index 8789a8df3..66f357150 100644 --- a/app/payment/serializers/__init__.py +++ b/app/payment/serializers/__init__.py @@ -1 +1,5 @@ -from .order import OrderSerializer +from app.payment.serializers.order import ( + OrderSerializer, + OrderListSerializer, + OrderUpdateCreateSerializer, +) diff --git a/app/payment/serializers/order.py b/app/payment/serializers/order.py index 51fd13a05..15145ff95 100644 --- a/app/payment/serializers/order.py +++ b/app/payment/serializers/order.py @@ -29,3 +29,17 @@ def create(self, validated_data): return Order.objects.create( user=user, expired_date=now() + paytime, **validated_data ) + + +class OrderEventListSerializer(BaseModelSerializer): + class Meta: + model = Event + fields = ("title", "id") + + +class OrderListSerializer(BaseModelSerializer): + event = OrderEventListSerializer(read_only=True) + + class Meta: + model = Order + fields = ("order_id", "status", "payment_link", "user", "event") diff --git a/app/payment/util/order_utils.py b/app/payment/util/order_utils.py new file mode 100644 index 000000000..d253c7558 --- /dev/null +++ b/app/payment/util/order_utils.py @@ -0,0 +1,41 @@ +from app.payment.enums import OrderStatus + + +def filter_user_event_orders(orders): + """ + Filter user payment orders for events, so the user only get + displayed the paid order for an event. All refunds will be displayed. + """ + + STATUS_PRIORITY = [ + OrderStatus.SALE, + OrderStatus.RESERVE, + OrderStatus.CAPTURE, + OrderStatus.INITIATE, + OrderStatus.CANCEL + ] + + filtered_orders = {} + final_orders = [] + + for order in orders: + if ( + not order.event or + order.event.expired + ): + continue + + if order.status == OrderStatus.REFUND: + final_orders.append(order) + continue + + if ( + order.event.id not in filtered_orders or + STATUS_PRIORITY.index(order.status) < STATUS_PRIORITY.index(filtered_orders[order.event.id].status) + ): + filtered_orders[order.event.id] = order + + filtered_orders = list(filtered_orders.values()) + final_orders += filtered_orders + + return final_orders \ No newline at end of file diff --git a/app/payment/util/payment_utils.py b/app/payment/util/payment_utils.py index 03b82ffc2..96bd5ce1f 100644 --- a/app/payment/util/payment_utils.py +++ b/app/payment/util/payment_utils.py @@ -17,7 +17,13 @@ def get_new_access_token(): "Merchant-Serial-Number": settings.VIPPS_MERCHANT_SERIAL_NUMBER, } - response = requests.post(TOKEN_URL, headers=TOKEN_HEADERS).json() + response = requests.post(TOKEN_URL, headers=TOKEN_HEADERS) + + if response.status_code != 200: + raise Exception("Could not get access token") + + response = response.json() + return (response["expires_on"], response["access_token"]) diff --git a/app/payment/views/__init__.py b/app/payment/views/__init__.py index e69de29bb..761f46a67 100644 --- a/app/payment/views/__init__.py +++ b/app/payment/views/__init__.py @@ -0,0 +1 @@ +from app.payment.views.order import Order, OrderListSerializer diff --git a/app/payment/views/order.py b/app/payment/views/order.py index 49ddb2859..c6ab0b341 100644 --- a/app/payment/views/order.py +++ b/app/payment/views/order.py @@ -1,27 +1,61 @@ -from rest_framework import status +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, status from rest_framework.response import Response from sentry_sdk import capture_exception from app.common.mixins import ActionMixin +from app.common.pagination import BasePagination +from app.common.permissions import is_admin_user from app.common.viewsets import BaseViewSet +from app.payment.filters import OrderFilter from app.payment.models import Order -from app.payment.serializers import OrderSerializer +from app.payment.serializers import OrderListSerializer, OrderSerializer class OrderViewSet(BaseViewSet, ActionMixin): serializer_class = OrderSerializer - queryset = Order.objects.all() + pagination_class = BasePagination + + filter_backends = [DjangoFilterBackend, filters.SearchFilter] + filterset_class = OrderFilter + search_fields = ["user", "event"] + + def get_queryset(self): + if hasattr(self, "action") and self.action == "list": + return Order.objects.all() + + def list(self, request): + """ + Returns list of payment orders. + """ + if is_admin_user(request): + serializer = OrderListSerializer( + self.get_queryset(), context={"request": request}, many=True + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + return Response( + {"detail": "Du har ikke tilgang til alle betalingsordrene."}, + status=status.HTTP_403_FORBIDDEN, + ) def retrieve(self, request, pk): + """ + Returns detailed information about the order with the specified pk. + """ try: - user = request.query_params.get("user_id") - event = request.query_params.get("event") - order = Order.objects.filter(user=user, event=event)[0] + if not is_admin_user(request): + return Response( + {"detail": "Du har ikke tilgang til denne betalingsordren."}, + status=status.HTTP_403_FORBIDDEN, + ) + + order = self.get_object() serializer = OrderSerializer( order, context={"request": request}, many=False ) - return Response(serializer.data, status.HTTP_200_OK) + return Response(serializer.data, status=status.HTTP_200_OK) except Order.DoesNotExist as order_not_exist: capture_exception(order_not_exist) return Response( diff --git a/app/payment/views/vipps_callback.py b/app/payment/views/vipps_callback.py index bdfc45ff4..63b95c607 100644 --- a/app/payment/views/vipps_callback.py +++ b/app/payment/views/vipps_callback.py @@ -2,49 +2,39 @@ 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() + 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 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, - } + 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() + res = requests.post(url, headers=headers) + status_code = res.status_code + json = res.json() + return (json, status_code) diff --git a/app/settings.py b/app/settings.py index c5409312e..c4390e40c 100644 --- a/app/settings.py +++ b/app/settings.py @@ -283,8 +283,3 @@ DEFAULT_AUTO_FIELD = "django.db.models.AutoField" CELERY_BROKER_URL = "amqp://guest:guest@rabbitmq:5672" -if ENVIRONMENT == EnvironmentOptions.LOCAL: - # Change this to False when testing async celery tasks localy - CELERY_TASK_ALWAYS_EAGER = True - - \ No newline at end of file diff --git a/app/tests/conftest.py b/app/tests/conftest.py index dea934478..210453a02 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -26,7 +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.payment.factories import OrderFactory, PaidEventFactory from app.util.test_utils import add_user_to_group_with_name, get_api_client @@ -102,6 +102,11 @@ def paid_event(): return PaidEventFactory() +@pytest.fixture() +def payment_order(): + return OrderFactory() + + @pytest.fixture() def group(): return GroupFactory() 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/payment/test_order_integration.py b/app/tests/payment/test_order_integration.py index 55d2413fc..3592105d2 100644 --- a/app/tests/payment/test_order_integration.py +++ b/app/tests/payment/test_order_integration.py @@ -1,21 +1,49 @@ -API_EVENT_BASE_URL = "/events/" -API_PAYMENT_BASE_URL = "/payment/" +import pytest +from app.util.test_utils import get_api_client -def _get_registration_url(event): - return f"{API_EVENT_BASE_URL}{event.pk}/registrations/" +API_PAYMENTS_BASE_URL = "/payment/" -def _get_order_url(): - return f"{API_PAYMENT_BASE_URL}order/" +def get_payment_order(order_id): + return f"{API_PAYMENTS_BASE_URL}{order_id}/" -def _get_registration_post_data(user, event): - return { - "user_id": user.user_id, - "event": event.pk, - } +@pytest.mark.django_db +def test_list_payment_orders_as_anonymous_user(default_client): + """An anonymous user should not be able to list all payment orders.""" + response = default_client.get(API_PAYMENTS_BASE_URL) -def _get_order_data(user, event): - return {"user_id": user.user_id, "event": event.pk} + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_list_payment_orders_as_admin_user(admin_user): + """An admin user should be able to list all payment orders.""" + + client = get_api_client(admin_user) + response = client.get(API_PAYMENTS_BASE_URL) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_get_order_as_anonymous_user(default_client, payment_order): + """An anonymous user should not be able to retrieve a spesific order.""" + + url = get_payment_order(payment_order.order_id) + response = default_client.get(url) + + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_get_order_as_admin_user(admin_user, payment_order): + """An admin user should be able to retrieve a spesific order.""" + + url = get_payment_order(payment_order.order_id) + client = get_api_client(admin_user) + response = client.get(url) + + assert response.status_code == 200 diff --git a/app/util/exceptions.py b/app/util/exceptions.py index 57d8d2ca4..e4e08f664 100644 --- a/app/util/exceptions.py +++ b/app/util/exceptions.py @@ -22,7 +22,7 @@ def exception_handler(exc, context): if response: log_api_error(response, exc) else: - logger.error(f"Unhandled request exception: {traceback(exc)}") + logger.error(traceback.format_exc()) if not settings.DEBUG and not response: response = Response(