diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5de97c5d5..cab568a1b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,6 +41,7 @@ jobs: run: | touch .env echo "AZURE_STORAGE_CONNECTION_STRING=${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}" >> .env + echo "VIPPS_MERCHANT_SERIAL_NUMBER=${{ secrets.VIPPS_MERCHANT_SERIAL_NUMBER }}" >> .env - name: Build the Stack run: docker-compose build diff --git a/.github/workflows/deploy_dev.yml b/.github/workflows/deploy_dev.yml index 5dfcfaa4a..bfebef6ea 100644 --- a/.github/workflows/deploy_dev.yml +++ b/.github/workflows/deploy_dev.yml @@ -10,7 +10,7 @@ jobs: deploy: uses: TIHLDE/Lepton/.github/workflows/deploy_to_azure.yml@dev with: - registry_name: leptondevregistry + registry_name: leptonregistrydev secrets: registry_username: ${{ secrets.ACI_DEV_USERNAME }} registry_password: ${{ secrets.ACI_DEV_PASSWORD }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cbc8b0c2..8fd93fec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ ## Neste versjon +## Versjon 2023.01.15 +- ✨ **Reaksjoner** Brukere kan reagere med emojier på arrangementer og nyheter. +- 🎨 **JubKom Rettigheter** Medlemmer av JubKom kan ikke lenger lage arrangementer. +- 🦟 **KoK Rettigheter** Medlemmer av KoK kan nå lage arrangementer, nyheter og manuelt legge til medlemmer på arrangement. +- 🦟 **Aktiviteter** Filterering av aktiviteter og arrangementer fungerer nå riktig. I visning av gruppe, vil nå også aktiviteter vises. +- 🦟 **Manuell Registering** Man får nå en feilmelding om at man ikke kan legge til dobbel manuell registering for en bruker på et arrangement. +- ⚡ **Mine Arrangementer** En bruker kan nå se alle sine tidligere arrangementer som de har deltatt på. +- 🦟 **Vipps** En ny versjon av Vipps betaling har nå kommet. + ## Versjon 2023.11.06 - ✨ **Påmelding** Medlemmer av undergrupper kan nå manuelt legge til brukere på arrangementer. - ⚡ **Venteliste** Brukere på venteliste kan nå se sin egen plass på ventelisten. @@ -24,6 +33,7 @@ ## Versjon 2023.10.23 - ⚡ **Brukere** HS kan styre medlemmer - ✨ **Bannere** Filtrering på bannere +- ✨ **emoji** Nyheter kan nå ha reaksjoner - ✨ **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. @@ -31,6 +41,7 @@ - ✨ **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. - ⚡ **Arrangementer** Du kan nå se hvilken plass du har på ventelisten til et arrangement. +- ✨ **Profil** Filtrere kommende og tidligere arrangementer ## Versjon 2022.10.13 diff --git a/app/common/enums.py b/app/common/enums.py index 94a50df75..7e3bd3897 100644 --- a/app/common/enums.py +++ b/app/common/enums.py @@ -27,7 +27,7 @@ class AdminGroup(ChoiceEnum): NOK = "Nok" PROMO = "Promo" SOSIALEN = "Sosialen" - KOK = "Kok" + KOK = "Kontkom" @classmethod def all(cls): @@ -43,6 +43,7 @@ class Groups(ChoiceEnum): JUBKOM = "JubKom" REDAKSJONEN = "Redaksjonen" FONDET = "Forvaltningsgruppen" + PLASK = "Plask" class AppModel(ChoiceEnum): diff --git a/app/content/exceptions.py b/app/content/exceptions.py index 07eae9f42..c3af02d3b 100644 --- a/app/content/exceptions.py +++ b/app/content/exceptions.py @@ -4,7 +4,12 @@ class APIPaidEventCantBeChangedToFreeEventException(APIException): status_code = status.HTTP_400_BAD_REQUEST - default_detail = "Arrangementet er et betalt arrangement, og kan ikke endres til et gratis arrangement" + default_detail = "Arrangementet er et betalt arrangement med påmeldte deltagere, og kan ikke endres til et gratis arrangement" + + +class APIEventCantBeChangedToPaidEventException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Arrangementet er et gratis arrangement med påmeldte deltagere, og kan ikke endres til et betalt arrangement" class APIUserAlreadyAttendedEvent(APIException): @@ -48,3 +53,7 @@ class UnansweredFormError(ValueError): class EventIsFullError(ValueError): pass + + +class RefundFailedError(ValueError): + pass diff --git a/app/content/factories/event_factory.py b/app/content/factories/event_factory.py index 7f30eeb9f..f4724f901 100644 --- a/app/content/factories/event_factory.py +++ b/app/content/factories/event_factory.py @@ -20,3 +20,4 @@ class Meta: start_registration_at = timezone.now() - timedelta(days=1) end_registration_at = timezone.now() + timedelta(days=9) sign_off_deadline = timezone.now() + timedelta(days=8) + emojis_allowed = True diff --git a/app/content/factories/news_factory.py b/app/content/factories/news_factory.py index 735718169..a19c51e28 100644 --- a/app/content/factories/news_factory.py +++ b/app/content/factories/news_factory.py @@ -11,3 +11,4 @@ class Meta: title = factory.Faker("sentence", nb_words=5) header = factory.Faker("sentence", nb_words=5) body = factory.Faker("paragraph", nb_sentences=10) + emojis_allowed = True diff --git a/app/content/migrations/0054_qrcode.py b/app/content/migrations/0054_qrcode.py index e8cf35b2e..fc0757724 100644 --- a/app/content/migrations/0054_qrcode.py +++ b/app/content/migrations/0054_qrcode.py @@ -44,4 +44,4 @@ class Migration(migrations.Migration): "verbose_name_plural": "qr_codes", }, ), - ] + ] \ No newline at end of file diff --git a/app/content/migrations/0054_registration_payment_expiredate.py b/app/content/migrations/0054_registration_payment_expiredate.py new file mode 100644 index 000000000..9df7b112f --- /dev/null +++ b/app/content/migrations/0054_registration_payment_expiredate.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2023-10-18 08:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0053_event_contact_person"), + ] + + operations = [ + migrations.AddField( + model_name="registration", + name="payment_expiredate", + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/app/content/migrations/0055_remove_qrcode_url_qrcode_content.py b/app/content/migrations/0055_remove_qrcode_url_qrcode_content.py index 888c90ddb..9be58442f 100644 --- a/app/content/migrations/0055_remove_qrcode_url_qrcode_content.py +++ b/app/content/migrations/0055_remove_qrcode_url_qrcode_content.py @@ -20,4 +20,4 @@ class Migration(migrations.Migration): field=models.CharField(default=None, max_length=600), preserve_default=False, ), - ] + ] \ No newline at end of file diff --git a/app/content/migrations/0057_event_emojis_allowed_news_emojis_allowed.py b/app/content/migrations/0057_event_emojis_allowed_news_emojis_allowed.py new file mode 100644 index 000000000..18bf55af2 --- /dev/null +++ b/app/content/migrations/0057_event_emojis_allowed_news_emojis_allowed.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.5 on 2023-12-15 17:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0056_registration_created_by_admin"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="emojis_allowed", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="news", + name="emojis_allowed", + field=models.BooleanField(default=False), + ), + ] diff --git a/app/content/migrations/0058_merge_20231217_2155.py b/app/content/migrations/0058_merge_20231217_2155.py new file mode 100644 index 000000000..16c3bfd0f --- /dev/null +++ b/app/content/migrations/0058_merge_20231217_2155.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.5 on 2023-12-17 20:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0054_registration_payment_expiredate"), + ("content", "0057_event_emojis_allowed_news_emojis_allowed"), + ] + + operations = [] diff --git a/app/content/models/event.py b/app/content/models/event.py index bbf6de139..0accdbf11 100644 --- a/app/content/models/event.py +++ b/app/content/models/event.py @@ -1,9 +1,10 @@ from datetime import timedelta +from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models -from app.common.enums import AdminGroup, Groups +from app.common.enums import AdminGroup from app.common.permissions import ( BasePermissionModel, check_has_access, @@ -11,6 +12,7 @@ ) from app.content.models import Category from app.content.models.user import User +from app.emoji.models.reaction import Reaction from app.forms.enums import EventFormType from app.group.models.group import Group from app.util.models import BaseModel, OptionalImage @@ -19,7 +21,7 @@ class Event(BaseModel, OptionalImage, BasePermissionModel): - write_access = (*AdminGroup.admin(), AdminGroup.PROMO, Groups.JUBKOM) + write_access = (*AdminGroup.admin(), AdminGroup.PROMO) title = models.CharField(max_length=200) start_date = models.DateTimeField() @@ -77,6 +79,10 @@ class Event(BaseModel, OptionalImage, BasePermissionModel): runned_sign_off_deadline_reminder = models.BooleanField(default=False) runned_sign_up_start_notifier = models.BooleanField(default=False) + """ Reactions """ + emojis_allowed = models.BooleanField(default=False) + reactions = GenericRelation(Reaction) + class Meta: ordering = ("start_date",) @@ -102,6 +108,11 @@ def list_count(self): """Number of users registered to attend the event""" return self.get_participants().count() + @property + def has_participants(self): + """Returns if the event has users registered to attend the event""" + return self.list_count > 0 + @property def waiting_list_count(self): """Number of users on the waiting list""" @@ -134,6 +145,9 @@ def get_waiting_list(self): def user_has_attended_event(self, user): return self.get_participants().filter(user=user, has_attended=True).exists() + def user_is_participant(self, user): + return self.get_participants().filter(user=user).exists() + @property def is_past_sign_off_deadline(self): return now() >= self.sign_off_deadline diff --git a/app/content/models/news.py b/app/content/models/news.py index ede2690b4..db696839c 100644 --- a/app/content/models/news.py +++ b/app/content/models/news.py @@ -1,8 +1,10 @@ from django.conf import settings +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from app.common.enums import AdminGroup, Groups from app.common.permissions import BasePermissionModel +from app.emoji.models.reaction import Reaction from app.util.models import BaseModel, OptionalImage @@ -17,6 +19,8 @@ class News(BaseModel, OptionalImage, BasePermissionModel): related_name="created_news", ) body = models.TextField() + emojis_allowed = models.BooleanField(default=False) + reactions = GenericRelation(Reaction) write_access = [*AdminGroup.all(), Groups.FONDET] diff --git a/app/content/models/registration.py b/app/content/models/registration.py index 544ec5979..1ccda13c4 100644 --- a/app/content/models/registration.py +++ b/app/content/models/registration.py @@ -4,6 +4,8 @@ from django.db import models from django.db.models import Q +from sentry_sdk import capture_exception + from app.common.enums import StrikeEnum from app.common.permissions import BasePermissionModel from app.communication.enums import UserNotificationSettingType @@ -17,7 +19,9 @@ from app.content.models.event import Event from app.content.models.strike import create_strike from app.content.models.user import User +from app.content.util.registration_utils import get_payment_expiredate from app.forms.enums import EventFormType +from app.payment.util.order_utils import check_if_order_is_paid, has_paid_order from app.util import now from app.util.models import BaseModel from app.util.utils import datetime_format @@ -36,6 +40,7 @@ class Registration(BaseModel, BasePermissionModel): is_on_wait = models.BooleanField(default=False, verbose_name="waiting list") has_attended = models.BooleanField(default=False) allow_photo = models.BooleanField(default=True) + payment_expiredate = models.DateTimeField(null=True, default=None) created_by_admin = models.BooleanField(default=False) class Meta: @@ -86,7 +91,27 @@ def delete_submission_if_exists(self): )[:1] Submission.objects.filter(form=event_form, user=self.user).delete() + def refund_payment_if_exist(self): + from app.content.util.event_utils import refund_vipps_order + + if not self.event.is_paid_event: + return + + orders = self.event.orders.filter(user=self.user) + + if has_paid_order(orders): + for order in orders: + if check_if_order_is_paid(order): + refund_vipps_order( + order_id=order.order_id, + event=self.event, + transaction_text=f"Refund for {self.event.title} - {self.user.first_name} {self.user.last_name}", + ) + self.send_notification_and_mail_for_refund(order) + def delete(self, *args, **kwargs): + from app.content.util.event_utils import start_payment_countdown + moved_registration = None if not self.is_on_wait: if self.event.is_past_sign_off_deadline: @@ -99,9 +124,26 @@ def delete(self, *args, **kwargs): moved_registration = self.move_from_waiting_list_to_queue() self.delete_submission_if_exists() + + # TODO: Add this for refund + # self.refund_payment_if_exist() + registration = super().delete(*args, **kwargs) if moved_registration: moved_registration.save() + + if ( + moved_registration.event.is_paid_event + and not moved_registration.is_on_wait + ): + try: + start_payment_countdown( + moved_registration.event, moved_registration + ) + except Exception as countdown_error: + capture_exception(countdown_error) + moved_registration.delete() + return registration def admin_unregister(self, *args, **kwargs): @@ -115,6 +157,7 @@ def admin_unregister(self, *args, **kwargs): moved_registration.save() def save(self, *args, **kwargs): + if not self.registration_id: self.create() @@ -208,6 +251,19 @@ def send_notification_and_mail(self): self.event.pk ).send() + def send_notification_and_mail_for_refund(self, order): + Notify( + [self.user], + f'Du har blitt meldt av "{self.event.title}" og vil bli refundert', + UserNotificationSettingType.UNREGISTRATION, + ).add_paragraph(f"Hei, {self.user.first_name}!").add_paragraph( + f"Du har blitt meldt av {self.event.title} og vil bli refundert." + ).add_paragraph( + "Du vil få pengene tilbake på kontoen din innen 2 til 3 virkedager. I enkelte tilfeller, avhengig av bank, tar det inntil 10 virkedager." + ).add_paragraph( + f"Hvis det skulle oppstå noen problemer så kontakt oss på hs@tihlde.org. Ditt ordrenummer er {order.order_id}." + ).send() + def should_swap_with_non_prioritized_user(self): return ( self.is_on_wait @@ -279,6 +335,12 @@ def move_from_waiting_list_to_queue(self): registrations_in_waiting_list[0], ) registration_move_to_queue.is_on_wait = False + + if self.event.is_paid_event: + registration_move_to_queue.payment_expiredate = get_payment_expiredate( + self.event + ) + return registration_move_to_queue def move_from_queue_to_waiting_list(self): diff --git a/app/content/serializers/event.py b/app/content/serializers/event.py index b2a3e1088..be994abbd 100644 --- a/app/content/serializers/event.py +++ b/app/content/serializers/event.py @@ -5,13 +5,17 @@ from app.common.enums import GroupType from app.common.serializers import BaseModelSerializer -from app.content.exceptions import APIPaidEventCantBeChangedToFreeEventException +from app.content.exceptions import ( + APIEventCantBeChangedToPaidEventException, + APIPaidEventCantBeChangedToFreeEventException, +) from app.content.models import Event, PriorityPool from app.content.serializers.priority_pool import ( PriorityPoolCreateSerializer, PriorityPoolSerializer, ) from app.content.serializers.user import DefaultUserSerializer +from app.emoji.serializers.reaction import ReactionSerializer from app.group.models.group import Group from app.group.serializers.group import SimpleGroupSerializer from app.payment.models.paid_event import PaidEvent @@ -29,6 +33,7 @@ class EventSerializer(serializers.ModelSerializer): required=False, allow_null=True ) contact_person = DefaultUserSerializer(read_only=True, required=False) + reactions = ReactionSerializer(required=False, many=True) class Meta: model = Event @@ -63,6 +68,8 @@ class Meta: "paid_information", "is_paid_event", "contact_person", + "reactions", + "emojis_allowed", ) def get_paid_information(self, obj): @@ -150,6 +157,7 @@ class Meta: "paid_information", "is_paid_event", "contact_person", + "emojis_allowed", ) def to_internal_value(self, data): @@ -171,36 +179,57 @@ def update(self, instance, validated_data): priority_pools_data = validated_data.pop("priority_pools", None) paid_information_data = validated_data.pop("paid_information", None) limit = validated_data.get("limit") - limit_difference = 0 - if limit: - limit_difference = limit - instance.limit + instance_limit = instance.limit event = super().update(instance, validated_data) + self.update_queue(event, limit, instance_limit) + + self.update_from_free_to_paid(event, paid_information_data) + + self.update_from_paid_to_free(event, paid_information_data) + + 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) + + event.save() + return event + + def update_queue(self, event, limit, instance_limit): + if not limit: + return + + limit_difference = limit - instance_limit + if limit_difference > 0 and event.waiting_list_count > 0: event.move_users_from_waiting_list_to_queue(limit_difference) if limit_difference < 0: event.move_users_from_queue_to_waiting_list(abs(limit_difference)) + def update_from_paid_to_free(self, event, paid_information_data): if paid_information_data and not event.is_paid_event: + if event.has_participants: + raise APIEventCantBeChangedToPaidEventException() + 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): - raise APIPaidEventCantBeChangedToFreeEventException() - - 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) + def update_from_free_to_paid(self, event, paid_information_data): + if event.is_paid_event: + if not len(paid_information_data) and event.has_participants: + raise APIPaidEventCantBeChangedToFreeEventException() - event.save() - return event + paid_event = PaidEvent.objects.filter(event=event) + if paid_event: + paid_event.first().delete() + event.paid_information = None def update_priority_pools(self, event, priority_pools_data): event.priority_pools.all().delete() diff --git a/app/content/serializers/news.py b/app/content/serializers/news.py index 7b6651c0d..8417f2af2 100644 --- a/app/content/serializers/news.py +++ b/app/content/serializers/news.py @@ -1,6 +1,7 @@ from app.common.serializers import BaseModelSerializer from app.content.models import News, User from app.content.serializers.user import DefaultUserSerializer +from app.emoji.serializers.reaction import ReactionSerializer class SimpleNewsSerializer(BaseModelSerializer): @@ -19,12 +20,15 @@ class Meta: class NewsSerializer(SimpleNewsSerializer): creator = DefaultUserSerializer(read_only=True) + reactions = ReactionSerializer(required=False, many=True) class Meta: model = SimpleNewsSerializer.Meta.model fields = SimpleNewsSerializer.Meta.fields + ( "creator", "body", + "reactions", + "emojis_allowed", ) def create(self, validated_data): diff --git a/app/content/serializers/registration.py b/app/content/serializers/registration.py index 61b6156c6..724c2c697 100644 --- a/app/content/serializers/registration.py +++ b/app/content/serializers/registration.py @@ -6,17 +6,18 @@ DefaultUserSerializer, UserListSerializer, ) +from app.content.util.registration_utils import get_payment_expiredate 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 +from app.payment.util.order_utils import has_paid_order +from app.payment.util.payment_utils import get_payment_order_status 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) wait_queue_number = serializers.SerializerMethodField(required=False) @@ -31,7 +32,7 @@ class Meta: "created_at", "survey_submission", "has_unanswered_evaluation", - "order", + "payment_expiredate", "has_paid_order", "wait_queue_number", "created_by_admin", @@ -44,20 +45,23 @@ 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 + orders = obj.event.orders.filter(user=obj.user) + + if orders and (order := orders.first()).status == OrderStatus.INITIATE: + order_status = get_payment_order_status(order.order_id) + order.status = order_status + order.save() + + return has_paid_order(orders) + + def create(self, validated_data): + event = validated_data["event"] + + if event.is_paid_event and not event.is_full: + validated_data["payment_expiredate"] = get_payment_expiredate(event) + + return super().create(validated_data) def get_wait_queue_number(self, obj): if obj.is_on_wait: diff --git a/app/content/serializers/user.py b/app/content/serializers/user.py index fb7be4982..5efd06b8b 100644 --- a/app/content/serializers/user.py +++ b/app/content/serializers/user.py @@ -104,6 +104,16 @@ class Meta(UserSerializer.Meta): ) +class SimpleUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ( + "user_id", + "first_name", + "last_name", + ) + + class UserCreateSerializer(serializers.ModelSerializer): study = serializers.SlugRelatedField( slug_field="slug", diff --git a/app/content/tests/test_event_utils.py b/app/content/tests/test_event_utils.py new file mode 100644 index 000000000..ec8b3c925 --- /dev/null +++ b/app/content/tests/test_event_utils.py @@ -0,0 +1,39 @@ +from datetime import timedelta + +import pytest + +from app.content.factories import EventFactory, RegistrationFactory +from app.content.util.event_utils import get_countdown_time +from app.payment.factories import PaidEventFactory + + +@pytest.fixture() +def paid_event(): + return PaidEventFactory() + + +@pytest.fixture() +def event(): + return EventFactory() + + +@pytest.fixture() +def registration(paid_event): + return RegistrationFactory(event=paid_event) + + +@pytest.mark.django_db +def test_that_paytime_countdown_adds_ten_minutes(paid_event): + """ + Should return the countdown time of the event + 10 minutes. + """ + + paytime = paid_event.paytime + paytime_in_seconds = (paytime.hour * 60 + paytime.minute) * 60 + paytime.second + + countdown_time = get_countdown_time(paid_event.event) + + ten_minutes = timedelta(minutes=10) + ten_minutes_in_seconds = ten_minutes.seconds + + assert countdown_time - paytime_in_seconds == ten_minutes_in_seconds diff --git a/app/content/util/event_utils.py b/app/content/util/event_utils.py index 034b98cc4..30852acdf 100644 --- a/app/content/util/event_utils.py +++ b/app/content/util/event_utils.py @@ -1,66 +1,89 @@ import os -import uuid -from datetime import datetime, timedelta +from datetime import datetime -from app.payment.enums import OrderStatus -from app.payment.models.order import Order +from sentry_sdk import capture_exception + +from app.content.exceptions import RefundFailedError from app.payment.tasks import check_if_has_paid from app.payment.util.payment_utils import ( get_new_access_token, initiate_payment, + refund_payment, ) -def create_payment_order(event, request, registration): +def start_payment_countdown(event, registration): """ Checks if event is a paid event - and creates a new Vipps payment order. + and starts the countdown for payment for an user. + """ + + if not event.is_paid_event or registration.is_on_wait: + return + + try: + check_if_has_paid.apply_async( + args=(event.id, registration.registration_id), + countdown=get_countdown_time(event), + ) + except Exception as payment_countdown_error: + capture_exception(payment_countdown_error) + + +def get_countdown_time(event): + paytime = event.paid_information.paytime + return (paytime.hour * 60 + paytime.minute + 10) * 60 + paytime.second + + +def create_vipps_order(order_id, event, transaction_text, fallback): + """ + Creates vipps order, and returns the url. + """ + + 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)}) + + event_price = int(event.paid_information.price * 100) + + response = initiate_payment( + amount=event_price, + order_id=str(order_id), + access_token=access_token, + transaction_text=transaction_text, + fallback=fallback, + ) + + return response["url"] + + +def refund_vipps_order(order_id, event, transaction_text): """ + Refunds vipps order. + """ + + 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)}) + + event_price = int(event.paid_information.price) * 100 + + try: + refund_payment( + amount=event_price, + order_id=str(order_id), + access_token=access_token, + transaction_text=transaction_text, + ) - 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, - ) + except Exception as refund_error: + capture_exception(refund_error) + raise RefundFailedError("Tilbakebetaling feilet") diff --git a/app/content/util/registration_utils.py b/app/content/util/registration_utils.py new file mode 100644 index 000000000..1e024c7b7 --- /dev/null +++ b/app/content/util/registration_utils.py @@ -0,0 +1,9 @@ +from datetime import datetime, timedelta + + +def get_payment_expiredate(event): + return datetime.now() + timedelta( + hours=event.paid_information.paytime.hour, + minutes=event.paid_information.paytime.minute, + seconds=event.paid_information.paytime.second, + ) diff --git a/app/content/views/event.py b/app/content/views/event.py index 334dd8b1f..7ad908c8f 100644 --- a/app/content/views/event.py +++ b/app/content/views/event.py @@ -62,20 +62,26 @@ def _list_queryset(self): ): return self.queryset - activity = self.request.query_params.get("activity", "false").lower() == "true" - category = Category.objects.filter(text=CategoryEnum.ACTIVITY).first() expired = self.request.query_params.get("expired", "false").lower() == "true" + activity = self.request.query_params.get("activity", None) - if activity and category: - return self._list_activity_queryset(category, expired, time) + if activity is None: + if expired: + return self.queryset.filter(end_date__lt=time).order_by("-start_date") + return self.queryset.filter(end_date__gte=time) - if expired: - return self.queryset.filter(end_date__lt=time).order_by("-start_date") + category = Category.objects.filter(text=CategoryEnum.ACTIVITY).first() + if category and activity.lower() == "true": + return self._list_activity_queryset(category, expired, time) - if category: - return self.queryset.filter(end_date__gte=time).filter( - ~Q(category=category) - ) + if category and activity.lower() == "false": + if expired: + return ( + self.queryset.filter(end_date__lt=time) + .filter(~Q(category=category)) + .order_by("-start_date") + ) + return self.queryset.filter(~Q(category=category)) return self.queryset.filter(end_date__gte=time) @@ -156,6 +162,7 @@ def create(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): event = Event.objects.get(pk=kwargs["pk"]) if event.is_paid_event: + # TODO: Add refund for all paid orders by participants paid_event = PaidEvent.objects.get(event=kwargs["pk"]) paid_event.delete() diff --git a/app/content/views/registration.py b/app/content/views/registration.py index da8c8d3a1..11bb2e6ca 100644 --- a/app/content/views/registration.py +++ b/app/content/views/registration.py @@ -1,5 +1,4 @@ import uuid -from datetime import datetime from django.shortcuts import get_object_or_404 from django_filters.rest_framework import DjangoFilterBackend @@ -22,8 +21,9 @@ from app.content.mixins import APIRegistrationErrorsMixin from app.content.models import Event, Registration, User from app.content.serializers import RegistrationSerializer -from app.content.util.event_utils import create_payment_order -from app.payment.models import Order +from app.content.util.event_utils import start_payment_countdown +from app.payment.enums import OrderStatus +from app.payment.models.order import Order class RegistrationViewSet(APIRegistrationErrorsMixin, BaseViewSet): @@ -63,6 +63,7 @@ def create(self, request, *args, **kwargs): request.data["allow_photo"] = request.user.allows_photo_by_default serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) event_id = self.kwargs.get("event_id", None) @@ -73,13 +74,13 @@ def create(self, request, *args, **kwargs): ) try: - create_payment_order(event, request, registration) - except Exception as order_error: - capture_exception(order_error) + start_payment_countdown(event, registration) + except Exception as countdown_error: + capture_exception(countdown_error) registration.delete() return Response( { - "detail": "Det skjedde en feil med opprettelse av betalingsordre. Påmeldingen ble ikke fullført." + "detail": "Det skjedde en feil med oppstart av betalingsfrist. Påmeldingen ble ikke fullført." }, status=status.HTTP_400_BAD_REQUEST, ) @@ -159,6 +160,12 @@ def add_registration(self, request, *args, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) + if event.user_is_participant(user): + return Response( + {"detail": "Brukeren er allerede påmeldt arrangementet."}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: if event.is_paid_event and not event.is_full: Order.objects.create( @@ -166,7 +173,7 @@ def add_registration(self, request, *args, **kwargs): user=user, event=event, payment_link=f"https://tihlde.org/arrangementer/{event_id}/", - expire_date=datetime.now(), + status=OrderStatus.SALE, ) except Exception as e: capture_exception(e) diff --git a/app/content/views/user.py b/app/content/views/user.py index 727c2197b..c1d8ba67e 100644 --- a/app/content/views/user.py +++ b/app/content/views/user.py @@ -276,11 +276,17 @@ def get_user_detail_strikes(self, request, *args, **kwargs): @action(detail=False, methods=["get"], url_path="me/events") def get_user_events(self, request, *args, **kwargs): registrations = request.user.registrations.all() + + # Apply the filter + filter_field = self.request.query_params.get("expired") + event_has_ended = CaseInsensitiveBooleanQueryParam(filter_field) + events = [ registration.event for registration in registrations - if not registration.event.expired + if registration.event.expired == event_has_ended.value ] + return self.paginate_response( data=events, serializer=EventListSerializer, context={"request": request} ) diff --git a/app/emoji/__init__.py b/app/emoji/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/emoji/admin.py b/app/emoji/admin.py new file mode 100644 index 000000000..74148625f --- /dev/null +++ b/app/emoji/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from app.emoji.models.reaction import Reaction + + +@admin.register(Reaction) +class ReactionAdmin(admin.ModelAdmin): + pass diff --git a/app/emoji/apps.py b/app/emoji/apps.py new file mode 100644 index 000000000..40d6314f4 --- /dev/null +++ b/app/emoji/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class EmojiConfig(AppConfig): + name = "app.emoji" diff --git a/app/emoji/enums.py b/app/emoji/enums.py new file mode 100644 index 000000000..c870ede16 --- /dev/null +++ b/app/emoji/enums.py @@ -0,0 +1,6 @@ +from django.db import models + + +class ContentTypes(models.TextChoices): + NEWS = "news" + EVENT = "event" diff --git a/app/emoji/exception.py b/app/emoji/exception.py new file mode 100644 index 000000000..22987cc55 --- /dev/null +++ b/app/emoji/exception.py @@ -0,0 +1,17 @@ +from rest_framework import status +from rest_framework.exceptions import APIException + + +class APIReactionNotAllowedException(APIException): + status_code = status.HTTP_403_FORBIDDEN + default_detail = "Reaksjoner er ikke tillatt her" + + +class APIContentTypeNotSupportedException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Er ikke støtte for denne typen" + + +class APIReactionDuplicateNotAllowedException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Du har allerede reagert her" diff --git a/app/emoji/factories/__init__.py b/app/emoji/factories/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/emoji/factories/reaction_factory.py b/app/emoji/factories/reaction_factory.py new file mode 100644 index 000000000..57cb2336d --- /dev/null +++ b/app/emoji/factories/reaction_factory.py @@ -0,0 +1,37 @@ +from django.contrib.contenttypes.models import ContentType + +import factory +from factory.django import DjangoModelFactory + +from app.content.factories.event_factory import EventFactory +from app.content.factories.news_factory import NewsFactory +from app.content.factories.user_factory import UserFactory +from app.content.models.event import Event +from app.content.models.news import News +from app.emoji.models.reaction import Reaction + + +class NewsReactionFactory(DjangoModelFactory): + class Meta: + model = Reaction + + emoji = factory.Faker("emoji") + user = factory.SubFactory(UserFactory) + content_object = factory.SubFactory(NewsFactory) + + @factory.lazy_attribute + def content_type(self): + return ContentType.objects.get_for_model(News) + + +class EventReactionFactory(DjangoModelFactory): + class Meta: + model = Reaction + + emoji = factory.Faker("emoji") + user = factory.SubFactory(UserFactory) + content_object = factory.SubFactory(EventFactory) + + @factory.lazy_attribute + def content_type(self): + return ContentType.objects.get_for_model(Event) diff --git a/app/emoji/migrations/0001_initial.py b/app/emoji/migrations/0001_initial.py new file mode 100644 index 000000000..da645d565 --- /dev/null +++ b/app/emoji/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.5 on 2023-11-18 16:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Reaction", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "reaction_id", + models.AutoField(editable=False, primary_key=True, serialize=False), + ), + ("emoji", models.CharField(max_length=60)), + ("object_id", models.PositiveIntegerField(blank=True, null=True)), + ( + "content_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reactions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Reaction", + "verbose_name_plural": "Reactions", + "unique_together": {("user", "object_id", "content_type")}, + }, + ), + ] diff --git a/app/emoji/migrations/__init__.py b/app/emoji/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/emoji/models/__init__.py b/app/emoji/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/emoji/models/reaction.py b/app/emoji/models/reaction.py new file mode 100644 index 000000000..6d5567f4e --- /dev/null +++ b/app/emoji/models/reaction.py @@ -0,0 +1,36 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + +from app.common.enums import Groups +from app.common.permissions import BasePermissionModel +from app.content.models.user import User +from app.util.models import BaseModel + + +class Reaction(BaseModel, BasePermissionModel): + write_access = (Groups.TIHLDE,) + read_access = (Groups.TIHLDE,) + + reaction_id = models.AutoField(primary_key=True, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="reactions") + emoji = models.CharField(max_length=60) + + content_type = models.ForeignKey( + ContentType, on_delete=models.CASCADE, null=True, blank=True + ) + object_id = models.PositiveIntegerField(null=True, blank=True) + content_object = GenericForeignKey("content_type", "object_id") + + class Meta: + unique_together = ("user", "object_id", "content_type") + verbose_name = "Reaction" + verbose_name_plural = "Reactions" + + def __str__(self): + return f"{self.user.first_name} - {self.emoji}" + + def has_object_write_permission(self, request): + return self.user == request.user and super().has_object_write_permission( + request + ) diff --git a/app/emoji/serializers/__init__.py b/app/emoji/serializers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/emoji/serializers/reaction.py b/app/emoji/serializers/reaction.py new file mode 100644 index 000000000..8fdd92dfb --- /dev/null +++ b/app/emoji/serializers/reaction.py @@ -0,0 +1,74 @@ +from django.contrib.contenttypes.models import ContentType +from django.db.utils import IntegrityError +from rest_framework import serializers + +from app.common.serializers import BaseModelSerializer +from app.content.models.event import Event +from app.content.models.news import News +from app.content.models.user import User +from app.emoji.enums import ContentTypes +from app.emoji.exception import ( + APIContentTypeNotSupportedException, + APIReactionDuplicateNotAllowedException, + APIReactionNotAllowedException, +) +from app.emoji.models.reaction import Reaction + + +class SimpleUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ("user_id", "first_name", "last_name", "image") + + +class ReactionSerializer(BaseModelSerializer): + user = SimpleUserSerializer(read_only=True) + + class Meta: + model = Reaction + fields = ("reaction_id", "user", "emoji") + + +class ReactionCreateSerializer(serializers.ModelSerializer): + content_type = serializers.CharField() + + class Meta: + model = Reaction + fields = ("reaction_id", "emoji", "content_type", "object_id") + + def create(self, validated_data, **kwargs): + user = self.context["request"].user + emoji = validated_data.pop("emoji") + object_id = validated_data.pop("object_id") + content_type = validated_data.pop("content_type") + content_type = ContentType.objects.get(model=content_type) + + object = None + if content_type.model.lower() == ContentTypes.NEWS: + object = News.objects.get(id=int(object_id)) + elif content_type.model.lower() == ContentTypes.EVENT: + object = Event.objects.get(id=int(object_id)) + + if not object: + raise APIContentTypeNotSupportedException() + if not object.emojis_allowed: + raise APIReactionNotAllowedException() + + try: + created_reaction = object.reactions.create( + user=user, + emoji=emoji, + ) + except IntegrityError: + raise APIReactionDuplicateNotAllowedException() + + return created_reaction + + +class ReactionUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = Reaction + fields = ("reaction_id", "emoji") + + def update(self, instance, validated_data): + return super().update(instance, validated_data) diff --git a/app/emoji/urls.py b/app/emoji/urls.py new file mode 100644 index 000000000..825abe620 --- /dev/null +++ b/app/emoji/urls.py @@ -0,0 +1,13 @@ +from django.conf.urls import include +from django.urls import path +from rest_framework import routers + +from app.emoji.views.reaction import ReactionViewSet + +router = routers.DefaultRouter() + +router.register("reactions", ReactionViewSet, basename="reactions") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/app/emoji/views/__init__.py b/app/emoji/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/emoji/views/reaction.py b/app/emoji/views/reaction.py new file mode 100644 index 000000000..518364df2 --- /dev/null +++ b/app/emoji/views/reaction.py @@ -0,0 +1,39 @@ +from rest_framework import status +from rest_framework.response import Response + +from app.common.pagination import BasePagination +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet +from app.emoji.models.reaction import Reaction +from app.emoji.serializers.reaction import ( + ReactionCreateSerializer, + ReactionSerializer, + ReactionUpdateSerializer, +) + + +class ReactionViewSet(BaseViewSet): + serializer_class = ReactionSerializer + permission_classes = [BasicViewPermission] + pagination_class = BasePagination + queryset = Reaction.objects.all() + + def create(self, request, *args, **kwargs): + data = request.data + serializer = ReactionCreateSerializer(data=data, context={"request": request}) + if serializer.is_valid(): + super().perform_create(serializer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def update(self, request, *args, **kwargs): + reaction = self.get_object() + serializer = ReactionUpdateSerializer( + reaction, data=request.data, context={"request": request} + ) + if serializer.is_valid(): + reaction = super().perform_update(serializer) + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, *args, **kwargs): + super().destroy(request, *args, **kwargs) + return Response({"detail": "Reaksjonen ble slettet"}, status=status.HTTP_200_OK) diff --git a/app/payment/admin.py b/app/payment/admin.py index 7e8d356e8..714568e5e 100644 --- a/app/payment/admin.py +++ b/app/payment/admin.py @@ -5,4 +5,10 @@ # Register your models here. admin.site.register(PaidEvent) -admin.site.register(Order) + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + search_fields = ("user__first_name", "user__last_name", "user__user_id", "order_id") + + list_filter = ("event",) diff --git a/app/payment/enums.py b/app/payment/enums.py index 21cf044db..c319490b6 100644 --- a/app/payment/enums.py +++ b/app/payment/enums.py @@ -3,7 +3,7 @@ class OrderStatus(TextChoices): INITIATE = "INITIATE" - RESERVE = "RESERVE" + RESERVED = "RESERVED" CAPTURE = "CAPTURE" REFUND = "REFUND" CANCEL = "CANCEL" 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..bc842fa0f 100644 --- a/app/payment/factories/order_factory.py +++ b/app/payment/factories/order_factory.py @@ -1,5 +1,4 @@ import random -from datetime import timedelta import factory from factory.django import DjangoModelFactory @@ -8,7 +7,6 @@ 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): @@ -18,4 +16,4 @@ class Meta: user = factory.SubFactory(UserFactory) event = factory.SubFactory(EventFactory) status = random.choice([e.value for e in OrderStatus]) - expire_date = now() + timedelta(hours=1) + payment_link = factory.Faker("url") diff --git a/app/payment/migrations/0002_remove_order_expire_date.py b/app/payment/migrations/0002_remove_order_expire_date.py new file mode 100644 index 000000000..3e7a176ca --- /dev/null +++ b/app/payment/migrations/0002_remove_order_expire_date.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.5 on 2023-10-18 09:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("payment", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="order", + name="expire_date", + ), + ] diff --git a/app/payment/migrations/0003_alter_order_status.py b/app/payment/migrations/0003_alter_order_status.py new file mode 100644 index 000000000..9b8a97f0e --- /dev/null +++ b/app/payment/migrations/0003_alter_order_status.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.5 on 2023-12-17 21:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payment", "0002_remove_order_expire_date"), + ] + + operations = [ + migrations.AlterField( + model_name="order", + name="status", + field=models.CharField( + choices=[ + ("INITIATE", "Initiate"), + ("RESERVED", "Reserved"), + ("CAPTURE", "Capture"), + ("REFUND", "Refund"), + ("CANCEL", "Cancel"), + ("SALE", "Sale"), + ("VOID", "Void"), + ], + default="INITIATE", + max_length=16, + ), + ), + ] diff --git a/app/payment/models/order.py b/app/payment/models/order.py index 31e9381d2..85dbef834 100644 --- a/app/payment/models/order.py +++ b/app/payment/models/order.py @@ -8,7 +8,6 @@ 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): @@ -25,16 +24,11 @@ class Order(BaseModel, BasePermissionModel): 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 + def __str__(self): + return f"{self.user} - {self.event.title} - {self.status} - {self.created_at}" diff --git a/app/payment/serializers/__init__.py b/app/payment/serializers/__init__.py index 8789a8df3..76e890ea0 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, + OrderCreateSerializer, + VippsOrderSerialzer, +) diff --git a/app/payment/serializers/order.py b/app/payment/serializers/order.py index 51fd13a05..9e4e9f80d 100644 --- a/app/payment/serializers/order.py +++ b/app/payment/serializers/order.py @@ -1,31 +1,44 @@ +import uuid + 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.content.util.event_utils import create_vipps_order 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") + fields = ("order_id", "status", "payment_link") + +class VippsOrderSerialzer(BaseModelSerializer): + class Meta: + model = Order + fields = ("order_id",) -class OrderUpdateCreateSerializer(BaseModelSerializer): - user = DefaultUserSerializer(read_only=True) +class OrderCreateSerializer(BaseModelSerializer): 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 - ) + fields = ("event",) + + def create(self, validated_data): + user = validated_data.pop("user") + event = validated_data.pop("event") + + order_id = uuid.uuid4() + payment_url = create_vipps_order( + order_id=order_id, + event=event, + transaction_text=f"Betaling for {event.title} - {user.first_name} {user.last_name}", + fallback=f"/arrangementer/{event.id}", + ) + + order = Order.objects.create( + order_id=order_id, + payment_link=payment_url, + event=event, + user=user, + ) + + return order diff --git a/app/payment/tasks.py b/app/payment/tasks.py index 671ebf701..26e13fab9 100644 --- a/app/payment/tasks.py +++ b/app/payment/tasks.py @@ -1,25 +1,24 @@ -from sentry_sdk import capture_exception - from app.celery import app +from app.content.models.event import Event 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.payment.util.order_utils import has_paid_order 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() +def check_if_has_paid(self, event_id, registration_id): + registration = Registration.objects.filter(registration_id=registration_id).first() + event = Event.objects.filter(id=event_id).first() + + if not registration or not event: + return + + user_orders = Order.objects.filter(event=event, user=registration.user) + + if not user_orders: + registration.delete() + return - except Order.DoesNotExist as order_not_exist: - capture_exception(order_not_exist) + if not has_paid_order(user_orders): + registration.delete() diff --git a/app/payment/tests/test_order_model.py b/app/payment/tests/test_order_model.py deleted file mode 100644 index 12c4d6b83..000000000 --- a/app/payment/tests/test_order_model.py +++ /dev/null @@ -1,27 +0,0 @@ -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/tests/test_payment_task.py b/app/payment/tests/test_payment_task.py new file mode 100644 index 000000000..a62104c8a --- /dev/null +++ b/app/payment/tests/test_payment_task.py @@ -0,0 +1,182 @@ +from django.utils import timezone + +import pytest + +from app.content.factories import EventFactory, RegistrationFactory +from app.content.models import Registration +from app.payment.enums import OrderStatus +from app.payment.factories import OrderFactory +from app.payment.tasks import check_if_has_paid +from app.payment.util.order_utils import check_if_order_is_paid, is_expired + + +@pytest.fixture() +def event(): + return EventFactory() + + +@pytest.fixture() +def registration(event): + return RegistrationFactory(event=event) + + +@pytest.mark.django_db +def test_delete_registration_if_no_orders(event, registration): + """Should delete registration if user has no orders.""" + + check_if_has_paid(event.id, registration.registration_id) + + registration = Registration.objects.filter( + registration_id=registration.registration_id + ).first() + + assert not registration + + +@pytest.mark.django_db +def test_delete_registration_if_no_paid_orders(event, registration): + """Should delete registration if user has no paid orders.""" + + first_order = OrderFactory(event=event, user=registration.user) + second_order = OrderFactory(event=event, user=registration.user) + third_order = OrderFactory(event=event, user=registration.user) + fourth_order = OrderFactory(event=event, user=registration.user) + + first_order.status = OrderStatus.VOID + first_order.save() + second_order.status = OrderStatus.INITIATE + second_order.save() + third_order.status = OrderStatus.CANCEL + third_order.save() + fourth_order.status = OrderStatus.REFUND + fourth_order.save() + + check_if_has_paid(event.id, registration.registration_id) + + registration = Registration.objects.filter( + registration_id=registration.registration_id + ).first() + + assert not registration + + +@pytest.mark.django_db +def test_keep_registration_if_has_paid_order(event, registration): + """Should not delete registration if user has paid order.""" + + first_order = OrderFactory(event=event, user=registration.user) + second_order = OrderFactory(event=event, user=registration.user) + third_order = OrderFactory(event=event, user=registration.user) + + first_order.status = OrderStatus.SALE + first_order.save() + second_order.status = OrderStatus.CANCEL + second_order.save() + third_order.status = OrderStatus.CANCEL + third_order.save() + + check_if_has_paid(event.id, registration.registration_id) + + registration.refresh_from_db() + + assert registration + + +@pytest.mark.django_db +def test_keep_registration_if_has_reserved_order(event, registration): + """Should not delete registration if user has reserved order.""" + + first_order = OrderFactory(event=event, user=registration.user) + second_order = OrderFactory(event=event, user=registration.user) + third_order = OrderFactory(event=event, user=registration.user) + + first_order.status = OrderStatus.RESERVED + first_order.save() + second_order.status = OrderStatus.CANCEL + second_order.save() + third_order.status = OrderStatus.CANCEL + third_order.save() + + check_if_has_paid(event.id, registration.registration_id) + + registration.refresh_from_db() + + assert registration + + +@pytest.mark.django_db +def test_keep_registration_if_has_captured_order(event, registration): + """Should not delete registration if user has captured order.""" + + first_order = OrderFactory(event=event, user=registration.user) + second_order = OrderFactory(event=event, user=registration.user) + third_order = OrderFactory(event=event, user=registration.user) + + first_order.status = OrderStatus.CAPTURE + first_order.save() + second_order.status = OrderStatus.CANCEL + second_order.save() + third_order.status = OrderStatus.CANCEL + third_order.save() + + check_if_has_paid(event.id, registration.registration_id) + + registration.refresh_from_db() + + assert registration + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "order_status", [OrderStatus.CAPTURE, OrderStatus.SALE, OrderStatus.RESERVED] +) +def test_if_order_is_paid(order_status): + """Should return true if order is paid.""" + + order = OrderFactory() + + order.status = order_status + order.save() + + assert check_if_order_is_paid(order) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "order_status", + [OrderStatus.INITIATE, OrderStatus.VOID, OrderStatus.CANCEL, OrderStatus.REFUND], +) +def test_if_order_is_not_paid(order_status): + """Should return false if order is not paid.""" + + order = OrderFactory() + + order.status = order_status + order.save() + + assert not check_if_order_is_paid(order) + + +@pytest.mark.django_db +def test_if_registration_payment_date_is_expired(registration): + """Should return true if registration payment date is expired.""" + + registration.payment_expiredate = timezone.now() - timezone.timedelta(seconds=1) + registration.save() + + assert is_expired(registration.payment_expiredate) + + registration.payment_expiredate = timezone.now() + registration.save() + + assert is_expired(registration.payment_expiredate) + + +@pytest.mark.django_db +def test_if_registration_payment_date_is_not_expired(registration): + """Should return false if registration payment date is not expired.""" + + registration.payment_expiredate = timezone.now() + timezone.timedelta(seconds=1) + registration.save() + + assert not is_expired(registration.payment_expiredate) diff --git a/app/payment/urls.py b/app/payment/urls.py index c7e2854a6..519b2be49 100644 --- a/app/payment/urls.py +++ b/app/payment/urls.py @@ -1,14 +1,14 @@ -from django.urls import include, path, re_path +from django.urls import include, re_path from rest_framework import routers from app.payment.views.order import OrderViewSet -from app.payment.views.vipps_callback import vipps_callback +from app.payment.views.vipps import VippsViewSet router = routers.DefaultRouter() -router.register("payment", OrderViewSet, basename="payment") +router.register("payments", OrderViewSet, basename="payment") +router.register( + r"v2/payments/(?P[0-9a-f-]+)", VippsViewSet, basename="payment" +) -urlpatterns = [ - re_path(r"", include(router.urls)), - path("v2/payment/", vipps_callback), -] +urlpatterns = [re_path(r"", include(router.urls))] diff --git a/app/payment/util/order_utils.py b/app/payment/util/order_utils.py new file mode 100644 index 000000000..8aa4c4b16 --- /dev/null +++ b/app/payment/util/order_utils.py @@ -0,0 +1,29 @@ +from django.utils import timezone + +from app.payment.enums import OrderStatus + + +def has_paid_order(orders): + if not orders: + return False + + for order in orders: + if check_if_order_is_paid(order): + return True + + return False + + +def check_if_order_is_paid(order): + if order and ( + order.status == OrderStatus.CAPTURE + or order.status == OrderStatus.RESERVED + or order.status == OrderStatus.SALE + ): + return True + + return False + + +def is_expired(expire_date): + return expire_date <= timezone.now() diff --git a/app/payment/util/payment_utils.py b/app/payment/util/payment_utils.py index 96bd5ce1f..e7e2f6118 100644 --- a/app/payment/util/payment_utils.py +++ b/app/payment/util/payment_utils.py @@ -1,4 +1,6 @@ import json +import os +from datetime import datetime from django.conf import settings @@ -27,7 +29,45 @@ def get_new_access_token(): return (response["expires_on"], response["access_token"]) -def initiate_payment(amount, order_id, event_name, access_token): +def check_access_token(): + """ + Checks for access token. + Updates acces token if expired. + Returns new access token. + """ + 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)}) + + return access_token + + +def get_payment_order_status(order_id): + """ + Returns status of payment order. + """ + + access_token = check_access_token() + + 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() + + return json["transactionLogHistory"][0]["operation"] + + +def initiate_payment(amount, order_id, access_token, transaction_text, fallback): """ Initiate a payment with Vipps amount: Amount to pay in Øre (100 NOK = 10000) @@ -37,14 +77,15 @@ def initiate_payment(amount, order_id, event_name, access_token): { "merchantInfo": { "callbackPrefix": settings.VIPPS_CALLBACK_PREFIX, - "fallBack": settings.VIPPS_FALLBACK, + "fallBack": f"{settings.VIPPS_FALLBACK}{fallback}", "merchantSerialNumber": settings.VIPPS_MERCHANT_SERIAL_NUMBER, }, "transaction": { "amount": amount, - "transactionText": "This payment is for the event:" + event_name, + "transactionText": transaction_text, "orderId": order_id, "skipLandingPage": False, + "scope": "name phoneNumber", }, } ) @@ -61,3 +102,37 @@ def initiate_payment(amount, order_id, event_name, access_token): raise Exception("Could not initiate payment") return response.json() + + +def refund_payment(amount, order_id, access_token, transaction_text): + """ + Refund a payment from Vipps + amount: Amount to pay in Øre (100 NOK = 10000) + """ + + url = f"{settings.VIPPS_ORDER_URL}/{order_id}/refund/" + + payload = json.dumps( + { + "merchantInfo": { + "merchantSerialNumber": settings.VIPPS_MERCHANT_SERIAL_NUMBER, + }, + "transaction": { + "amount": amount, + "transactionText": transaction_text, + }, + } + ) + + 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, + "X-Request-Id": order_id, + } + + response = requests.post(url, headers=headers, data=payload) + + if response.status_code != 200: + raise Exception("Could not refund payment") diff --git a/app/payment/views/order.py b/app/payment/views/order.py index 49ddb2859..822a4c978 100644 --- a/app/payment/views/order.py +++ b/app/payment/views/order.py @@ -4,12 +4,16 @@ from sentry_sdk import capture_exception from app.common.mixins import ActionMixin +from app.common.permissions import BasicViewPermission from app.common.viewsets import BaseViewSet +from app.content.models import Registration, User from app.payment.models import Order -from app.payment.serializers import OrderSerializer +from app.payment.serializers import OrderCreateSerializer, OrderSerializer +from app.payment.util.order_utils import is_expired class OrderViewSet(BaseViewSet, ActionMixin): + permission_classes = [BasicViewPermission] serializer_class = OrderSerializer queryset = Order.objects.all() @@ -17,9 +21,9 @@ 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] + orders = Order.objects.filter(user=user, event=event) serializer = OrderSerializer( - order, context={"request": request}, many=False + orders, context={"request": request}, many=True ) return Response(serializer.data, status.HTTP_200_OK) except Order.DoesNotExist as order_not_exist: @@ -28,3 +32,37 @@ def retrieve(self, request, pk): {"detail": "Fant ikke beatlingsordre."}, status=status.HTTP_404_NOT_FOUND, ) + + def create(self, request, *args, **kwargs): + try: + user = request.user + event = request.data.get("event") + registration = Registration.objects.get(user=user, event=event) + + if is_expired(registration.payment_expiredate): + return Response( + {"detail": "Din betalingstid er utgått"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = OrderCreateSerializer( + data=request.data, + context={"request": request}, + ) + + if serializer.is_valid(): + order = super().perform_create(serializer, user=user) + serializer = OrderSerializer( + order, context={"request": request}, many=False + ) + + return Response(serializer.data, status.HTTP_201_CREATED) + + return Response(serializer.errors, status.HTTP_400_BAD_REQUEST) + + except User.DoesNotExist as user_not_exist: + capture_exception(user_not_exist) + return Response( + {"detail": "Fant ikke bruker."}, + status=status.HTTP_404_NOT_FOUND, + ) diff --git a/app/payment/views/vipps.py b/app/payment/views/vipps.py new file mode 100644 index 000000000..0cac12686 --- /dev/null +++ b/app/payment/views/vipps.py @@ -0,0 +1,42 @@ +from django.conf import settings +from rest_framework import status +from rest_framework.response import Response + +from sentry_sdk import capture_exception + +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet +from app.payment.models import Order +from app.payment.serializers import VippsOrderSerialzer + + +class VippsViewSet(BaseViewSet): + permission_classes = [BasicViewPermission] + serializer_class = VippsOrderSerialzer + queryset = Order.objects.all() + + def create(self, request, order_id): + try: + order = Order.objects.get(order_id=order_id) + data = request.data + + MSN = data.get("merchantSerialNumber") + if int(MSN) != int(settings.VIPPS_MERCHANT_SERIAL_NUMBER): + return Response( + {"detail": "Merchant serial number matcher ikke"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + transaction_info = data.get("transactionInfo") + if transaction_info: + new_status = transaction_info["status"] + order.status = new_status + order.save() + + return Response(status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"detail": "Kunne ikke oppdatere ordre"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/app/payment/views/vipps_callback.py b/app/payment/views/vipps_callback.py index 63b95c607..754564d79 100644 --- a/app/payment/views/vipps_callback.py +++ b/app/payment/views/vipps_callback.py @@ -2,29 +2,11 @@ import requests -from app.payment.models.order import Order from app.payment.util.payment_utils import get_new_access_token -def vipps_callback(_request, order_id): - 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): + """Force payment for an order.""" access_token = get_new_access_token()[1] url = f"{settings.VIPPS_FORCE_PAYMENT_URL}{order_id}/approve" headers = { diff --git a/app/settings.py b/app/settings.py index 3998a2877..49137f7ba 100644 --- a/app/settings.py +++ b/app/settings.py @@ -99,6 +99,7 @@ "app.gallery", "app.badge", "app.payment", + "app.emoji", ] # Django rest framework diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 7528ffdc3..38c7ee57e 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -24,10 +24,15 @@ UserFactory, ) from app.content.factories.toddel_factory import ToddelFactory +from app.emoji.factories.reaction_factory import ( + EventReactionFactory, + NewsReactionFactory, +) from app.forms.tests.form_factories import FormFactory, SubmissionFactory 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.order_factory import OrderFactory from app.payment.factories.paid_event_factory import PaidEventFactory from app.util.test_utils import add_user_to_group_with_name, get_api_client @@ -112,6 +117,12 @@ def jubkom_member(member): return member +@pytest.fixture() +def plask_member(member): + add_user_to_group_with_name(member, Groups.PLASK) + return member + + @pytest.fixture() def member_client(member): return get_api_client(user=member) @@ -132,6 +143,11 @@ def group(): return GroupFactory() +@pytest.fixture() +def order(): + return OrderFactory() + + @pytest.fixture() def membership(): return MembershipFactory(membership_type=MembershipType.MEMBER) @@ -227,6 +243,16 @@ def toddel(): return ToddelFactory() +@pytest.fixture() +def news_reaction(member, news): + return NewsReactionFactory(user=member, content_object=news) + + +@pytest.fixture() +def event_reaction(member, event): + return EventReactionFactory(user=member, content_object=event) + + @pytest.fixture() def priority_group(): return GroupFactory(name="Prioritized group", slug="prioritized_group") diff --git a/app/tests/content/test_event_integration.py b/app/tests/content/test_event_integration.py index a8b793f9c..66da8c273 100644 --- a/app/tests/content/test_event_integration.py +++ b/app/tests/content/test_event_integration.py @@ -50,6 +50,33 @@ def get_event_data( return data +def get_paid_event_data( + price, + paytime, + title="New Title", + location="New Location", + organizer=None, + contact_person=None, + limit=0, +): + 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}, + "limit": limit, + } + if organizer: + data["organizer"] = organizer + if contact_person: + data["contact_person"] = contact_person + return data + + # "event_current_organizer"/"event_new_organizer" should have one of 3 different values: # - None -> The event has no connected organizer/should remove connection to organizer # - "same" -> The event is connected to/should be connected to same organizer as user is member of @@ -171,18 +198,31 @@ def permission_test_util( @pytest.mark.django_db def test_list_as_anonymous_user(default_client, event): - """An anonymous user should be able to list all events that are not activities.""" + """An anonymous user should be able to list all events. Activities should included.""" category = Category.objects.create(text="Aktivitet") - activity = EventFactory(category=category) - - activity.category = category - activity.save() + EventFactory(category=category) event.category = None event.save() response = default_client.get(API_EVENTS_BASE_URL) + assert response.status_code == 200 + assert response.json().get("count") == 2 + + +@pytest.mark.django_db +def test_list_events_as_anonymous_user(default_client, event): + """An anonymous user should be able to list all events. Activities should not be included.""" + + category = Category.objects.create(text="Aktivitet") + EventFactory(category=category) + + event.category = None + event.save() + + response = default_client.get(f"{API_EVENTS_BASE_URL}?activity=false") + assert response.status_code == 200 assert response.json().get("count") == 1 @@ -192,10 +232,7 @@ def test_list_activities_as_anonymous_user(default_client, event): """An anonymous user should be able to list all activities.""" category = Category.objects.create(text="Aktivitet") - activity = EventFactory(category=category) - - activity.category = category - activity.save() + EventFactory(category=category) event.category = None event.save() @@ -206,6 +243,40 @@ def test_list_activities_as_anonymous_user(default_client, event): assert response.json().get("count") == 1 +@pytest.mark.django_db +def test_list_expired_events_as_anonymous_user(default_client, event): + """ + An anonymous user should be able to list all expired events. + """ + + two_days_ago = now() - timedelta(days=1) + event.end_date = two_days_ago + event.save() + + response = default_client.get(f"{API_EVENTS_BASE_URL}?expired=true") + + assert response.status_code == 200 + assert response.json().get("count") == 1 + + +@pytest.mark.django_db +def test_list_expired_activities_as_anonymous_user(default_client, event): + """ + An anonymous user should be able to list all expired activities. + """ + + two_days_ago = now() - timedelta(days=1) + category = Category.objects.create(text="Aktivitet") + event.end_date = two_days_ago + event.category = category + event.save() + + response = default_client.get(f"{API_EVENTS_BASE_URL}?expired=true") + + assert response.status_code == 200 + assert response.json().get("count") == 1 + + @pytest.mark.django_db def test_retrieve_as_anonymous_user(default_client, event): """An anonymous user should be able to retrieve an event.""" @@ -790,10 +861,114 @@ def test_expired_filter_list(api_client, admin_user, expired, expected_count): @pytest.mark.django_db -def test_jubkom_has_create_permission(api_client, jubkom_member): +def test_jubkom_has_not_create_permission(api_client, jubkom_member): + """ + A jubkom member should not be able to create an event. + """ + client = api_client(user=jubkom_member) organizer = Group.objects.get(name=Groups.JUBKOM).slug data = get_event_data(organizer=organizer) response = client.post(API_EVENTS_BASE_URL, data) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_update_from_free_event_with_participants_to_paid_event( + api_client, admin_user, event, registration +): + """ + An admin should not be able to update a free event with participants to a paid event. + """ + + registration.event = event + registration.is_on_wait = False + registration.save() + + url = f"{API_EVENTS_BASE_URL}{event.id}/" + client = api_client(user=admin_user) + data = get_paid_event_data(price=200, paytime="01:00", limit=1) + + response = client.put(url, data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_update_from_paid_event_with_participants_to_free_event( + api_client, admin_user, event, paid_event, registration +): + """ + An admin should not be able to update a paid event with participants to a free event. + """ + paid_event.event = event + paid_event.save() + + registration.event = event + registration.is_on_wait = False + registration.save() + + url = f"{API_EVENTS_BASE_URL}{event.id}/" + client = api_client(user=admin_user) + data = get_event_data(limit=1) + + response = client.put(url, data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_update_from_paid_event_to_free_event( + api_client, admin_user, event, paid_event +): + """ + An admin should be able to update a paid event with no participants to a free event. + """ + paid_event.event = event + paid_event.save() + + url = f"{API_EVENTS_BASE_URL}{event.id}/" + client = api_client(user=admin_user) + data = get_event_data(limit=0) + + response = client.put(url, data) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_update_from_free_event_to_paid_event(api_client, admin_user, event): + """ + An admin should be able to update a free event with no participants to a paid event. + """ + url = f"{API_EVENTS_BASE_URL}{event.id}/" + client = api_client(user=admin_user) + data = get_paid_event_data(price=200, paytime="01:00", limit=1) + + response = client.put(url, data) + + data = response.json() + + assert response.status_code == status.HTTP_200_OK + assert data["is_paid_event"] + assert data["paid_information"]["price"] == "200.00" + assert data["paid_information"]["paytime"] == "01:00:00" + + +@pytest.mark.django_db +def test_create_paid_event(api_client, admin_user): + """ + An admin should be able to create a paid event. + """ + client = api_client(user=admin_user) + data = get_paid_event_data(price=200, paytime="01:00", limit=1) + + response = client.post(API_EVENTS_BASE_URL, data) + + data = response.json() + assert response.status_code == status.HTTP_201_CREATED + assert data["is_paid_event"] + assert data["paid_information"]["price"] == "200.00" + assert data["paid_information"]["paytime"] == "01:00:00" diff --git a/app/tests/content/test_registration_integration.py b/app/tests/content/test_registration_integration.py index 7f52f25da..772003d0d 100644 --- a/app/tests/content/test_registration_integration.py +++ b/app/tests/content/test_registration_integration.py @@ -885,16 +885,15 @@ def test_add_registration_to_event_as_admin_group_member(event, member, organize AdminGroup.INDEX, ], ) -def test_add_registration_to_event_as_admin_group_member_when_event_closed( +def test_add_existing_user_registration_to_event_as_admin_group_member( event, member, organizer_name ): """ - A member of NOK, Promo, Sosialen or KOK should be able to add a - registration to an event manually even though the event is closed. + A member of NOK, Promo, Sosialen or KOK should not be able to add a registration + to an event manually if the user is already registered. """ - event.closed = True - event.save() + RegistrationFactory(event=event, user=member) data = {"user": member.user_id, "event": event.id} url = f"{_get_registration_url(event=event)}add/" @@ -903,7 +902,7 @@ def test_add_registration_to_event_as_admin_group_member_when_event_closed( response = client.post(url, data) - assert response.status_code == status.HTTP_201_CREATED + assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.django_db @@ -917,15 +916,15 @@ def test_add_registration_to_event_as_admin_group_member_when_event_closed( AdminGroup.INDEX, ], ) -def test_add_registration_to_event_as_admin_group_member_before_registration_open( +def test_add_registration_to_event_as_admin_group_member_when_event_closed( event, member, organizer_name ): """ A member of NOK, Promo, Sosialen or KOK should be able to add a - registration to an event manually even though the registration has not opened. + registration to an event manually even though the event is closed. """ - event.start_registration_at = now() + timedelta(days=1) + event.closed = True event.save() data = {"user": member.user_id, "event": event.id} @@ -949,15 +948,15 @@ def test_add_registration_to_event_as_admin_group_member_before_registration_ope AdminGroup.INDEX, ], ) -def test_add_registration_to_event_as_admin_group_member_after_registration_closed( +def test_add_registration_to_event_as_admin_group_member_before_registration_open( event, member, organizer_name ): """ A member of NOK, Promo, Sosialen or KOK should be able to add a - registration to an event manually even though the registration has closed. + registration to an event manually even though the registration has not opened. """ - event.end_registration_at = now() - timedelta(days=1) + event.start_registration_at = now() + timedelta(days=1) event.save() data = {"user": member.user_id, "event": event.id} @@ -981,14 +980,19 @@ def test_add_registration_to_event_as_admin_group_member_after_registration_clos AdminGroup.INDEX, ], ) -def test_add_registration_to_paid_event(paid_event, member, organizer_name): +def test_add_registration_to_event_as_admin_group_member_after_registration_closed( + event, member, organizer_name +): """ A member of NOK, Promo, Sosialen or KOK should be able to add a - registration to a paid event manually. A order with status "SALE" should be created. + registration to an event manually even though the registration has closed. """ - data = {"user": member.user_id, "event": paid_event.event.id} - url = f"{_get_registration_url(event=paid_event.event)}add/" + event.end_registration_at = now() - timedelta(days=1) + event.save() + + data = {"user": member.user_id, "event": event.id} + url = f"{_get_registration_url(event=event)}add/" client = get_api_client(user=member, group_name=organizer_name) diff --git a/app/tests/content/test_user_integration.py b/app/tests/content/test_user_integration.py index 27930833c..139f3bd9a 100644 --- a/app/tests/content/test_user_integration.py +++ b/app/tests/content/test_user_integration.py @@ -1,9 +1,13 @@ +from datetime import timedelta + +from django.utils import timezone from django.utils.text import slugify from rest_framework import status import pytest from app.common.enums import AdminGroup, GroupType +from app.content.factories.event_factory import EventFactory from app.content.factories.registration_factory import RegistrationFactory from app.content.factories.strike_factory import StrikeFactory from app.content.factories.user_factory import UserFactory @@ -32,6 +36,10 @@ def _get_user_detail_url(user): return f"{API_USER_BASE_URL}{user.user_id}/" +def _get_user_events_url(): + return f"{API_USER_BASE_URL}me/events/" + + def _get_user_post_data(): return { "email": "ola@nordmann.org", @@ -469,3 +477,93 @@ def test_destroy_other_user_as_index_user(member, user, api_client): response = client.delete(url) assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_list_expired_user_events(member, api_client): + """ "All the events listed as expired should be expired""" + client = api_client(user=member) + + two_days_ago = timezone.now() - timedelta(days=2) + event = EventFactory(end_date=two_days_ago) + + registration = RegistrationFactory(user=member, event=event) + + url = _get_user_events_url() + + query_params = {"expired": "true"} + + response = client.get(url, data=query_params) + + assert response.status_code == status.HTTP_200_OK + + registrations = response.json().get("results") + for registration in registrations: + assert registration.get("expired") + + +@pytest.mark.django_db +def test_list_unexpired_user_events(member, api_client): + """All the events listed as unexpired should be unexpired""" + client = api_client(user=member) + + two_days_ago = timezone.now() - timedelta(days=2) + event = EventFactory(end_date=two_days_ago) + + registration = RegistrationFactory(user=member, event=event) + + url = _get_user_events_url() + + query_params = {"expired": "false"} + + response = client.get(url, data=query_params) + + assert response.status_code == status.HTTP_200_OK + + registrations = response.json().get("results") + for registration in registrations: + assert not registration.get("expired") + + +@pytest.mark.django_db +def test_list_expired_user_events_with_a_blank_query_params(member, api_client): + """All the events listed should be unexpired when returning a blank query params""" + client = api_client(user=member) + + two_days_ago = timezone.now() - timedelta(days=2) + event = EventFactory(end_date=two_days_ago) + + registration = RegistrationFactory(user=member, event=event) + + url = _get_user_events_url() + + query_params = {"expired": ""} + + response = client.get(url, data=query_params) + + assert response.status_code == status.HTTP_200_OK + + registrations = response.json().get("results") + for registration in registrations: + assert not registration.get("expired") + + +@pytest.mark.django_db +def test_list_expired_user_events_with_no_query_params(member, api_client): + """All the events listed should be unexpired with no query params""" + client = api_client(user=member) + + two_days_ago = timezone.now() - timedelta(days=2) + event = EventFactory(end_date=two_days_ago) + + registration = RegistrationFactory(user=member, event=event) + + url = _get_user_events_url() + + response = client.get(url) + + assert response.status_code == status.HTTP_200_OK + + registrations = response.json().get("results") + for registration in registrations: + assert not registration.get("expired") diff --git a/app/tests/emoji/__init__.py b/app/tests/emoji/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/tests/emoji/test_reaction_integration.py b/app/tests/emoji/test_reaction_integration.py new file mode 100644 index 000000000..0a1cb07e0 --- /dev/null +++ b/app/tests/emoji/test_reaction_integration.py @@ -0,0 +1,211 @@ +from rest_framework import status + +import pytest + +from app.common.enums import Groups +from app.content.factories.event_factory import EventFactory +from app.content.factories.news_factory import NewsFactory +from app.content.factories.user_factory import UserFactory +from app.emoji.enums import ContentTypes +from app.emoji.factories.reaction_factory import ( + EventReactionFactory, + NewsReactionFactory, +) +from app.emoji.models.reaction import Reaction +from app.tests.conftest import add_user_to_group_with_name +from app.util.test_utils import get_api_client + +API_REACTION_BASE_URL = "/emojis/reactions/" + + +def _get_reactions_url(): + return API_REACTION_BASE_URL + + +def _get_reactions_detailed_url(reaction): + return f"{API_REACTION_BASE_URL}{reaction.reaction_id}/" + + +def _get_reactions_post_data(content_type, content_id): + return { + "emoji": ":smiley:", + "content_type": content_type, + "object_id": content_id, + } + + +def _get_reactions_put_data(): + return { + "emoji": ":New_Smiley:", + } + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("content_type", "object_factory"), + [ + (ContentTypes.NEWS, NewsFactory), + (ContentTypes.EVENT, EventFactory), + ], +) +def test_create_reaction_as_member(member, content_type, object_factory): + """A member should be able to create a reaction on a supported content type""" + object_id = object_factory().id + url = _get_reactions_url() + client = get_api_client(user=member) + data = _get_reactions_post_data(content_type, object_id) + response = client.post(url, data) + + assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("content_type", "object_factory"), + [ + (ContentTypes.NEWS, NewsFactory), + (ContentTypes.EVENT, EventFactory), + ], +) +def test_create_reaction_as_anonymous_user( + default_client, content_type, object_factory +): + """An anonymous user should not be able to create a reaction on supported content type""" + object_id = object_factory().id + url = _get_reactions_url() + + data = _get_reactions_post_data(content_type, object_id) + response = default_client.post(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("reaction"), + [ + NewsReactionFactory, + EventReactionFactory, + ], +) +def test_update_reaction_as_anonymous_user(default_client, reaction): + """An anonymous user should not be able to update a reaction on a supported content type""" + reaction = reaction() + url = _get_reactions_detailed_url(reaction) + + data = _get_reactions_put_data() + response = default_client.put(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + reaction.refresh_from_db() + assert reaction.emoji != data["emoji"] + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("reaction"), + [ + NewsReactionFactory, + EventReactionFactory, + ], +) +def test_update_own_reaction_as_member(reaction, member): + """A member should be able to do change their own reaction on a supported content type""" + reaction = reaction(user=member) + url = _get_reactions_detailed_url(reaction) + client = get_api_client(user=member) + data = _get_reactions_put_data() + response = client.put(url, data) + + assert response.status_code == status.HTTP_200_OK + reaction.refresh_from_db() + assert reaction.emoji == data["emoji"] + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("reaction"), + [ + NewsReactionFactory, + EventReactionFactory, + ], +) +def test_update_not_own_reaction_as_member(reaction, member): + """A member should not be able to update another user's reaction on a supported content type""" + reaction = reaction(user=member) + member = UserFactory() + add_user_to_group_with_name(member, Groups.TIHLDE) + + url = _get_reactions_detailed_url(reaction) + client = get_api_client(user=member) + data = _get_reactions_put_data() + response = client.put(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + reaction.refresh_from_db() + assert reaction.emoji != data["emoji"] + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("content_type", "object_factory", "reaction"), + [ + (ContentTypes.NEWS, NewsFactory, NewsReactionFactory), + (ContentTypes.EVENT, EventFactory, EventReactionFactory), + ], +) +def test_create_reaction_twice_as_member( + reaction, member, content_type, object_factory +): + """A member should not be able to create more than one reaction on the same supported content type""" + object_factory() + reaction = reaction(user=member) + url = _get_reactions_url() + client = get_api_client(user=reaction.user) + data = _get_reactions_post_data(content_type, reaction.object_id) + data["emoji"] = ":SecondSmiley:" + response = client.post(url, data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["detail"] == "Du har allerede reagert her" + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("reaction"), + [ + NewsReactionFactory, + EventReactionFactory, + ], +) +def test_destroy_own_reaction_as_member(reaction, member): + """A member should be able to delete their own reaction on a supported content type""" + reaction = reaction(user=member) + url = _get_reactions_detailed_url(reaction) + client = get_api_client(user=reaction.user) + response = client.delete(url) + + assert response.status_code == status.HTTP_200_OK + assert Reaction.objects.filter(user=reaction.user).count() == 0 + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("reaction"), + [ + NewsReactionFactory, + EventReactionFactory, + ], +) +def test_destroy_not_own_reaction_as_member(reaction, member): + """A member should not be able to delete another user's reaction on a supported content type""" + reaction = reaction(user=member) + member = UserFactory() + add_user_to_group_with_name(member, Groups.TIHLDE) + + url = _get_reactions_detailed_url(reaction) + client = get_api_client(user=member) + response = client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert Reaction.objects.filter(user=reaction.user).count() == 1 diff --git a/app/tests/payment/test_order_integration.py b/app/tests/payment/test_order_integration.py deleted file mode 100644 index 55d2413fc..000000000 --- a/app/tests/payment/test_order_integration.py +++ /dev/null @@ -1,21 +0,0 @@ -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 index a759cdfa5..b6f629688 100644 --- a/app/tests/payment/test_paid_event_integration.py +++ b/app/tests/payment/test_paid_event_integration.py @@ -136,9 +136,11 @@ def test_update_paid_event_as_admin(admin_user): @pytest.mark.django_db -def test_update_paid_event_to_free_event_as_admin(admin_user): +def test_update_paid_event_to_free_event_with_registrations_as_admin( + admin_user, registration +): """ - HS and Index members should not be able to update a paid event to a free event. + HS and Index members should not be able to update a paid event with registrations to a free event. 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. @@ -146,6 +148,10 @@ def test_update_paid_event_to_free_event_as_admin(admin_user): paid_event = PaidEventFactory(price=100.00) event = paid_event.event + + registration.event = event + registration.save() + 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) @@ -156,3 +162,27 @@ def test_update_paid_event_to_free_event_as_admin(admin_user): assert response.status_code == 400 assert event.is_paid_event + + +@pytest.mark.django_db +def test_update_paid_event_to_free_event_without_registrations_as_admin(admin_user): + """ + HS and Index members should be able to update a paid event without registrations to a free event. + 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. + """ + + paid_event = PaidEventFactory(price=100.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, is_paid_event=False) + + response = client.put(url, data) + event.refresh_from_db() + + assert response.status_code == 200 + assert not event.is_paid_event diff --git a/app/tests/payment/test_vipps_callback.py b/app/tests/payment/test_vipps_callback.py index 5b9f4e544..8a4201fa9 100644 --- a/app/tests/payment/test_vipps_callback.py +++ b/app/tests/payment/test_vipps_callback.py @@ -1,49 +1,45 @@ -API_EVENT_BASE_URL = "/events/" +from django.conf import settings +import pytest -def _get_registration_url(event): - return f"{API_EVENT_BASE_URL}{event.pk}/registrations/" +from app.payment.enums import OrderStatus +from app.payment.factories import OrderFactory -def _get_registration_post_data(user, event): +def get_callback_data(order_id, status): return { - "user_id": user.user_id, - "event": event.pk, + "merchantSerialNumber": settings.VIPPS_MERCHANT_SERIAL_NUMBER, + "orderId": order_id, + "transactionInfo": { + "amount": 20000, + "status": status, + "timeStamp": "2018-12-12T11:18:38.246Z", + "transactionId": "5001420062", + }, } -# 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) +@pytest.mark.django_db +@pytest.mark.parametrize( + "status", + [ + OrderStatus.RESERVED, + OrderStatus.CAPTURE, + OrderStatus.REFUND, + OrderStatus.CANCEL, + OrderStatus.SALE, + OrderStatus.VOID, + ], +) +def test_update_order_status_by_vipps_callback(default_client, status): + """Should update order status.""" + + order = OrderFactory(status=OrderStatus.INITIATE) + order_id = order.order_id + + data = get_callback_data(order_id, status) + response = default_client.post(f"/v2/payments/{order_id}/", data=data) + order.refresh_from_db() + + assert response.status_code == 200 + assert order.status == status diff --git a/app/urls.py b/app/urls.py index 2b540d666..9dd10e08b 100644 --- a/app/urls.py +++ b/app/urls.py @@ -31,4 +31,5 @@ path("forms/", include("app.forms.urls")), path("galleries/", include("app.gallery.urls")), path("badges/", include("app.badge.urls")), + path("emojis/", include("app.emoji.urls")), ] diff --git a/app/util/exceptions.py b/app/util/exceptions.py index e4e08f664..977c60d02 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(traceback.format_exc()) + logger.error(traceback.format_traceback(exc)()) if not settings.DEBUG and not response: response = Response(