From 6aeef42c4aa169d91be83092e2d5d5a6b9e5454c Mon Sep 17 00:00:00 2001 From: Erik Skjellevik <98759397+eriskjel@users.noreply.github.com> Date: Sat, 23 Mar 2024 18:04:13 +0100 Subject: [PATCH 01/59] Feat(kontres)/add image to bookable item (#785) * added optional image to bookable item model * added update method in serializer to handle new images * linting * remove update method for images --- ...okableitem_image_bookableitem_image_alt.py | 23 +++++++++++++++++++ app/kontres/models/bookable_item.py | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 app/kontres/migrations/0007_bookableitem_image_bookableitem_image_alt.py diff --git a/app/kontres/migrations/0007_bookableitem_image_bookableitem_image_alt.py b/app/kontres/migrations/0007_bookableitem_image_bookableitem_image_alt.py new file mode 100644 index 00000000..52bfc06b --- /dev/null +++ b/app/kontres/migrations/0007_bookableitem_image_bookableitem_image_alt.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.5 on 2024-03-22 12:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("kontres", "0006_rename_alcohol_agreement_reservation_serves_alcohol"), + ] + + operations = [ + migrations.AddField( + model_name="bookableitem", + name="image", + field=models.URLField(blank=True, max_length=600, null=True), + ), + migrations.AddField( + model_name="bookableitem", + name="image_alt", + field=models.CharField(blank=True, max_length=200, null=True), + ), + ] diff --git a/app/kontres/models/bookable_item.py b/app/kontres/models/bookable_item.py index 6ad0ece1..b6ed9669 100644 --- a/app/kontres/models/bookable_item.py +++ b/app/kontres/models/bookable_item.py @@ -4,10 +4,10 @@ from app.common.enums import AdminGroup, Groups from app.common.permissions import BasePermissionModel, check_has_access -from app.util.models import BaseModel +from app.util.models import BaseModel, OptionalImage -class BookableItem(BaseModel, BasePermissionModel): +class BookableItem(BaseModel, BasePermissionModel, OptionalImage): write_access = AdminGroup.admin() read_access = [Groups.TIHLDE] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) From c9b597580c3b73e4b0e70f1f0bc30b602601be62 Mon Sep 17 00:00:00 2001 From: Erik Skjellevik <98759397+eriskjel@users.noreply.github.com> Date: Sat, 6 Apr 2024 12:23:08 +0200 Subject: [PATCH 02/59] Feat(kontres)/add approved by (#786) * added approved by field * endpoint will now set approved by * serializer will return full user object in approved_by_detail * created test for approved by * migration * remove unnecessary code * removed write-only field in approved-by context --- .../0008_reservation_approved_by.py | 27 +++++++++++++++++++ app/kontres/models/reservation.py | 7 +++++ .../serializer/reservation_seralizer.py | 2 ++ app/kontres/views/reservation.py | 12 ++++++++- .../kontres/test_reservation_integration.py | 19 +++++++++++++ 5 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 app/kontres/migrations/0008_reservation_approved_by.py diff --git a/app/kontres/migrations/0008_reservation_approved_by.py b/app/kontres/migrations/0008_reservation_approved_by.py new file mode 100644 index 00000000..ce4954fa --- /dev/null +++ b/app/kontres/migrations/0008_reservation_approved_by.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.5 on 2024-04-06 09:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("kontres", "0007_bookableitem_image_bookableitem_image_alt"), + ] + + operations = [ + migrations.AddField( + model_name="reservation", + name="approved_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="approved_reservations", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/app/kontres/models/reservation.py b/app/kontres/models/reservation.py index 50d15921..f73fbc0a 100644 --- a/app/kontres/models/reservation.py +++ b/app/kontres/models/reservation.py @@ -53,6 +53,13 @@ class Reservation(BaseModel, BasePermissionModel): null=True, blank=True, ) + approved_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="approved_reservations", + null=True, + blank=True, + ) def __str__(self): return f"{self.state} - Reservation request by {self.author.first_name} {self.author.last_name} to book {self.bookable_item.name}. Created at {self.created_at}" diff --git a/app/kontres/serializer/reservation_seralizer.py b/app/kontres/serializer/reservation_seralizer.py index af52fc37..98bf66ac 100644 --- a/app/kontres/serializer/reservation_seralizer.py +++ b/app/kontres/serializer/reservation_seralizer.py @@ -36,6 +36,8 @@ class ReservationSerializer(serializers.ModelSerializer): ) sober_watch_detail = UserSerializer(source="sober_watch", read_only=True) + approved_by_detail = UserSerializer(source="approved_by", read_only=True) + class Meta: model = Reservation fields = "__all__" diff --git a/app/kontres/views/reservation.py b/app/kontres/views/reservation.py index cfd75f96..d3ab071f 100644 --- a/app/kontres/views/reservation.py +++ b/app/kontres/views/reservation.py @@ -58,7 +58,17 @@ def update(self, request, *args, **kwargs): reservation = self.get_object() serializer = self.get_serializer(reservation, data=request.data, partial=True) serializer.is_valid(raise_exception=True) - serializer.save() + + # Check if the state is being updated to CONFIRMED and set approved_by + if ( + "state" in serializer.validated_data + and serializer.validated_data["state"] == ReservationStateEnum.CONFIRMED + and reservation.state != ReservationStateEnum.CONFIRMED + ): + serializer.save(approved_by=request.user) + else: + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) def destroy(self, request, *args, **kwargs): diff --git a/app/tests/kontres/test_reservation_integration.py b/app/tests/kontres/test_reservation_integration.py index 9f651bc1..7668c996 100644 --- a/app/tests/kontres/test_reservation_integration.py +++ b/app/tests/kontres/test_reservation_integration.py @@ -237,6 +237,25 @@ def test_admin_can_edit_reservation_to_confirmed(reservation, admin_user): assert response.data["state"] == ReservationStateEnum.CONFIRMED +@pytest.mark.django_db +def test_admin_can_approve_reservation_and_approved_by_is_set(reservation, admin_user): + client = get_api_client(user=admin_user) + assert reservation.state == ReservationStateEnum.PENDING + assert reservation.approved_by is None + + response = client.put( + f"/kontres/reservations/{reservation.id}/", + {"state": "CONFIRMED"}, + format="json", + ) + + reservation.refresh_from_db() + + assert response.status_code == 200 + assert reservation.state == ReservationStateEnum.CONFIRMED + assert response.data["approved_by_detail"]["user_id"] == str(admin_user.user_id) + + @pytest.mark.django_db def test_admin_can_edit_reservation_to_cancelled(reservation, admin_user): client = get_api_client(user=admin_user) From 28067fad7d7cb0075bb14c087c3693c4d5df4292 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Mon, 8 Apr 2024 20:18:04 +0200 Subject: [PATCH 03/59] Create minutes for Codex (#787) * init * format --- app/content/factories/__init__.py | 1 + app/content/factories/minute_factory.py | 14 ++ app/content/migrations/0059_minute.py | 47 +++++++ app/content/models/__init__.py | 1 + app/content/models/minute.py | 49 +++++++ app/content/serializers/__init__.py | 5 + app/content/serializers/minute.py | 37 ++++++ app/content/urls.py | 2 + app/content/views/__init__.py | 1 + app/content/views/minute.py | 47 +++++++ app/tests/conftest.py | 12 ++ app/tests/content/test_minute_integration.py | 133 +++++++++++++++++++ 12 files changed, 349 insertions(+) create mode 100644 app/content/factories/minute_factory.py create mode 100644 app/content/migrations/0059_minute.py create mode 100644 app/content/models/minute.py create mode 100644 app/content/serializers/minute.py create mode 100644 app/content/views/minute.py create mode 100644 app/tests/content/test_minute_integration.py diff --git a/app/content/factories/__init__.py b/app/content/factories/__init__.py index 5c27302e..1a28282e 100644 --- a/app/content/factories/__init__.py +++ b/app/content/factories/__init__.py @@ -11,3 +11,4 @@ from app.content.factories.priority_pool_factory import PriorityPoolFactory from app.content.factories.qr_code_factory import QRCodeFactory from app.content.factories.logentry_factory import LogEntryFactory +from app.content.factories.minute_factory import MinuteFactory diff --git a/app/content/factories/minute_factory.py b/app/content/factories/minute_factory.py new file mode 100644 index 00000000..84377af9 --- /dev/null +++ b/app/content/factories/minute_factory.py @@ -0,0 +1,14 @@ +import factory +from factory.django import DjangoModelFactory + +from app.content.factories.user_factory import UserFactory +from app.content.models.minute import Minute + + +class MinuteFactory(DjangoModelFactory): + class Meta: + model = Minute + + title = factory.Faker("sentence", nb_words=4) + content = factory.Faker("text") + author = factory.SubFactory(UserFactory) diff --git a/app/content/migrations/0059_minute.py b/app/content/migrations/0059_minute.py new file mode 100644 index 00000000..977bc07c --- /dev/null +++ b/app/content/migrations/0059_minute.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.5 on 2024-04-08 17:56 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0058_merge_20231217_2155"), + ] + + operations = [ + migrations.CreateModel( + name="Minute", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=200)), + ("content", models.TextField(blank=True, default="")), + ( + "author", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="meeting_minutes", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/app/content/models/__init__.py b/app/content/models/__init__.py index 44b06243..b9646857 100644 --- a/app/content/models/__init__.py +++ b/app/content/models/__init__.py @@ -14,3 +14,4 @@ get_strike_strike_size, ) from app.content.models.qr_code import QRCode +from app.content.models.minute import Minute diff --git a/app/content/models/minute.py b/app/content/models/minute.py new file mode 100644 index 00000000..c27009ed --- /dev/null +++ b/app/content/models/minute.py @@ -0,0 +1,49 @@ +from django.db import models + +from app.common.enums import AdminGroup +from app.common.permissions import BasePermissionModel +from app.content.models.user import User +from app.util.models import BaseModel + + +class Minute(BaseModel, BasePermissionModel): + write_access = (AdminGroup.INDEX,) + read_access = (AdminGroup.INDEX,) + + title = models.CharField(max_length=200) + content = models.TextField(default="", blank=True) + author = models.ForeignKey( + User, + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + related_name="meeting_minutes", + ) + + @classmethod + def has_update_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_destroy_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_retrieve_permission(cls, request): + return cls.has_read_permission(request) + + def has_object_read_permission(self, request): + return self.has_read_permission(request) + + def has_object_update_permission(self, request): + return self.has_write_permission(request) + + def has_object_destroy_permission(self, request): + return self.has_write_permission(request) + + def has_object_retrieve_permission(self, request): + return self.has_read_permission(request) + + def __str__(self): + return self.title diff --git a/app/content/serializers/__init__.py b/app/content/serializers/__init__.py index c587de35..baea3383 100644 --- a/app/content/serializers/__init__.py +++ b/app/content/serializers/__init__.py @@ -31,3 +31,8 @@ DefaultUserSerializer, UserPermissionsSerializer, ) +from app.content.serializers.minute import ( + MinuteCreateSerializer, + MinuteSerializer, + MinuteUpdateSerializer, +) diff --git a/app/content/serializers/minute.py b/app/content/serializers/minute.py new file mode 100644 index 00000000..b3f0b2d5 --- /dev/null +++ b/app/content/serializers/minute.py @@ -0,0 +1,37 @@ +from rest_framework import serializers + +from app.content.models import Minute, User + + +class SimpleUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ("user_id", "first_name", "last_name", "image") + + +class MinuteCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Minute + fields = ("title", "content") + + def create(self, validated_data): + author = self.context["request"].user + minute = Minute.objects.create(**validated_data, author=author) + return minute + + +class MinuteSerializer(serializers.ModelSerializer): + author = SimpleUserSerializer(read_only=True) + + class Meta: + model = Minute + fields = ("id", "title", "content", "author", "created_at", "updated_at") + + +class MinuteUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = Minute + fields = ("id", "title", "content") + + def update(self, instance, validated_data): + return super().update(instance, validated_data) diff --git a/app/content/urls.py b/app/content/urls.py index a1f51508..1a783c06 100644 --- a/app/content/urls.py +++ b/app/content/urls.py @@ -6,6 +6,7 @@ CheatsheetViewSet, EventViewSet, LogEntryViewSet, + MinuteViewSet, NewsViewSet, PageViewSet, QRCodeViewSet, @@ -42,6 +43,7 @@ router.register("pages", PageViewSet) router.register("strikes", StrikeViewSet, basename="strikes") router.register("log-entries", LogEntryViewSet, basename="log-entries") +router.register("minutes", MinuteViewSet, basename="minutes") urlpatterns = [ re_path(r"", include(router.urls)), diff --git a/app/content/views/__init__.py b/app/content/views/__init__.py index 9a89d3ab..517d59b3 100644 --- a/app/content/views/__init__.py +++ b/app/content/views/__init__.py @@ -13,3 +13,4 @@ from app.content.views.toddel import ToddelViewSet from app.content.views.qr_code import QRCodeViewSet from app.content.views.logentry import LogEntryViewSet +from app.content.views.minute import MinuteViewSet diff --git a/app/content/views/minute.py b/app/content/views/minute.py new file mode 100644 index 00000000..266d3d0d --- /dev/null +++ b/app/content/views/minute.py @@ -0,0 +1,47 @@ +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.content.models import Minute +from app.content.serializers import ( + MinuteCreateSerializer, + MinuteSerializer, + MinuteUpdateSerializer, +) + + +class MinuteViewSet(BaseViewSet): + serializer_class = MinuteSerializer + permission_classes = [BasicViewPermission] + pagination_class = BasePagination + queryset = Minute.objects.all() + + def create(self, request, *args, **kwargs): + data = request.data + serializer = MinuteCreateSerializer(data=data, context={"request": request}) + if serializer.is_valid(): + super().perform_create(serializer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response( + {"detail": serializer.errors}, status=status.HTTP_400_BAD_REQUEST + ) + + def update(self, request, *args, **kwargs): + minute = self.get_object() + serializer = MinuteUpdateSerializer( + minute, data=request.data, context={"request": request} + ) + if serializer.is_valid(): + minute = super().perform_update(serializer) + return Response(serializer.data, status=status.HTTP_200_OK) + + return Response( + {"detail": serializer.errors}, status=status.HTTP_400_BAD_REQUEST + ) + + def destroy(self, request, *args, **kwargs): + super().destroy(request, *args, **kwargs) + return Response({"detail": "The minute was deleted"}, status=status.HTTP_200_OK) diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 02d22d5e..3d864bb0 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -14,6 +14,7 @@ from app.content.factories import ( CheatsheetFactory, EventFactory, + MinuteFactory, NewsFactory, PageFactory, ParentPageFactory, @@ -124,6 +125,12 @@ def plask_member(member): return member +@pytest.fixture() +def index_member(member): + add_user_to_group_with_name(member, AdminGroup.INDEX) + return member + + @pytest.fixture() def member_client(member): return get_api_client(user=member) @@ -281,3 +288,8 @@ def event_with_priority_pool(priority_group): event = EventFactory(limit=1) PriorityPoolFactory(event=event, groups=(priority_group,)) return event + + +@pytest.fixture() +def minute(user): + return MinuteFactory(author=user) diff --git a/app/tests/content/test_minute_integration.py b/app/tests/content/test_minute_integration.py new file mode 100644 index 00000000..a0b92573 --- /dev/null +++ b/app/tests/content/test_minute_integration.py @@ -0,0 +1,133 @@ +from rest_framework import status + +import pytest + +from app.util.test_utils import get_api_client + +API_MINUTE_BASE_URL = "/minutes/" + + +def get_minute_detail_url(minute): + return f"{API_MINUTE_BASE_URL}{minute.id}/" + + +def get_minute_post_data(): + return {"title": "Test Minute", "content": "This is a test minute."} + + +def get_minute_put_data(): + return {"title": "Test Minute update", "content": "This is a test minute update."} + + +@pytest.mark.django_db +def test_create_minute_as_member(member): + """A member should be not able to create a minute""" + url = API_MINUTE_BASE_URL + client = get_api_client(user=member) + data = get_minute_post_data() + response = client.post(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_create_minute_as_index_member(index_member): + """An index member should be able to create a minute""" + url = API_MINUTE_BASE_URL + client = get_api_client(user=index_member) + data = get_minute_post_data() + response = client.post(url, data) + + assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +def test_update_minute_as_member(member, minute): + """A member should not be able to update a minute""" + url = get_minute_detail_url(minute) + client = get_api_client(user=member) + data = get_minute_put_data() + response = client.put(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_update_minute_as_index_member(index_member, minute): + """An index member should be able to update a minute""" + minute.author = index_member + minute.save() + url = get_minute_detail_url(minute) + client = get_api_client(user=index_member) + data = get_minute_put_data() + response = client.put(url, data) + + assert response.status_code == status.HTTP_200_OK + assert response.data["title"] == data["title"] + + +@pytest.mark.django_db +def test_delete_minute_as_member(member, minute): + """A member should not be able to delete a minute""" + url = get_minute_detail_url(minute) + client = get_api_client(user=member) + response = client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_delete_minute_as_index_member(index_member, minute): + """An index member should be able to delete a minute""" + minute.author = index_member + minute.save() + url = get_minute_detail_url(minute) + client = get_api_client(user=index_member) + response = client.delete(url) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_list_minutes_as_member(member): + """A member should not be able to list minutes""" + url = API_MINUTE_BASE_URL + client = get_api_client(user=member) + response = client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_list_minutes_as_index_member(index_member, minute): + """An index member should be able to list minutes""" + minute.author = index_member + minute.save() + url = API_MINUTE_BASE_URL + client = get_api_client(user=index_member) + response = client.get(url) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_retrieve_minute_as_member(member, minute): + """A member should not be able to retrieve a minute""" + url = get_minute_detail_url(minute) + client = get_api_client(user=member) + response = client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_retrieve_minute_as_index_member(index_member, minute): + """An index member should be able to retrieve a minute""" + minute.author = index_member + minute.save() + url = get_minute_detail_url(minute) + client = get_api_client(user=index_member) + response = client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == minute.id From 9e4ff7667155816b442539312802b0fe4b6c55c2 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Mon, 8 Apr 2024 22:06:44 +0200 Subject: [PATCH 04/59] Feat(minute)/viewset (#788) * added richer reponse on post and put * added to admin panel * added filter for minute --- CHANGELOG.md | 3 +++ app/content/admin/admin.py | 7 +++++++ app/content/enums.py | 5 +++++ app/content/filters/__init__.py | 1 + app/content/filters/minute.py | 15 +++++++++++++++ app/content/migrations/0060_minute_tag.py | 22 ++++++++++++++++++++++ app/content/models/minute.py | 4 ++++ app/content/serializers/__init__.py | 1 + app/content/serializers/minute.py | 14 +++++++++++--- app/content/views/minute.py | 23 +++++++++++++++++++++-- 10 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 app/content/filters/minute.py create mode 100644 app/content/migrations/0060_minute_tag.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b526f705..17dd794b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ ## Neste versjon +## Versjon 2023.04.08 +- ✨ **Codex** Index brukere kan nå opprette dokumenter og møtereferater i Codex. + ## Versjon 2023.03.11 - 🦟 **Vipps** Brukere som kommer fra venteliste vil nå få en payment countdown startet, slik at de blir kastet ut hvis de ikke betaler. - ⚡ **Venteliste** Brukere vil nå se sin reelle ventelisteplass som tar hensyn til prioriteringer. diff --git a/app/content/admin/admin.py b/app/content/admin/admin.py index 8afc11f8..0ac3488f 100644 --- a/app/content/admin/admin.py +++ b/app/content/admin/admin.py @@ -251,3 +251,10 @@ def object_link(self, obj): object_link.admin_order_field = "object_repr" object_link.short_description = "object" + + +@admin.register(models.Minute) +class MinuteAdmin(admin.ModelAdmin): + list_display = ("title", "author", "created_at", "updated_at") + search_fields = ("title", "content", "author__user_id") + list_filter = ("author",) diff --git a/app/content/enums.py b/app/content/enums.py index 3f0ced1d..5d2332a8 100644 --- a/app/content/enums.py +++ b/app/content/enums.py @@ -18,3 +18,8 @@ class CategoryEnum(ChoiceEnum): KURS = "Kurs" ANNET = "Annet" FADDERUKA = "Fadderuka" + + +class MinuteTagEnum(models.TextChoices): + MINUTE = "Møtereferat" + DOCUMENT = "Dokument" diff --git a/app/content/filters/__init__.py b/app/content/filters/__init__.py index ae6e7612..d442c266 100644 --- a/app/content/filters/__init__.py +++ b/app/content/filters/__init__.py @@ -1,3 +1,4 @@ from app.content.filters.cheatsheet import CheatsheetFilter from app.content.filters.event import EventFilter from app.content.filters.user import UserFilter +from app.content.filters.minute import MinuteFilter diff --git a/app/content/filters/minute.py b/app/content/filters/minute.py new file mode 100644 index 00000000..db956ab2 --- /dev/null +++ b/app/content/filters/minute.py @@ -0,0 +1,15 @@ +from django_filters.rest_framework import FilterSet, OrderingFilter + +from app.content.models import Minute + + +class MinuteFilter(FilterSet): + """Filters minutes""" + + ordering = OrderingFilter( + fields=("created_at", "updated_at", "title", "author", "tag") + ) + + class Meta: + model = Minute + fields = ["author", "title", "tag"] diff --git a/app/content/migrations/0060_minute_tag.py b/app/content/migrations/0060_minute_tag.py new file mode 100644 index 00000000..b2d57d89 --- /dev/null +++ b/app/content/migrations/0060_minute_tag.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.5 on 2024-04-08 19:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0059_minute"), + ] + + operations = [ + migrations.AddField( + model_name="minute", + name="tag", + field=models.CharField( + choices=[("Møtereferat", "Minute"), ("Dokument", "Document")], + default="Møtereferat", + max_length=50, + ), + ), + ] diff --git a/app/content/models/minute.py b/app/content/models/minute.py index c27009ed..2aa8d26a 100644 --- a/app/content/models/minute.py +++ b/app/content/models/minute.py @@ -2,6 +2,7 @@ from app.common.enums import AdminGroup from app.common.permissions import BasePermissionModel +from app.content.enums import MinuteTagEnum from app.content.models.user import User from app.util.models import BaseModel @@ -12,6 +13,9 @@ class Minute(BaseModel, BasePermissionModel): title = models.CharField(max_length=200) content = models.TextField(default="", blank=True) + tag = models.CharField( + max_length=50, choices=MinuteTagEnum.choices, default=MinuteTagEnum.MINUTE + ) author = models.ForeignKey( User, blank=True, diff --git a/app/content/serializers/__init__.py b/app/content/serializers/__init__.py index baea3383..53ae7b21 100644 --- a/app/content/serializers/__init__.py +++ b/app/content/serializers/__init__.py @@ -35,4 +35,5 @@ MinuteCreateSerializer, MinuteSerializer, MinuteUpdateSerializer, + MinuteListSerializer, ) diff --git a/app/content/serializers/minute.py b/app/content/serializers/minute.py index b3f0b2d5..f490195d 100644 --- a/app/content/serializers/minute.py +++ b/app/content/serializers/minute.py @@ -12,7 +12,7 @@ class Meta: class MinuteCreateSerializer(serializers.ModelSerializer): class Meta: model = Minute - fields = ("title", "content") + fields = ("title", "content", "tag") def create(self, validated_data): author = self.context["request"].user @@ -25,13 +25,21 @@ class MinuteSerializer(serializers.ModelSerializer): class Meta: model = Minute - fields = ("id", "title", "content", "author", "created_at", "updated_at") + fields = ("id", "title", "content", "author", "created_at", "updated_at", "tag") class MinuteUpdateSerializer(serializers.ModelSerializer): class Meta: model = Minute - fields = ("id", "title", "content") + fields = ("id", "title", "content", "tag") def update(self, instance, validated_data): return super().update(instance, validated_data) + + +class MinuteListSerializer(serializers.ModelSerializer): + author = SimpleUserSerializer(read_only=True) + + class Meta: + model = Minute + fields = ("id", "title", "author", "created_at", "updated_at", "tag") diff --git a/app/content/views/minute.py b/app/content/views/minute.py index 266d3d0d..3cc14914 100644 --- a/app/content/views/minute.py +++ b/app/content/views/minute.py @@ -1,12 +1,15 @@ -from rest_framework import status +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, status from rest_framework.response import Response from app.common.pagination import BasePagination from app.common.permissions import BasicViewPermission from app.common.viewsets import BaseViewSet +from app.content.filters import MinuteFilter from app.content.models import Minute from app.content.serializers import ( MinuteCreateSerializer, + MinuteListSerializer, MinuteSerializer, MinuteUpdateSerializer, ) @@ -18,11 +21,26 @@ class MinuteViewSet(BaseViewSet): pagination_class = BasePagination queryset = Minute.objects.all() + filter_backends = [DjangoFilterBackend, filters.SearchFilter] + filterset_class = MinuteFilter + search_fields = [ + "title", + "author__first_name", + "author__last_name", + "author__user_id", + ] + + def get_serializer_class(self): + if hasattr(self, "action") and self.action == "list": + return MinuteListSerializer + return super().get_serializer_class() + def create(self, request, *args, **kwargs): data = request.data serializer = MinuteCreateSerializer(data=data, context={"request": request}) if serializer.is_valid(): - super().perform_create(serializer) + minute = super().perform_create(serializer) + serializer = MinuteSerializer(minute) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response( @@ -36,6 +54,7 @@ def update(self, request, *args, **kwargs): ) if serializer.is_valid(): minute = super().perform_update(serializer) + serializer = MinuteSerializer(minute) return Response(serializer.data, status=status.HTTP_200_OK) return Response( From 0544b2f7fb7c8aabac2d5c41a337cac315467ada Mon Sep 17 00:00:00 2001 From: Erik Skjellevik <98759397+eriskjel@users.noreply.github.com> Date: Wed, 10 Apr 2024 18:45:32 +0200 Subject: [PATCH 05/59] Feat(kontres)/add notification (#790) * created methods for sending notification to admin and user * endpoint will now send notification if needed * add migrations for new notification types --- app/communication/enums.py | 3 ++ ...ernotificationsetting_notification_type.py | 35 ++++++++++++++ app/kontres/models/reservation.py | 47 ++++++++++++++++++- app/kontres/views/reservation.py | 31 ++++++++---- 4 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 app/communication/migrations/0010_alter_usernotificationsetting_notification_type.py diff --git a/app/communication/enums.py b/app/communication/enums.py index 60cb68ac..d9c6fc60 100644 --- a/app/communication/enums.py +++ b/app/communication/enums.py @@ -15,3 +15,6 @@ class UserNotificationSettingType(models.TextChoices): FINE = "FINE", "Grupper - bot" GROUP_MEMBERSHIP = "GROUP_MEMBERSHIP", "Grupper - medlemsskap" OTHER = "OTHER", "Andre" + RESERVATION_NEW = "RESERVATION NEW", "Ny reservasjon" + RESERVATION_APPROVED = "RESERVATION APPROVED", "Godkjent reservasjon" + RESERVATION_CANCELLED = "RESERVATION CANCELLED", "Avslått reservasjon" diff --git a/app/communication/migrations/0010_alter_usernotificationsetting_notification_type.py b/app/communication/migrations/0010_alter_usernotificationsetting_notification_type.py new file mode 100644 index 00000000..87c08c70 --- /dev/null +++ b/app/communication/migrations/0010_alter_usernotificationsetting_notification_type.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.5 on 2024-04-10 16:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("communication", "0009_alter_usernotificationsetting_notification_type"), + ] + + operations = [ + migrations.AlterField( + model_name="usernotificationsetting", + name="notification_type", + field=models.CharField( + choices=[ + ("REGISTRATION", "Påmeldingsoppdateringer"), + ("UNREGISTRATION", "Avmeldingsoppdateringer"), + ("STRIKE", "Prikkoppdateringer"), + ("EVENT_SIGN_UP_START", "Arrangementer - påmeldingsstart"), + ("EVENT_SIGN_OFF_DEADLINE", "Arrangementer - avmeldingsfrist"), + ("EVENT_EVALUATION", "Arrangementer - evaluering"), + ("EVENT_INFO", "Arrangementer - info fra arrangør"), + ("FINE", "Grupper - bot"), + ("GROUP_MEMBERSHIP", "Grupper - medlemsskap"), + ("OTHER", "Andre"), + ("RESERVATION NEW", "Ny reservasjon"), + ("RESERVATION APPROVED", "Godkjent reservasjon"), + ("RESERVATION CANCELLED", "Avslått reservasjon"), + ], + max_length=30, + ), + ), + ] diff --git a/app/kontres/models/reservation.py b/app/kontres/models/reservation.py index f73fbc0a..22f544ca 100644 --- a/app/kontres/models/reservation.py +++ b/app/kontres/models/reservation.py @@ -2,10 +2,12 @@ from django.db import models -from app.common.enums import AdminGroup, Groups +from app.common.enums import AdminGroup, Groups, MembershipType from app.common.permissions import BasePermissionModel, check_has_access +from app.communication.enums import UserNotificationSettingType +from app.communication.notifier import Notify from app.content.models import User -from app.group.models import Group +from app.group.models import Group, Membership from app.kontres.enums import ReservationStateEnum from app.kontres.models.bookable_item import BookableItem from app.util.models import BaseModel @@ -110,3 +112,44 @@ def has_object_update_permission(self, request): def is_own_reservation(self, request): return self.author == request.user + + def notify_admins_new_reservation(self): + formatted_start_time = self.start_time.strftime("%d/%m %H:%M") + + leader_membership = Membership.objects.filter( + group=Group.objects.get(pk="kontkom"), membership_type=MembershipType.LEADER + ).first() + + if leader_membership is None: + return + + notification_message = ( + f"En ny reservasjon er opprettet for {self.bookable_item.name}, " + f"planlagt til {formatted_start_time}." + ) + + Notify( + users=[leader_membership.user], + title="Ny Reservasjon Laget", + notification_type=UserNotificationSettingType.RESERVATION_NEW, + ).add_paragraph(notification_message).send() + + def notify_approved(self): + formatted_date_time = self.start_time.strftime("%d/%m %H:%M") + Notify( + [self.author], + f'Reservasjonssøknad for "{self.bookable_item.name} er godkjent."', + UserNotificationSettingType.RESERVATION_APPROVED, + ).add_paragraph( + f"Hei, {self.author.first_name}! Din søknad for å reservere {self.bookable_item.name}, den {formatted_date_time} har blitt godkjent." + ).send() + + def notify_denied(self): + formatted_date_time = self.start_time.strftime("%d/%m %H:%M") + Notify( + [self.author], + f'Reservasjonssøknad for "{self.bookable_item.name}" er avslått.', + UserNotificationSettingType.RESERVATION_CANCELLED, + ).add_paragraph( + f"Hei, {self.author.first_name}! Din søknad for å reservere {self.bookable_item.name}, den {formatted_date_time} har blitt avslått. Du kan ta kontakt med Kontor og Kiosk dersom du lurer på noe ifm. dette." + ).send() diff --git a/app/kontres/views/reservation.py b/app/kontres/views/reservation.py index d3ab071f..1a0165fd 100644 --- a/app/kontres/views/reservation.py +++ b/app/kontres/views/reservation.py @@ -59,17 +59,28 @@ def update(self, request, *args, **kwargs): serializer = self.get_serializer(reservation, data=request.data, partial=True) serializer.is_valid(raise_exception=True) - # Check if the state is being updated to CONFIRMED and set approved_by - if ( - "state" in serializer.validated_data - and serializer.validated_data["state"] == ReservationStateEnum.CONFIRMED - and reservation.state != ReservationStateEnum.CONFIRMED - ): - serializer.save(approved_by=request.user) - else: - serializer.save() + if serializer.is_valid(): + previous_state = reservation.state + new_state = serializer.validated_data.get("state") + + # Check if the state is being updated to CONFIRMED and set approved_by + if ( + new_state == ReservationStateEnum.CONFIRMED + and previous_state != ReservationStateEnum.CONFIRMED + ): + serializer.save(approved_by=request.user) + else: + serializer.save() + + if new_state and new_state != previous_state: + if new_state == ReservationStateEnum.CONFIRMED: + serializer.instance.notify_approved() + elif new_state == ReservationStateEnum.CANCELLED: + serializer.instance.notify_denied() - return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, *args, **kwargs): super().destroy(self, request, *args, **kwargs) From ae483dd55385940c35654c758375ad594d34a411 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Fri, 12 Apr 2024 14:27:46 +0200 Subject: [PATCH 06/59] Memberships with fines activated (#791) init --- app/content/views/user.py | 14 ++++++++++++++ app/group/serializers/group.py | 1 + app/tests/content/test_user_integration.py | 1 + 3 files changed, 16 insertions(+) diff --git a/app/content/views/user.py b/app/content/views/user.py index e0edc677..35940418 100644 --- a/app/content/views/user.py +++ b/app/content/views/user.py @@ -181,6 +181,20 @@ def get_user_memberships(self, request, pk, *args, **kwargs): context={"request": request}, ) + @action(detail=True, methods=["get"], url_path="memberships-with-fines") + def get_user_memberships_with_fines(self, request, pk, *args, **kwargs): + user = self._get_user(request, pk) + self.check_object_permissions(self.request, user) + + memberships = user.memberships.filter( + group__type__in=GroupType.public_groups(), group__fines_activated=True + ).order_by("-created_at") + return self.paginate_response( + data=memberships, + serializer=MembershipSerializer, + context={"request": request}, + ) + @action(detail=True, methods=["get"], url_path="membership-histories") def get_user_membership_histories(self, request, pk, *args, **kwargs): user = self._get_user(request, pk) diff --git a/app/group/serializers/group.py b/app/group/serializers/group.py index 406cfa73..fd45dd6c 100644 --- a/app/group/serializers/group.py +++ b/app/group/serializers/group.py @@ -22,6 +22,7 @@ class Meta: "viewer_is_member", "image", "image_alt", + "fines_activated", ) def get_viewer_is_member(self, obj): diff --git a/app/tests/content/test_user_integration.py b/app/tests/content/test_user_integration.py index 139f3bd9..bd36027e 100644 --- a/app/tests/content/test_user_integration.py +++ b/app/tests/content/test_user_integration.py @@ -180,6 +180,7 @@ def test_filter_only_users_with_active_strikes( [ ("/", status.HTTP_200_OK), ("/memberships/", status.HTTP_200_OK), + ("/memberships-with-fines/", status.HTTP_200_OK), ("/membership-histories/", status.HTTP_200_OK), ("/badges/", status.HTTP_200_OK), ("/events/", status.HTTP_200_OK), From bfa229981f5d8b67c67babd31368b1a2496ed111 Mon Sep 17 00:00:00 2001 From: haruixu <114171733+haruixu@users.noreply.github.com> Date: Tue, 16 Apr 2024 09:13:57 +0200 Subject: [PATCH 07/59] Feat(user)/user bio (#758) * Created model, serializer and view for user-bio * Created user bio model and made migrations * Created user bio serializer + viewsets + added new endpoint * Tested create method + added bio serializer to user serializer * Format * Created update method and started testing * Debugging test failures in user retrieve * fixed model error * Created user_bio_factory + started testing put method * Created fixture for UserBio * Created custom excpetion for duplicate user bio * Added permissions and inherited from BaseModel * Modularized serializer for bio * Use correct serializers in viewset + added destroy method * Finished testing bio viewset integration + format * Changed environent file to .env to avoid pushing up keys * Fix: Flipped assertion statement in test, since user bio should not be deleted * skiped buggy test from kontres * added mark to pytest.skip * Moved keys to .env file and reverted docker variables * Skip buggy kontres test * format * Added str method to user_bio * Removed unused imports * format * Changed user relation to a OneToOne-field (same affect as ForeignKey(unique=True) + removed check for duplicate bio in serializer * Migrations + changed assertion status code in duplicate bio test (could try catch in serializer to produce 400 status code) * format * format * Changed limit for description 50 -> 500 + migrations * Migrate * added id to serializer * merged leaf nodes in migrations * format --------- Co-authored-by: Ester2109 <126612066+Ester2109@users.noreply.github.com> Co-authored-by: Mads Nylund Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Co-authored-by: Tam Le --- .envs/.local | 2 +- app/content/factories/__init__.py | 1 + app/content/factories/user_bio_factory.py | 12 ++ app/content/migrations/0059_userbio.py | 43 ++++++ .../0060_alter_userbio_description.py | 18 +++ ...1_userbio_created_at_userbio_updated_at.py | 27 ++++ .../migrations/0062_alter_userbio_user.py | 25 ++++ .../migrations/0063_alter_userbio_user.py | 24 ++++ .../0064_alter_userbio_description.py | 18 +++ ...nute_tag_0064_alter_userbio_description.py | 13 ++ app/content/models/user_bio.py | 45 +++++++ app/content/serializers/user.py | 3 + app/content/serializers/user_bio.py | 23 ++++ app/content/urls.py | 2 + app/content/views/__init__.py | 1 + app/content/views/user.py | 20 ++- app/content/views/user_bio.py | 54 ++++++++ app/tests/conftest.py | 6 + .../content/test_user_bio_integration.py | 124 ++++++++++++++++++ .../kontres/test_reservation_integration.py | 3 + 20 files changed, 457 insertions(+), 7 deletions(-) create mode 100644 app/content/factories/user_bio_factory.py create mode 100644 app/content/migrations/0059_userbio.py create mode 100644 app/content/migrations/0060_alter_userbio_description.py create mode 100644 app/content/migrations/0061_userbio_created_at_userbio_updated_at.py create mode 100644 app/content/migrations/0062_alter_userbio_user.py create mode 100644 app/content/migrations/0063_alter_userbio_user.py create mode 100644 app/content/migrations/0064_alter_userbio_description.py create mode 100644 app/content/migrations/0065_merge_0060_minute_tag_0064_alter_userbio_description.py create mode 100644 app/content/models/user_bio.py create mode 100644 app/content/serializers/user_bio.py create mode 100644 app/content/views/user_bio.py create mode 100644 app/tests/content/test_user_bio_integration.py diff --git a/.envs/.local b/.envs/.local index a873515c..69592b85 100644 --- a/.envs/.local +++ b/.envs/.local @@ -4,4 +4,4 @@ DATABASE_HOST=db DATABASE_NAME=nettside-dev DATABASE_PASSWORD=password DATABASE_PORT=3306 -DATABASE_USER=root +DATABASE_USER=root \ No newline at end of file diff --git a/app/content/factories/__init__.py b/app/content/factories/__init__.py index 1a28282e..6dd452ef 100644 --- a/app/content/factories/__init__.py +++ b/app/content/factories/__init__.py @@ -10,5 +10,6 @@ from app.content.factories.toddel_factory import ToddelFactory from app.content.factories.priority_pool_factory import PriorityPoolFactory from app.content.factories.qr_code_factory import QRCodeFactory +from app.content.factories.user_bio_factory import UserBioFactory from app.content.factories.logentry_factory import LogEntryFactory from app.content.factories.minute_factory import MinuteFactory diff --git a/app/content/factories/user_bio_factory.py b/app/content/factories/user_bio_factory.py new file mode 100644 index 00000000..56967709 --- /dev/null +++ b/app/content/factories/user_bio_factory.py @@ -0,0 +1,12 @@ +import factory +from factory.django import DjangoModelFactory + +from app.content.factories.user_factory import UserFactory +from app.content.models.user_bio import UserBio + + +class UserBioFactory(DjangoModelFactory): + class Meta: + model = UserBio + + user = factory.SubFactory(UserFactory) diff --git a/app/content/migrations/0059_userbio.py b/app/content/migrations/0059_userbio.py new file mode 100644 index 00000000..efe3561c --- /dev/null +++ b/app/content/migrations/0059_userbio.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.5 on 2024-01-29 17:51 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0058_merge_20231217_2155"), + ] + + operations = [ + migrations.CreateModel( + name="UserBio", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("description", models.CharField(max_length=50)), + ("gitHub_link", models.URLField(blank=True, max_length=300, null=True)), + ( + "linkedIn_link", + models.URLField(blank=True, max_length=300, null=True), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bio", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/app/content/migrations/0060_alter_userbio_description.py b/app/content/migrations/0060_alter_userbio_description.py new file mode 100644 index 00000000..1f945a9a --- /dev/null +++ b/app/content/migrations/0060_alter_userbio_description.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2024-02-01 10:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0059_userbio"), + ] + + operations = [ + migrations.AlterField( + model_name="userbio", + name="description", + field=models.CharField(blank=True, max_length=50, null=True), + ), + ] diff --git a/app/content/migrations/0061_userbio_created_at_userbio_updated_at.py b/app/content/migrations/0061_userbio_created_at_userbio_updated_at.py new file mode 100644 index 00000000..8d70b870 --- /dev/null +++ b/app/content/migrations/0061_userbio_created_at_userbio_updated_at.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.5 on 2024-02-19 16:09 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0060_alter_userbio_description"), + ] + + operations = [ + migrations.AddField( + model_name="userbio", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AddField( + model_name="userbio", + name="updated_at", + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/app/content/migrations/0062_alter_userbio_user.py b/app/content/migrations/0062_alter_userbio_user.py new file mode 100644 index 00000000..3697c9a2 --- /dev/null +++ b/app/content/migrations/0062_alter_userbio_user.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.5 on 2024-02-21 13:35 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0061_userbio_created_at_userbio_updated_at"), + ] + + operations = [ + migrations.AlterField( + model_name="userbio", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bio", + to=settings.AUTH_USER_MODEL, + unique=True, + ), + ), + ] diff --git a/app/content/migrations/0063_alter_userbio_user.py b/app/content/migrations/0063_alter_userbio_user.py new file mode 100644 index 00000000..c9cb9f2d --- /dev/null +++ b/app/content/migrations/0063_alter_userbio_user.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.5 on 2024-02-21 13:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0062_alter_userbio_user"), + ] + + operations = [ + migrations.AlterField( + model_name="userbio", + name="user", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="bio", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/app/content/migrations/0064_alter_userbio_description.py b/app/content/migrations/0064_alter_userbio_description.py new file mode 100644 index 00000000..3e046107 --- /dev/null +++ b/app/content/migrations/0064_alter_userbio_description.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2024-02-26 15:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0063_alter_userbio_user"), + ] + + operations = [ + migrations.AlterField( + model_name="userbio", + name="description", + field=models.CharField(blank=True, max_length=500, null=True), + ), + ] diff --git a/app/content/migrations/0065_merge_0060_minute_tag_0064_alter_userbio_description.py b/app/content/migrations/0065_merge_0060_minute_tag_0064_alter_userbio_description.py new file mode 100644 index 00000000..311ba846 --- /dev/null +++ b/app/content/migrations/0065_merge_0060_minute_tag_0064_alter_userbio_description.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.5 on 2024-04-16 06:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0060_minute_tag"), + ("content", "0064_alter_userbio_description"), + ] + + operations = [] diff --git a/app/content/models/user_bio.py b/app/content/models/user_bio.py new file mode 100644 index 00000000..bca02474 --- /dev/null +++ b/app/content/models/user_bio.py @@ -0,0 +1,45 @@ +from django.db import models + +from app.common.enums import Groups +from app.common.permissions import BasePermissionModel, check_has_access +from app.content.models.user import User +from app.util.models import BaseModel + + +class UserBio(BaseModel, BasePermissionModel): + read_access = (Groups.TIHLDE,) + write_access = (Groups.TIHLDE,) + + description = models.CharField(max_length=500, blank=True, null=True) + + gitHub_link = models.URLField(max_length=300, blank=True, null=True) + + linkedIn_link = models.URLField(max_length=300, blank=True, null=True) + + user = models.OneToOneField( + User, on_delete=models.CASCADE, unique=True, related_name="bio" + ) + + def __str__(self): + bio_str = f"{self.user}" + if self.description: + bio_str += f" - {self.description}" + if self.gitHub_link: + bio_str += f" - {self.gitHub_link}" + if self.linkedIn_link: + bio_str += f" - {self.linkedIn_link}" + return bio_str + + @classmethod + def has_update_permission(cls, request): + return check_has_access(cls.write_access, request) + + @classmethod + def has_destroy_permission(cls, request): + return check_has_access(cls.write_access, request) + + def has_object_update_permission(self, request): + return self.user == request.user + + def has_object_destroy_permission(self, request): + return self.user == request.user diff --git a/app/content/serializers/user.py b/app/content/serializers/user.py index d52aed01..d1bd1c54 100644 --- a/app/content/serializers/user.py +++ b/app/content/serializers/user.py @@ -6,6 +6,7 @@ from app.common.enums import GroupType from app.common.serializers import BaseModelSerializer from app.content.models import User +from app.content.serializers.user_bio import UserBioSerializer from app.group.models import Group, Membership @@ -50,6 +51,7 @@ def get_studyyear(self, obj): class UserSerializer(DefaultUserSerializer): unread_notifications = serializers.SerializerMethodField() unanswered_evaluations_count = serializers.SerializerMethodField() + bio = UserBioSerializer(read_only=True, required=False) class Meta: model = User @@ -63,6 +65,7 @@ class Meta: "slack_user_id", "allows_photo_by_default", "accepts_event_rules", + "bio", ) read_only_fields = ("user_id",) diff --git a/app/content/serializers/user_bio.py b/app/content/serializers/user_bio.py new file mode 100644 index 00000000..fd701ffb --- /dev/null +++ b/app/content/serializers/user_bio.py @@ -0,0 +1,23 @@ +from app.common.serializers import BaseModelSerializer +from app.content.models.user_bio import UserBio + + +class UserBioSerializer(BaseModelSerializer): + class Meta: + model = UserBio + fields = ["id", "description", "gitHub_link", "linkedIn_link"] + + +class UserBioCreateSerializer(BaseModelSerializer): + class Meta: + model = UserBio + fields = ["description", "gitHub_link", "linkedIn_link"] + + def create(self, validated_data): + return super().create(validated_data) + + +class UserBioUpdateSerializer(BaseModelSerializer): + class Meta: + model = UserBio + fields = ["description", "gitHub_link", "linkedIn_link"] diff --git a/app/content/urls.py b/app/content/urls.py index 1a783c06..f710aad0 100644 --- a/app/content/urls.py +++ b/app/content/urls.py @@ -14,6 +14,7 @@ ShortLinkViewSet, StrikeViewSet, ToddelViewSet, + UserBioViewset, UserCalendarEvents, UserViewSet, accept_form, @@ -30,6 +31,7 @@ router.register("short-links", ShortLinkViewSet, basename="short-link") router.register("qr-codes", QRCodeViewSet, basename="qr-code") router.register("users", UserViewSet, basename="user") +router.register("user-bios", UserBioViewset, basename="user-bio") router.register( r"events/(?P\d+)/registrations", RegistrationViewSet, diff --git a/app/content/views/__init__.py b/app/content/views/__init__.py index 517d59b3..bc0bd030 100644 --- a/app/content/views/__init__.py +++ b/app/content/views/__init__.py @@ -12,5 +12,6 @@ from app.content.views.strike import StrikeViewSet from app.content.views.toddel import ToddelViewSet from app.content.views.qr_code import QRCodeViewSet +from app.content.views.user_bio import UserBioViewset from app.content.views.logentry import LogEntryViewSet from app.content.views.minute import MinuteViewSet diff --git a/app/content/views/user.py b/app/content/views/user.py index 35940418..90d787b2 100644 --- a/app/content/views/user.py +++ b/app/content/views/user.py @@ -63,15 +63,23 @@ def get_serializer_class(self): return super().get_serializer_class() def retrieve(self, request, pk, *args, **kwargs): - user = self._get_user(request, pk) - self.check_object_permissions(self.request, user) + try: + user = self._get_user(request, pk) - serializer = DefaultUserSerializer(user) - if is_admin_user(self.request) or user == request.user: - serializer = UserSerializer(user, context={"request": self.request}) + self.check_object_permissions(self.request, user) - return Response(serializer.data, status=status.HTTP_200_OK) + serializer = DefaultUserSerializer(user) + + if is_admin_user(self.request) or user == request.user: + serializer = UserSerializer(user, context={"request": self.request}) + + return Response(serializer.data, status=status.HTTP_200_OK) + + except Exception: + return Response( + {"message": "Error"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) def create(self, request, *args, **kwargs): serializer = UserCreateSerializer(data=self.request.data) diff --git a/app/content/views/user_bio.py b/app/content/views/user_bio.py new file mode 100644 index 00000000..fcb7da4c --- /dev/null +++ b/app/content/views/user_bio.py @@ -0,0 +1,54 @@ +from rest_framework import status +from rest_framework.response import Response + +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet +from app.content.models.user_bio import UserBio +from app.content.serializers.user_bio import ( + UserBioCreateSerializer, + UserBioSerializer, + UserBioUpdateSerializer, +) + + +class UserBioViewset(BaseViewSet): + queryset = UserBio.objects.all() + serializer_class = UserBioSerializer + permission_classes = [BasicViewPermission] + + def create(self, request, *args, **kwargs): + data = request.data + + serializer = UserBioCreateSerializer(data=data, context={"request": request}) + + if not serializer.is_valid(): + return Response( + {"detail": serializer.errors}, status=status.HTTP_400_BAD_REQUEST + ) + + user_bio = super().perform_create(serializer, user=request.user) + + user_bio_serializer = UserBioSerializer( + user_bio, context={"user": user_bio.user} + ) + + return Response(user_bio_serializer.data, status=status.HTTP_201_CREATED) + + def update(self, request, *args, **kwargs): + bio = self.get_object() + serializer = UserBioUpdateSerializer( + bio, data=request.data, context={"request": request} + ) + if serializer.is_valid(): + bio = super().perform_update(serializer) + return Response(serializer.data, status=status.HTTP_200_OK) + + return Response( + {"detail": serializer.errors}, status=status.HTTP_400_BAD_REQUEST + ) + + def destroy(self, request, *args, **kwargs): + super().destroy(request, *args, **kwargs) + return Response( + {"detail": ("Brukerbio ble slettet")}, status=status.HTTP_200_OK + ) diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 3d864bb0..14aae4af 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -22,6 +22,7 @@ QRCodeFactory, RegistrationFactory, ShortLinkFactory, + UserBioFactory, UserFactory, ) from app.content.factories.toddel_factory import ToddelFactory @@ -290,6 +291,11 @@ def event_with_priority_pool(priority_group): return event +@pytest.fixture() +def user_bio(): + return UserBioFactory() + + @pytest.fixture() def minute(user): return MinuteFactory(author=user) diff --git a/app/tests/content/test_user_bio_integration.py b/app/tests/content/test_user_bio_integration.py new file mode 100644 index 00000000..a55978d3 --- /dev/null +++ b/app/tests/content/test_user_bio_integration.py @@ -0,0 +1,124 @@ +from rest_framework import status + +import pytest + +from app.content.models.user_bio import UserBio +from app.util.test_utils import get_api_client + +pytestmark = pytest.mark.django_db + +API_USER_BIO_BASE_URL = "/user-bios/" + + +def _get_bio_url(user_bio): + return f"{API_USER_BIO_BASE_URL}{user_bio.id}/" + + +def _get_user_bio_post_data(): + return { + "description": "this is my description", + "gitHub_link": "https://www.github.com", + "linkedIn_link": "https://www.linkedIn.com", + } + + +def _get_user_bio_put_data(): + return { + "description": "New description", + } + + +@pytest.mark.django_db +def test_create_user_bio(member, api_client): + """A user should be able to create a user bio""" + data = _get_user_bio_post_data() + client = api_client(user=member) + response = client.post(API_USER_BIO_BASE_URL, data) + + assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +def test_create_duplicate_user_bio(member, api_client, user_bio): + """A user should not be able to create a duplicate user bio""" + user_bio.user = member + user_bio.save() + + data = _get_user_bio_post_data() + client = api_client(user=member) + response = client.post(API_USER_BIO_BASE_URL, data) + + assert response.status_code == status.HTTP_409_CONFLICT + + +@pytest.mark.django_db +def test_update_bio_as_anonymous_user(default_client, user_bio): + """An anonymous user should not be able to update a user's bio""" + url = _get_bio_url(user_bio) + data = _get_user_bio_put_data() + response = default_client.put(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + user_bio.refresh_from_db() + assert user_bio.description != data["description"] + + +@pytest.mark.django_db +def test_update_own_bio_as_user(member, user_bio): + """An user should be able to update their own bio""" + user_bio.user = member + user_bio.save() + url = _get_bio_url(user_bio) + client = get_api_client(user=member) + data = _get_user_bio_put_data() + response = client.put(url, data) + + assert response.status_code == status.HTTP_200_OK + user_bio.refresh_from_db() + assert user_bio.description == data["description"] + + +@pytest.mark.django_db +def test_update_another_users_bio(member, user_bio): + """An user should not be able to update another user's bio""" + url = _get_bio_url(user_bio) + client = get_api_client(user=member) + data = _get_user_bio_put_data() + response = client.put(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + user_bio.refresh_from_db() + assert user_bio.description != data["description"] + + +@pytest.mark.django_db +def test_destroy_bio_as_anonymous_user(default_client, user_bio): + """An anonymous user should not be able to destroy a user's bio""" + url = _get_bio_url(user_bio) + response = default_client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert len(UserBio.objects.filter(id=user_bio.id)) + + +@pytest.mark.django_db +def test_destroy_own_bio(user_bio, member): + """An user should be able to destroy their own user's bio""" + user_bio.user = member + user_bio.save() + url = _get_bio_url(user_bio) + client = get_api_client(user=member) + response = client.delete(url) + assert response.status_code == status.HTTP_200_OK + assert not len(UserBio.objects.filter(id=user_bio.id)) + + +@pytest.mark.django_db +def test_destroy_other_bios(member, user_bio): + """An user should not be able to delete another user's bio""" + url = _get_bio_url(user_bio) + client = get_api_client(user=member) + response = client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert len(UserBio.objects.filter(id=user_bio.id)) diff --git a/app/tests/kontres/test_reservation_integration.py b/app/tests/kontres/test_reservation_integration.py index 7668c996..d78dc977 100644 --- a/app/tests/kontres/test_reservation_integration.py +++ b/app/tests/kontres/test_reservation_integration.py @@ -659,6 +659,9 @@ def test_retrieve_specific_reservation_within_its_date_range(member, bookable_it @pytest.mark.skip @pytest.mark.django_db +@pytest.mark.skip( + "This test is only working sometimes, needs to be fixed. Kontres backend team's responsibility." +) def test_retrieve_subset_of_reservations(member, bookable_item): client = get_api_client(user=member) From 3f5649620512f854a54866db116280ddb1a6a05d Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Tue, 16 Apr 2024 09:14:44 +0200 Subject: [PATCH 08/59] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17dd794b..d9ed4dcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ ## Neste versjon +## Versjon 2024.04.16 +- ✨ **Brukerbio**. Bruker kan nå opprette bio. + ## Versjon 2023.04.08 - ✨ **Codex** Index brukere kan nå opprette dokumenter og møtereferater i Codex. From 064da8a359e96ac68089f9778c438413735b5b22 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Wed, 17 Apr 2024 16:53:00 +0200 Subject: [PATCH 09/59] added filter for allowed photos for user (#794) added filter for allowed photos --- app/content/filters/user.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/content/filters/user.py b/app/content/filters/user.py index d44c492d..69ffd2e6 100644 --- a/app/content/filters/user.py +++ b/app/content/filters/user.py @@ -20,6 +20,10 @@ class UserFilter(FilterSet): ) in_group = CharFilter(method="filter_is_in_group", label="Only list users in group") + has_allowed_photo = BooleanFilter( + method="filter_has_allowed_photo", label="Has allowed photo" + ) + class Meta: model: User fields = [ @@ -52,3 +56,6 @@ def filter_has_active_strikes(self, queryset, name, value): if value is False: return queryset.exclude(strikes__in=Strike.objects.active()).distinct() return queryset.filter(strikes__in=Strike.objects.active()).distinct() + + def filter_has_allowed_photo(self, queryset, name, value): + return queryset.filter(allows_photo_by_default=value) From 81a3c5ed47c7f58040018726f77ee67e970a7ec9 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Wed, 17 Apr 2024 17:58:47 +0200 Subject: [PATCH 10/59] Upped payment time when coming from waiting list (#796) --- app/content/models/registration.py | 4 +++- app/content/util/event_utils.py | 11 ++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/content/models/registration.py b/app/content/models/registration.py index 795bdc9c..418cb145 100644 --- a/app/content/models/registration.py +++ b/app/content/models/registration.py @@ -135,7 +135,9 @@ def delete(self, *args, **kwargs): if moved_registration.event.is_paid_event: try: start_payment_countdown( - moved_registration.event, moved_registration + moved_registration.event, + moved_registration, + from_wait_list=True, ) except Exception as countdown_error: capture_exception(countdown_error) diff --git a/app/content/util/event_utils.py b/app/content/util/event_utils.py index 30852acd..d6343190 100644 --- a/app/content/util/event_utils.py +++ b/app/content/util/event_utils.py @@ -12,7 +12,7 @@ ) -def start_payment_countdown(event, registration): +def start_payment_countdown(event, registration, from_wait_list=False): """ Checks if event is a paid event and starts the countdown for payment for an user. @@ -24,13 +24,18 @@ def start_payment_countdown(event, registration): try: check_if_has_paid.apply_async( args=(event.id, registration.registration_id), - countdown=get_countdown_time(event), + countdown=get_countdown_time(event, from_wait_list), ) except Exception as payment_countdown_error: capture_exception(payment_countdown_error) -def get_countdown_time(event): +def get_countdown_time(event, from_wait_list=False): + if from_wait_list: + # 12 hours and 10 minutes as seconds + return (12 * 60 * 60) + (10 * 60) + + # paytime as seconds paytime = event.paid_information.paytime return (paytime.hour * 60 + paytime.minute + 10) * 60 + paytime.second From a583c456d9805bad2cf81d0503fb76e1fe7df209 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Wed, 17 Apr 2024 18:21:48 +0200 Subject: [PATCH 11/59] fixed paymenttime saved to db (#798) --- app/content/models/registration.py | 4 +--- app/content/util/registration_utils.py | 8 ++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/app/content/models/registration.py b/app/content/models/registration.py index 418cb145..92224b56 100644 --- a/app/content/models/registration.py +++ b/app/content/models/registration.py @@ -357,9 +357,7 @@ def move_from_waiting_list_to_queue(self): 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 - ) + registration_move_to_queue.payment_expiredate = get_payment_expiredate() return registration_move_to_queue diff --git a/app/content/util/registration_utils.py b/app/content/util/registration_utils.py index 1e024c7b..f9609089 100644 --- a/app/content/util/registration_utils.py +++ b/app/content/util/registration_utils.py @@ -1,9 +1,5 @@ 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, - ) +def get_payment_expiredate(): + return datetime.now() + timedelta(hours=12) From 0f24085a9d115830fa464382742ab4c59787f7a0 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Wed, 17 Apr 2024 20:43:28 +0200 Subject: [PATCH 12/59] fixed bug (#800) --- app/content/util/registration_utils.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/content/util/registration_utils.py b/app/content/util/registration_utils.py index f9609089..8f91017f 100644 --- a/app/content/util/registration_utils.py +++ b/app/content/util/registration_utils.py @@ -1,5 +1,12 @@ from datetime import datetime, timedelta -def get_payment_expiredate(): - return datetime.now() + timedelta(hours=12) +def get_payment_expiredate(event=None): + if not event: + return datetime.now() + timedelta(hours=12) + + return datetime.now() + timedelta( + hours=event.paid_information.paytime.hour, + minutes=event.paid_information.paytime.minute, + seconds=event.paid_information.paytime.second, + ) From e597268e5f0754d08739171cea0125d0fc13e24a Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Wed, 1 May 2024 21:35:16 +0200 Subject: [PATCH 13/59] Disallow users to unregister when payment is done (#802) added 400 status code for deleting paid registration --- app/content/views/registration.py | 17 +++++++++ .../content/test_registration_integration.py | 38 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/app/content/views/registration.py b/app/content/views/registration.py index 11bb2e6c..2b5d680a 100644 --- a/app/content/views/registration.py +++ b/app/content/views/registration.py @@ -24,6 +24,7 @@ from app.content.util.event_utils import start_payment_countdown from app.payment.enums import OrderStatus from app.payment.models.order import Order +from app.payment.util.order_utils import has_paid_order class RegistrationViewSet(APIRegistrationErrorsMixin, BaseViewSet): @@ -121,11 +122,27 @@ def destroy(self, request, *args, **kwargs): def _unregister(self, registration): self._log_on_destroy(registration) + + if self._registration_is_paid(registration): + return Response( + { + "detail": "Du kan ikke melde deg av et arrangement du har betalt for." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + registration.delete() return Response( {"detail": "Du har blitt meldt av arrangementet"}, status=status.HTTP_200_OK ) + def _registration_is_paid(self, registration): + event = registration.event + if event.is_paid_event: + orders = event.orders.filter(user=registration.user) + return has_paid_order(orders) + return False + def _admin_unregister(self, registration): self._log_on_destroy(registration) registration.admin_unregister() diff --git a/app/tests/content/test_registration_integration.py b/app/tests/content/test_registration_integration.py index 772003d0..36fda996 100644 --- a/app/tests/content/test_registration_integration.py +++ b/app/tests/content/test_registration_integration.py @@ -10,6 +10,7 @@ from app.forms.enums import EventFormType from app.forms.tests.form_factories import EventFormFactory, SubmissionFactory from app.group.factories import GroupFactory +from app.payment.enums import OrderStatus from app.util.test_utils import add_user_to_group_with_name, get_api_client from app.util.utils import now @@ -1031,3 +1032,40 @@ def test_add_registration_to_event_as_member(member, event): response = client.post(url, data) assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("order_status", "status_code"), + [ + (OrderStatus.SALE, status.HTTP_400_BAD_REQUEST), + (OrderStatus.CAPTURE, status.HTTP_400_BAD_REQUEST), + (OrderStatus.RESERVED, status.HTTP_400_BAD_REQUEST), + (OrderStatus.CANCEL, status.HTTP_200_OK), + (OrderStatus.INITIATE, status.HTTP_200_OK), + (OrderStatus.REFUND, status.HTTP_200_OK), + (OrderStatus.VOID, status.HTTP_200_OK), + ], +) +def test_delete_registration_with_paid_order_as_self( + member, event, order, paid_event, order_status, status_code +): + """ + A member should not be able to delete their registration if they have a paid order. + """ + + order.status = order_status + order.event = event + order.user = member + order.save() + + paid_event.event = event + paid_event.save() + + registration = RegistrationFactory(user=member, event=event) + client = get_api_client(user=member) + + url = _get_registration_detail_url(registration) + response = client.delete(url) + + assert response.status_code == status_code From 3b84765466c618a62bbfd1d877b3514c41bfa4cd Mon Sep 17 00:00:00 2001 From: Mads Nylund Date: Wed, 1 May 2024 21:40:04 +0200 Subject: [PATCH 14/59] update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ed4dcf..6fc6c022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ ## Neste versjon +## Versjon 2024.05.01 +- ⚡**Påmelding**. En bruker som har betalt for en påmelding på et arrangement kan ikke lenger melde seg av. + ## Versjon 2024.04.16 - ✨ **Brukerbio**. Bruker kan nå opprette bio. From f21e0ab1ecf47b87579b173ab04e2484b7bdb8cb Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Thu, 2 May 2024 12:51:17 +0200 Subject: [PATCH 15/59] Added serializer for category in event (#804) added serializer for category in event --- CHANGELOG.md | 1 + app/content/serializers/category.py | 6 ++++++ app/content/serializers/event.py | 3 +++ 3 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc6c022..3174fbc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ## Neste versjon ## Versjon 2024.05.01 +- ⚡**Arrangement**. Et arrangement vil nå få kategori sendt som navn på kategori istedenfor kun id. - ⚡**Påmelding**. En bruker som har betalt for en påmelding på et arrangement kan ikke lenger melde seg av. ## Versjon 2024.04.16 diff --git a/app/content/serializers/category.py b/app/content/serializers/category.py index d5e07ee7..dc60ed8f 100644 --- a/app/content/serializers/category.py +++ b/app/content/serializers/category.py @@ -7,3 +7,9 @@ class CategorySerializer(BaseModelSerializer): class Meta: model = Category fields = "__all__" # bad form + + +class SimpleCategorySerializer(BaseModelSerializer): + class Meta: + model = Category + fields = ("id", "text") diff --git a/app/content/serializers/event.py b/app/content/serializers/event.py index 737b8a81..f2deee1b 100644 --- a/app/content/serializers/event.py +++ b/app/content/serializers/event.py @@ -6,6 +6,7 @@ from app.common.enums import GroupType from app.common.serializers import BaseModelSerializer from app.content.models import Event, PriorityPool +from app.content.serializers.category import SimpleCategorySerializer from app.content.serializers.priority_pool import ( PriorityPoolCreateSerializer, PriorityPoolSerializer, @@ -30,6 +31,7 @@ class EventSerializer(serializers.ModelSerializer): ) contact_person = DefaultUserSerializer(read_only=True, required=False) reactions = ReactionSerializer(required=False, many=True) + category = SimpleCategorySerializer(read_only=True) class Meta: model = Event @@ -104,6 +106,7 @@ def validate_limit(self, limit): class EventListSerializer(serializers.ModelSerializer): expired = serializers.BooleanField(read_only=True) organizer = SimpleGroupSerializer(read_only=True) + category = SimpleCategorySerializer(read_only=True) class Meta: model = Event From 64d717cd4d9171e28031fed820523b290ed375a4 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Sun, 9 Jun 2024 18:36:24 +0200 Subject: [PATCH 16/59] Permission middelware (#806) * added a check for existing user and id on request * format --- app/common/permissions.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/common/permissions.py b/app/common/permissions.py index 84d77af3..2214c1cc 100644 --- a/app/common/permissions.py +++ b/app/common/permissions.py @@ -61,6 +61,12 @@ def check_has_access(groups_with_access, request): def set_user_id(request): + # If the id and user of the request is already set, return + if (hasattr(request, "id") and request.id) and ( + hasattr(request, "user") and request.user + ): + return + token = request.META.get("HTTP_X_CSRF_TOKEN") request.id = None request.user = None From ed57afcf96217ad3c4e18c336c28399fa76dbf53 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Mon, 10 Jun 2024 00:03:19 +0200 Subject: [PATCH 17/59] Permission refactor of QR Codes (#807) * added permissions to qr code and refactored viewset * format * removed unused imports --- app/content/models/qr_code.py | 31 ++++++++++++++++++++++++++++++- app/content/views/qr_code.py | 7 +++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/content/models/qr_code.py b/app/content/models/qr_code.py index 476b4e33..aab7550c 100644 --- a/app/content/models/qr_code.py +++ b/app/content/models/qr_code.py @@ -1,7 +1,7 @@ from django.db import models from app.common.enums import Groups -from app.common.permissions import BasePermissionModel +from app.common.permissions import BasePermissionModel, check_has_access from app.content.models import User from app.util.models import BaseModel, OptionalImage @@ -20,3 +20,32 @@ class Meta: def __str__(self): return f"{self.name} - {self.user.user_id}" + + @classmethod + def has_read_permission(cls, request): + return check_has_access(cls.read_access, request) + + @classmethod + def has_retrieve_permission(cls, request): + return check_has_access(cls.read_access, request) + + @classmethod + def has_destroy_permission(cls, request): + return check_has_access(cls.write_access, request) + + @classmethod + def has_create_permission(cls, request): + return check_has_access(cls.write_access, request) + + @classmethod + def has_update_permission(cls, request): + return check_has_access(cls.write_access, request) + + def has_object_retrieve_permission(self, request): + return request.user == self.user + + def has_object_update_permission(self, request): + return request.user == self.user + + def has_object_destroy_permission(self, request): + return request.user == self.user diff --git a/app/content/views/qr_code.py b/app/content/views/qr_code.py index db9316f3..478546e6 100644 --- a/app/content/views/qr_code.py +++ b/app/content/views/qr_code.py @@ -1,10 +1,9 @@ -from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.response import Response from app.common.permissions import BasicViewPermission from app.common.viewsets import BaseViewSet -from app.content.models import QRCode, User +from app.content.models import QRCode from app.content.serializers.qr_code import ( QRCodeCreateSerializer, QRCodeSerializer, @@ -19,11 +18,11 @@ class QRCodeViewSet(BaseViewSet): def get_queryset(self): if hasattr(self, "action") and self.action == "retrieve": return super().get_queryset() - user = get_object_or_404(User, user_id=self.request.id) + user = self.request.user return super().get_queryset().filter(user=user) def create(self, request, *args, **kwargs): - user = get_object_or_404(User, user_id=request.id) + user = request.user data = request.data serializer = QRCodeCreateSerializer(data=data, context={"request": request}) From ab3cf158e751120fed454fe9df4b705bff2e878c Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Mon, 10 Jun 2024 12:49:13 +0200 Subject: [PATCH 18/59] Permissions for payment orders (#808) * added read permissions * added permissions for payment order and tests * format --- app/common/permissions.py | 3 + app/content/serializers/user.py | 10 ++- app/content/views/user.py | 14 ++-- app/payment/models/order.py | 62 ++++++++++++++---- app/payment/views/order.py | 57 ++++++++++++++++- app/tests/payment/test_order_integration.py | 71 ++++++++++++++++++--- 6 files changed, 187 insertions(+), 30 deletions(-) diff --git a/app/common/permissions.py b/app/common/permissions.py index 2214c1cc..a7aa169a 100644 --- a/app/common/permissions.py +++ b/app/common/permissions.py @@ -47,6 +47,9 @@ def check_has_access(groups_with_access, request): set_user_id(request) user = request.user + if not user: + return False + try: groups = map(str, groups_with_access) return ( diff --git a/app/content/serializers/user.py b/app/content/serializers/user.py index d1bd1c54..b92ebbe2 100644 --- a/app/content/serializers/user.py +++ b/app/content/serializers/user.py @@ -177,7 +177,15 @@ def get_fields(self): class UserPermissionsSerializer(serializers.ModelSerializer): permissions = DRYGlobalPermissionsField( - actions=["write", "write_all", "read", "destroy", "update", "retrieve"] + actions=[ + "write", + "write_all", + "read", + "read_all", + "destroy", + "update", + "retrieve", + ] ) class Meta: diff --git a/app/content/views/user.py b/app/content/views/user.py index 90d787b2..9db67ca8 100644 --- a/app/content/views/user.py +++ b/app/content/views/user.py @@ -170,10 +170,16 @@ def connect_to_slack(self, request, *args, **kwargs): @action(detail=False, methods=["get"], url_path="me/permissions") def get_user_permissions(self, request, *args, **kwargs): - serializer = UserPermissionsSerializer( - request.user, context={"request": request} - ) - return Response(serializer.data, status=status.HTTP_200_OK) + try: + serializer = UserPermissionsSerializer( + request.user, context={"request": request} + ) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception: + return Response( + {"detail": "Kunne ikke hente brukerens tillatelser"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) @action(detail=True, methods=["get"], url_path="memberships") def get_user_memberships(self, request, pk, *args, **kwargs): diff --git a/app/payment/models/order.py b/app/payment/models/order.py index f5c2454d..dd221467 100644 --- a/app/payment/models/order.py +++ b/app/payment/models/order.py @@ -2,12 +2,11 @@ from django.db import models -from app.common.enums import AdminGroup +from app.common.enums import Groups from app.common.permissions import ( BasePermissionModel, - is_admin_group_user, + check_has_access, is_admin_user, - is_index_user, ) from app.content.models.event import Event from app.content.models.user import User @@ -16,7 +15,8 @@ class Order(BaseModel, BasePermissionModel): - access = AdminGroup.admin() + read_access = (Groups.TIHLDE,) + order_id = models.UUIDField( auto_created=True, default=uuid.uuid4, primary_key=True, serialize=False ) @@ -40,28 +40,64 @@ def __str__(self): @classmethod def has_update_permission(cls, request): - return is_admin_user(request) + return False @classmethod def has_destroy_permission(cls, request): - return is_index_user(request) + return False @classmethod def has_retrieve_permission(cls, request): - return is_admin_group_user(request) + if not request.user: + return False + + return ( + check_has_access(cls.read_access, request) + or is_admin_user(request) + or request.user.memberships_with_events_access.exists() + ) @classmethod def has_read_permission(cls, request): - return is_admin_group_user(request) + if not request.user: + return False - def has_object_read_permission(self, request): - return self.has_read_permission(request) + return ( + check_has_access(cls.read_access, request) + or request.user.memberships_with_events_access.exists() + ) + + @classmethod + def has_list_permission(cls, request): + return is_admin_user(request) + + @classmethod + def has_read_all_permission(cls, request): + return is_admin_user(request) def has_object_update_permission(self, request): - return self.has_update_permission(request) + return False def has_object_destroy_permission(self, request): - return self.has_destroy_permission(request) + return False def has_object_retrieve_permission(self, request): - return self.has_retrieve_permission(request) + if not request.user: + return False + + organizer = self.event.organizer + + return ( + self.check_request_user_has_access_through_organizer( + request.user, organizer + ) + or is_admin_user(request) + or self.user == request.user + ) + + def check_request_user_has_access_through_organizer(self, user, organizer): + # All memberships that have access to events will also have access to orders + if not organizer: + return False + + return user.memberships_with_events_access.filter(group=organizer).exists() diff --git a/app/payment/views/order.py b/app/payment/views/order.py index ebe4f685..9c3b765e 100644 --- a/app/payment/views/order.py +++ b/app/payment/views/order.py @@ -1,14 +1,15 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, status +from rest_framework.decorators import action from rest_framework.response import Response from sentry_sdk import capture_exception from app.common.mixins import ActionMixin from app.common.pagination import BasePagination -from app.common.permissions import BasicViewPermission +from app.common.permissions import BasicViewPermission, is_admin_user from app.common.viewsets import BaseViewSet -from app.content.models import Registration, User +from app.content.models import Event, Registration, User from app.payment.filters.order import OrderFilter from app.payment.models import Order from app.payment.serializers import ( @@ -38,7 +39,7 @@ class OrderViewSet(BaseViewSet, ActionMixin): def retrieve(self, request, pk): try: - order = Order.objects.get(order_id=pk) + order = self.get_object() serializer = OrderSerializer( order, context={"request": request}, many=False ) @@ -103,3 +104,53 @@ def create(self, request, *args, **kwargs): {"detail": "Fant ikke bruker."}, status=status.HTTP_404_NOT_FOUND, ) + + @action(detail=False, methods=["GET"], url_path=r"event/(?P\d+)") + def event_orders(self, request, event_id): + try: + if is_admin_user(request): + orders = Order.objects.filter(event=event_id) + serializer = OrderListSerializer( + orders, context={"request": request}, many=True + ) + return Response(serializer.data, status.HTTP_200_OK) + + event = Event.objects.filter(id=event_id).first() + + if not event: + return Response( + {"detail": "Fant ikke arrangement."}, + status=status.HTTP_404_NOT_FOUND, + ) + + organizer = event.organizer + + if not organizer: + return Response( + {"detail": "Du har ikke tilgang til disse betalingsordrene."}, + status=status.HTTP_403_FORBIDDEN, + ) + + has_access_through_organizer = ( + request.user.memberships_with_events_access.filter( + group=organizer + ).exists() + ) + + if not has_access_through_organizer: + return Response( + {"detail": "Du har ikke tilgang til disse betalingsordrene."}, + status=status.HTTP_403_FORBIDDEN, + ) + + orders = Order.objects.filter(event=event) + + serializer = OrderListSerializer( + orders, context={"request": request}, many=True + ) + return Response(serializer.data, status.HTTP_200_OK) + except Exception: + return Response( + {"detail": "Det skjedde en feil på serveren."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/app/tests/payment/test_order_integration.py b/app/tests/payment/test_order_integration.py index df2cc14c..f3ef2461 100644 --- a/app/tests/payment/test_order_integration.py +++ b/app/tests/payment/test_order_integration.py @@ -3,7 +3,10 @@ import pytest from app.common.enums import AdminGroup +from app.group.factories import GroupFactory +from app.group.models import Group from app.payment.enums import OrderStatus +from app.payment.factories import OrderFactory from app.util.test_utils import add_user_to_group_with_name, get_api_client API_ORDERS_BASE_URL = "/payments/" @@ -29,9 +32,9 @@ def test_list_orders_as_user(member): @pytest.mark.django_db -@pytest.mark.parametrize("group_name", AdminGroup.all()) +@pytest.mark.parametrize("group_name", AdminGroup.admin()) def test_list_orders_as_admin_user(member, group_name): - """A member of an admin group should be able to list orders.""" + """An admin or index user should be able to list orders.""" add_user_to_group_with_name(member, group_name) client = get_api_client(user=member) response = client.get(API_ORDERS_BASE_URL) @@ -54,9 +57,19 @@ def test_retrieve_order_as_member(member, order): @pytest.mark.django_db -@pytest.mark.parametrize("group_name", AdminGroup.all()) +def test_retrieve_own_order_as_member(member, order): + """A user should be able to retrieve their own order.""" + order.user = member + order.save() + client = get_api_client(user=member) + response = client.get(get_orders_url_detail(order.order_id)) + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +@pytest.mark.parametrize("group_name", AdminGroup.admin()) def test_retrieve_order_as_admin_user(member, order, group_name): - """A member of an adming group should be able to retrieve an order.""" + """An admin or member of Index should be able to retrieve an order.""" add_user_to_group_with_name(member, group_name) client = get_api_client(user=member) response = client.get(get_orders_url_detail(order.order_id)) @@ -81,11 +94,11 @@ def test_delete_order_as_member(member, order): @pytest.mark.django_db @pytest.mark.parametrize("group_name", [AdminGroup.INDEX]) def test_delete_order_as_index_user(member, order, group_name): - """An index user should be able to delete an order.""" + """An index user should not be able to delete an order.""" add_user_to_group_with_name(member, group_name) client = get_api_client(user=member) response = client.delete(get_orders_url_detail(order.order_id)) - assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.status_code == status.HTTP_403_FORBIDDEN @pytest.mark.django_db @@ -106,13 +119,53 @@ def test_update_order_as_member(member, order): @pytest.mark.django_db @pytest.mark.parametrize("group_name", [*AdminGroup.admin()]) def test_update_order_as_admin_user(member, order, group_name): - """An index and HS user should be able to update an order.""" + """An index and HS user should not be able to update an order.""" add_user_to_group_with_name(member, group_name) client = get_api_client(user=member) data = {"status": OrderStatus.SALE} response = client.put(get_orders_url_detail(order.order_id), data=data) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_list_all_orders_for_event_as_organizer(member, event): + """ + A member of an organizer group should be able to list all orders for an event. + """ + add_user_to_group_with_name(member, AdminGroup.SOSIALEN) + organizer = Group.objects.get(name=AdminGroup.SOSIALEN) + + event.organizer = organizer + event.save() + + orders = [OrderFactory(event=event) for _ in range(3)] + + url = f"{API_ORDERS_BASE_URL}event/{event.id}/" + client = get_api_client(user=member) + + response = client.get(url) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == len(orders) + + +@pytest.mark.django_db +def test_list_all_orders_for_event_as_non_organizer(member, event): + """ + A member of a group that is not the organizer should not be able to list all orders for an event. + """ + add_user_to_group_with_name(member, AdminGroup.NOK) + GroupFactory(name=AdminGroup.KOK) + organizer = Group.objects.get(name=AdminGroup.KOK) + + event.organizer = organizer + event.save() + + [OrderFactory(event=event) for _ in range(3)] - order.refresh_from_db() + url = f"{API_ORDERS_BASE_URL}event/{event.id}/" + client = get_api_client(user=member) + + response = client.get(url) - assert order.status == OrderStatus.SALE + assert response.status_code == status.HTTP_403_FORBIDDEN From 062193d6ae9e8a9bdd5051d741954e03d23dc37c Mon Sep 17 00:00:00 2001 From: martcl Date: Sat, 27 Jul 2024 00:22:14 +0200 Subject: [PATCH 19/59] chore(iac): updated docs and force https (#810) chore: updated docs and force https --- infrastructure/CODEOWNERS | 1 - infrastructure/README.md | 118 +++++++++++++++++++++++++---------- infrastructure/containers.tf | 41 +++++++++--- infrastructure/database.tf | 45 ++++++++++--- infrastructure/inputs.tf | 4 -- infrastructure/storage.tf | 1 + infrastructure/vnet.tf | 5 +- main.tf | 6 +- 8 files changed, 161 insertions(+), 60 deletions(-) delete mode 100644 infrastructure/CODEOWNERS diff --git a/infrastructure/CODEOWNERS b/infrastructure/CODEOWNERS deleted file mode 100644 index c099a5ad..00000000 --- a/infrastructure/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -@martcl diff --git a/infrastructure/README.md b/infrastructure/README.md index 6d001056..401dd0de 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -1,44 +1,97 @@ # Infrastructre -What brave souls are wandering around in these parts? Infrastructure might be a bit big and scary, but don't worry, we'll get through this together. After reading this, you'll be able to: - -- Understand the basic concepts of infrastructure as Code -- Understand basic terraform concepts -- Understand basic Azure concepts -- Be able to contribute to this infrastructure +What brave souls are wandering around in these parts? Infrastructure might be a bit big and scary, but don't worry, we'll get through this together. There are some comments in cuppled in the `/infrastructure` folder that might help you with questions about infra choices that was done when this was created. ## Overview First of all, IaC (infrastructre as code) is a way of managing infrastructure in a declerative way. Imagine you are a customer at a resturant. You don't go into the kitchen and tell the chef how to cook your food, you just tell the waiter what you want and the chef will make it for you. This is the same way IaC works. You tell the cloud provider what you want, and they will make it for you. We use terraform to do this. Terraform is a tool that allows us to write code that will be translated into infrastructure. This is done by writing code using the hashicorp language HCL (Hashicorp Configuration Language). **The code should be written in a way that is easy to understand and easy to read.** This is because the code itelf is the documentation. -Now with that out of the way, let's get into the actual infrastructure. We use Azure as our cloud provider. This means that we use Azure to host our infrastructure. This documentation will not go into detail about how Azure works, but it will explain the basics of how we use it. +We use Azure as our cloud provider. This means that we use Azure to host our infrastructure. This documentation will not go into detail about how Azure works, but it will explain the basics of how we use it. ### You need - Terraform cli - Azure cli ### Contributing to the infrastructure -First of all, you need to create a service principal to use with terraform authentication to Azure. Look at the section "Setup from scratch" to see how to do this. +You need access to TIHLDE's Azure subscription to be able to contribute to the infrastructure. See Azure auth section from [Setup from scratch](#setup-from-scratch) for info about how to authenticate to Azure. -We have multiple enviroments for our infra, `dev` and `pro`. Dev is used for development and correspond to api-dev.tihlde.org and pro is used for production and correspond to api.tihlde.org. When you are working on the infrastructure, you should always work in the `dev` environment. This is done by running the following command: +We have multiple enviroments for our infra, `dev` and `pro`. Dev is used for development and correspond to api-dev.tihlde.org and pro is used for production and correspond to api.tihlde.org. When you are working on the infrastructure, you should always work in the `dev` environment when playing. This is done by running the following command: ```bash -terraform workspace select dev terraform init +terraform workspace select dev +``` + +After selecting the correct enviroment, you must have the correct `terraform.tfvars` file in the root of the project. This file contains the variables that are used to configure the infrastructure. Ask some of the other developers for the correct values. When you have the correct values, you can run `terraform plan -vars-file dev.tfvars` to see what will be changed. + +> ⚠️ Don't run "terraform apply -vars-file <>" if you don't know what you are doing. You need to be sure that this is correct and don't nuke our infra before applying any changes. Allways run the `plan` command first. + +When you are done making changes, you can commit and push your changes to Github. DO NOT push your `*.tfvars` file to Github. These file contain sensitive information and should not be shared with randos. + +### How to do common changes + +#### Changing existing environment variables to new values + +Switch to the terraform workspace where you want to make the change. + +```bash +terraform wokspace select dev ``` -After selecting the correct enviroment, you must have the correct `terraform.tfvars` file in the root of the project. This file contains the variables that are used to configure the infrastructure. Ask some of the other developers for the correct values. When you have the correct values, you can run `terraform plan` to see what will be changed. +Make changes to the `dev.tfvars` file and run terraform plan to see what will be changed. + +```bash +terraform plan -vars-file dev.tfvars +``` -> ⚠️ Don't run "terraform apply" locally. This will change the infrastructure in the cloud. This task should be done by Github Actions. Keep this as a rule of thumb. +If everything looks good, you can apply the changes. -When you are done making changes, you can commit and push your changes to Github. This will trigger a Github Action that will run terraform plan. Inspect this plan to see if everything looks good. If it does, you can merge it to master. This will trigger another Github Action that will run terraform apply. This will change the infrastructure in the cloud. +```bash +terraform apply -vars-file dev.tfvars +``` + +#### Adding new environment variables from tfvars file + +Go to the file that manages containers and add a new `env` block on both lepton rest api and lepton celery. + +```hcl +env { + name = "MY_NEW_ENV_VAR" + value = var.my_new_env_var +} +``` +You allso need to pass this variable down into the terraform module `infrastucture` in the `inputs.tf` file. + +```hcl +variable "my_new_env_var" { + type = string + sensitive = true # Add this if it is sensitive +} +``` + +Now add the variable to the `dev.tfvars` file and pass it down to the module. + + +After you are done, run terraform plan to see what will be changed. Then apply the changes. + +### We are fucked! Something is absolutely broken + +If you don't know how to do it, you should not do it. + +* You can roll back the database max 7 days. +* You can go into the Azure portal and look at logs +* See if the container app revision is failing. +* You can fix the fault, push new changes to github, restart the revision after new image is uploaded. +* You can access the running containers directly from browsers with portal to find issues. (I would rather recommend az cli for this) +* You can send a message to Azure support. (they can't access our infra so they only answer questions) +* You can do dirty migration hot-fixes directly from the containers. (celery container is best for this) ### Setup from scratch If you are setting up the infrastructure from scratch, you will need to do a few things. First of all, you will need to setup a storage account to store the terraform state. This is done by running the following command ([source](https://learn.microsoft.com/en-us/azure/developer/terraform/store-state-in-azure-storage?tabs=azure-cli)): ```bash #!/bin/bash -RESOURCE_GROUP_NAME=tfstate +RESOURCE_GROUP_NAME=devops STORAGE_ACCOUNT_NAME=tfstatetihlde # must be globaly unique CONTAINER_NAME=tfstate @@ -54,8 +107,18 @@ az storage account create --resource-group $RESOURCE_GROUP_NAME --name $STORAGE_ az storage container create --name $CONTAINER_NAME --account-name $STORAGE_ACCOUNT_NAME ``` -With that out of the way, you will need to create a service principal to use with terraform authentication to Azure. This is done by running the following commands ([source](https://learn.microsoft.com/en-us/azure/developer/terraform/get-started-cloud-shell-bash?tabs=bash)): +With that out of the way, you will need to authenticate to Azure so that terraform can make talk to Azure. There are two easy options, first is to az login and select the correct subscription. The second option is to use a service principal account. +#### Auth option 1 +```bash +az account set --subscription "" +az login +``` + +#### Auth option 2 +You are now creating private credentials. Do not share this with anyone. Create seperate creds for each person. + +[source](https://learn.microsoft.com/en-us/azure/developer/terraform/get-started-cloud-shell-bash?tabs=bash) ```bash #!/bin/bash export MSYS_NO_PATHCONV=1 @@ -77,9 +140,7 @@ The command with output something similar to this: } ``` -When working locally, the easiest way to add these values is to add them to the `~/.bashrc` file. This is done to simplify the terraform setup. Add the following lines to the `~/.bashrc` file: - - +Then fill this in with the correct values and run it. ```bash export ARM_SUBSCRIPTION_ID="" export ARM_TENANT_ID="" @@ -87,15 +148,7 @@ export ARM_CLIENT_ID="" export ARM_CLIENT_SECRET="" ``` -Remember to run `source ~/.bashrc` after you have added these values.😉 - - -> Little recap on what we just did: -> -> - We created a storage account to store the terraform state -> - We created a service principal to authenticate against Azure -> - We added the values of the service principal to the `~/.bashrc` file - +### Running terraform We are now ready to start working with terraform localy. We want to have a `dev` and `prod` environment. This is done by creating terraform workspaces. You can create a workspace by running the following command: @@ -110,15 +163,14 @@ Select the workspace you want to work in by running the following command: terraform workspace select dev ``` -Try changing the infrastructre a bit and run `terraform plan` to see what will be changed. - +Create a `terraform.tfvars` file in the root of the project with ok values. +Try changing the infrastructre a bit and run `terraform plan -vars-file terraform.tfvars` to see what will be changed. -!!!!⚠️ +> ⚠️ Remember to delete your infra when you are done playing around with it. This is done by running the following command: ```bash -terraform destroy -``` - +terraform destroy -vars-file terraform.tfvars +``` \ No newline at end of file diff --git a/infrastructure/containers.tf b/infrastructure/containers.tf index 4950fe02..856f795c 100644 --- a/infrastructure/containers.tf +++ b/infrastructure/containers.tf @@ -1,3 +1,9 @@ +/* +We host Lepton in Azure Container Apps. TLDR: This is a simple service +that autoscales containers, manages network, sertificates and logging. +This was the cheapest service on azure for our usecase when it was created. +*/ + resource "azurerm_log_analytics_workspace" "lepton" { name = "logspace" location = azurerm_resource_group.lepton.location @@ -19,16 +25,30 @@ resource "azurerm_container_app_environment" "lepton" { tags = local.common_tags } +locals { + lepton_cpu = { + dev = 0.5 + pro = 1 + } + lepton_mem = { + dev = "1Gi" + pro = "2Gi" + } +} + resource "azurerm_container_app" "lepton-api" { name = "lepton-api" container_app_environment_id = azurerm_container_app_environment.lepton.id resource_group_name = azurerm_resource_group.lepton.name revision_mode = "Single" + // Required to not delete the manually created custom domain since + // it is not possible to create a managed certificate for a custom domain + // with terraform (2023) lifecycle { - ignore_changes = [ ingress ] // Required to not delete the manually created custom domain since it is not possible to create a managed certificate for a custom domain with terraform + ignore_changes = [ingress[0].custom_domain] } - + secret { name = "reg-passwd" value = azurerm_container_registry.lepton.admin_password @@ -45,10 +65,11 @@ resource "azurerm_container_app" "lepton-api" { max_replicas = var.lepton_api_max_replicas container { - name = "lepton-api" - image = "${azurerm_container_registry.lepton.login_server}/lepton:latest" - cpu = 1.0 - memory = "2Gi" + name = "lepton-api" + image = "${azurerm_container_registry.lepton.login_server}/lepton:latest" + + cpu = local.lepton_cpu[var.enviroment] + memory = local.lepton_mem[var.enviroment] env { name = "DATABASE_HOST" @@ -144,17 +165,17 @@ resource "azurerm_container_app" "lepton-api" { value = var.vipps_order_url } env { - name = "PROD" - value = var.debug + name = var.enviroment == "pro" ? "PROD" : "DEV" + value = "true" } } } - ingress { target_port = 8000 - allow_insecure_connections = true + allow_insecure_connections = false external_enabled = true + traffic_weight { percentage = 100 latest_revision = true diff --git a/infrastructure/database.tf b/infrastructure/database.tf index 82d007d7..15065dab 100644 --- a/infrastructure/database.tf +++ b/infrastructure/database.tf @@ -1,11 +1,21 @@ +/* +Everything related to the cloud databse setup is defined here. +It is important that we NEVER do changes to database resouces +that will affect the user data. If you se "destroy" in the +terraform plan on vital database resources... ask your elders if +it is ok. +*/ + resource "azurerm_mysql_flexible_server" "lepton-database-server" { - name = "lepton-database-${terraform.workspace}" - resource_group_name = azurerm_resource_group.lepton.name - location = azurerm_resource_group.lepton.location - administrator_login = random_string.database_username.result - administrator_password = random_password.database_password.result - delegated_subnet_id = azurerm_subnet.database.id - private_dns_zone_id = azurerm_private_dns_zone.lepton.id + name = "lepton-database-${terraform.workspace}" + resource_group_name = azurerm_resource_group.lepton.name + location = azurerm_resource_group.lepton.location + administrator_login = random_string.database_username.result + administrator_password = random_password.database_password.result + delegated_subnet_id = azurerm_subnet.database.id + private_dns_zone_id = azurerm_private_dns_zone.lepton.id + + // We can only roll back the database 7 days backup_retention_days = 7 sku_name = local.database_sku[terraform.workspace] geo_redundant_backup_enabled = false @@ -22,6 +32,8 @@ resource "azurerm_mysql_flexible_server" "lepton-database-server" { depends_on = [azurerm_private_dns_zone_virtual_network_link.lepton] } +// This setting was off when we moved to terraform +// and after testing it, it is required or else our migrations won't apply correctly resource "azurerm_mysql_flexible_server_configuration" "sql_generate_invisible_primary_key" { name = "sql_generate_invisible_primary_key" resource_group_name = azurerm_resource_group.lepton.name @@ -29,6 +41,8 @@ resource "azurerm_mysql_flexible_server_configuration" "sql_generate_invisible_p value = "OFF" } +// The database and backend are in a closed network inside Azure so +// it is not that important to keep encrypt network trafic resource "azurerm_mysql_flexible_server_configuration" "require_secure_transport" { name = "require_secure_transport" resource_group_name = azurerm_resource_group.lepton.name @@ -36,17 +50,32 @@ resource "azurerm_mysql_flexible_server_configuration" "require_secure_transport value = "OFF" } +// We store everything inside one mysql databse +// NEVER delete this resource in prod! resource "azurerm_mysql_flexible_database" "lepton-database" { name = "db" resource_group_name = azurerm_resource_group.lepton.name server_name = azurerm_mysql_flexible_server.lepton-database-server.name charset = "utf8mb4" - collation = "utf8mb4_0900_ai_ci" + collation = local.database_collation[var.enviroment] } locals { + // sku is the different machines that we rent from Azure. + // We use a cheaper one for dev, and a more expensive for pro. + // There might be wiggleroom in what machine size we need. database_sku = { dev = "B_Standard_B1s" pro = "B_Standard_B2s" } + // WHY DO WE HAVE DIFFERENT collation? + // this is left over from a bug that happend back in 2023... + // long story short. "utf8mb4_unicode_ci" is the correct format. + // Getting dev and pro back in sync is done by nuking dev enviroment + // and build it up with the same collation as pro enviroment. + // do not change this in prod, as it will result in data loss. + database_collation = { + dev = "utf8mb4_0900_ai_ci" + pro = "utf8mb4_unicode_ci" + } } diff --git a/infrastructure/inputs.tf b/infrastructure/inputs.tf index 81a1c347..c95871b3 100644 --- a/infrastructure/inputs.tf +++ b/infrastructure/inputs.tf @@ -69,7 +69,3 @@ variable "enviroment" { description = "value is either dev or pro" default = "dev" } - -variable "debug" { - default = "false" -} diff --git a/infrastructure/storage.tf b/infrastructure/storage.tf index 3af0ae0f..c86a94f2 100644 --- a/infrastructure/storage.tf +++ b/infrastructure/storage.tf @@ -1,3 +1,4 @@ +// This is where the images/pdf/uploads is stored for lepton resource "azurerm_storage_account" "lepton" { name = "leptonstorage${var.enviroment}" resource_group_name = azurerm_resource_group.lepton.name diff --git a/infrastructure/vnet.tf b/infrastructure/vnet.tf index f1901007..b3ab2cca 100644 --- a/infrastructure/vnet.tf +++ b/infrastructure/vnet.tf @@ -19,7 +19,10 @@ resource "azurerm_subnet" "database" { name = "database-subnet" resource_group_name = azurerm_resource_group.lepton.name virtual_network_name = azurerm_virtual_network.lepton.name - address_prefixes = ["10.0.8.0/21"] + + // We don't need this large network space for our databse + // but it made segmentation easier. + address_prefixes = ["10.0.8.0/21"] delegation { name = "fs" diff --git a/main.tf b/main.tf index 6eaf020c..10b49d13 100644 --- a/main.tf +++ b/main.tf @@ -33,9 +33,9 @@ module "infrastructure" { vipps_client_secret = var.vipps_client_secret vipps_merchant_serial_number = var.vipps_merchant_serial_number vipps_fallback_url = var.vipps_fallback_url - vipps_token_url = var.vipps_token_url - vipps_force_payment_url = var.vipps_force_payment_url - vipps_order_url = var.vipps_order_url + vipps_token_url = var.vipps_token_url + vipps_force_payment_url = var.vipps_force_payment_url + vipps_order_url = var.vipps_order_url lepton_api_min_replicas = var.lepton_api_min_replicas lepton_api_max_replicas = var.lepton_api_max_replicas From 23b310afa340ca9a3d59955088cd881bfdb221e4 Mon Sep 17 00:00:00 2001 From: martcl Date: Sat, 27 Jul 2024 00:30:55 +0200 Subject: [PATCH 20/59] feat(iac): add terraform guardrails so index don't nuke our infra (#811) feat: add guardrails so index don't fup --- infrastructure/database.tf | 8 ++++++++ infrastructure/storage.tf | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/infrastructure/database.tf b/infrastructure/database.tf index 15065dab..ad7d3a9c 100644 --- a/infrastructure/database.tf +++ b/infrastructure/database.tf @@ -30,6 +30,10 @@ resource "azurerm_mysql_flexible_server" "lepton-database-server" { tags = local.common_tags depends_on = [azurerm_private_dns_zone_virtual_network_link.lepton] + + lifecycle { + prevent_destroy = true + } } // This setting was off when we moved to terraform @@ -58,6 +62,10 @@ resource "azurerm_mysql_flexible_database" "lepton-database" { server_name = azurerm_mysql_flexible_server.lepton-database-server.name charset = "utf8mb4" collation = local.database_collation[var.enviroment] + + lifecycle { + prevent_destroy = true + } } locals { diff --git a/infrastructure/storage.tf b/infrastructure/storage.tf index c86a94f2..ad1d7e07 100644 --- a/infrastructure/storage.tf +++ b/infrastructure/storage.tf @@ -8,4 +8,8 @@ resource "azurerm_storage_account" "lepton" { min_tls_version = "TLS1_2" tags = local.common_tags + + lifecycle { + prevent_destroy = true + } } From fa31096e2b35ac5a41cc5c5c816515c9075811ae Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Tue, 30 Jul 2024 23:54:06 +0200 Subject: [PATCH 21/59] Automatic registration for new users with Feide (#809) * started on feide registration endpoint * made endpoint for creating user with Feide * added test for parse group * finished * format * removes three years if in digtrans --- app/content/exceptions.py | 92 +++++++++++++++ app/content/serializers/__init__.py | 1 + app/content/serializers/user.py | 85 +++++++++++++- app/content/tests/test_feide_utils.py | 32 ++++++ app/content/urls.py | 2 + app/content/util/feide_utils.py | 158 ++++++++++++++++++++++++++ app/content/views/__init__.py | 1 + app/content/views/feide.py | 35 ++++++ app/content/views/user.py | 1 - app/settings.py | 7 ++ requirements.txt | 1 + 11 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 app/content/tests/test_feide_utils.py create mode 100644 app/content/util/feide_utils.py create mode 100644 app/content/views/feide.py diff --git a/app/content/exceptions.py b/app/content/exceptions.py index c3af02d3..73f07dfe 100644 --- a/app/content/exceptions.py +++ b/app/content/exceptions.py @@ -57,3 +57,95 @@ class EventIsFullError(ValueError): class RefundFailedError(ValueError): pass + + +class FeideError(ValueError): + def __init__( + self, + message="Det skjedde en feil under registrering av din bruker ved hjelp av Feide.", + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ): + self.message = message + self.status_code = status_code + + +class FeideTokenNotFoundError(FeideError): + def __init__( + self, + message="Fikk ikke tak i Feide token for din bruker. Prøv igjen eller registrer deg manuelt.", + ): + self.message = message + super().__init__(self.message, status_code=status.HTTP_404_NOT_FOUND) + + +class FeideUserInfoNotFoundError(FeideError): + def __init__( + self, + message="Fikk ikke tak i brukerinformasjon om deg fra Feide. Prøv igjen eller registrer deg manuelt.", + ): + self.message = message + super().__init__(self.message, status_code=status.HTTP_404_NOT_FOUND) + + +class FeideUsernameNotFoundError(FeideError): + def __init__( + self, + message="Fikk ikke tak i brukernavn fra Feide. Prøv igjen eller registrer deg manuelt.", + ): + self.message = message + super().__init__(self.message, status_code=status.HTTP_404_NOT_FOUND) + + +class FeideUserGroupsNotFoundError(FeideError): + def __init__( + self, + message="Fikk ikke tak i dine gruppetilhørigheter fra Feide. Prøv igjen eller registrer deg manuelt.", + ): + self.message = message + super().__init__(self.message, status_code=status.HTTP_404_NOT_FOUND) + + +class FeideGetTokenError(FeideError): + def __init__( + self, + message="Fikk ikke tilgang til Feide sitt API for å hente ut din token. Prøv igjen eller registrer deg manuelt.", + ): + self.message = message + super().__init__( + self.message, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class FeideUsedUserCode(FeideError): + def __init__( + self, + message="Feide innloggings kode har allerede blitt brukt. Prøv å registrere deg på nytt.", + ): + self.message = message + super().__init__(self.message, status_code=status.HTTP_409_CONFLICT) + + +class FeideGetUserGroupsError(FeideError): + def __init__( + self, + message="Fikk ikke tilgang til Feide sitt API for å hente ut dine utdanninger. Prøv igjen eller registrer deg manuelt.", + ): + self.message = message + super().__init__( + self.message, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class FeideParseGroupsError(FeideError): + def __init__( + self, + message="Vi fant ingen utdanningen du tilhører som er en del av TIHLDE. Hvis du mener dette er feil så kan du opprette en bruker manuelt og sende mail til hs@tihlde.org for å den godkjent.", + ): + self.message = message + super().__init__(self.message, status_code=status.HTTP_404_NOT_FOUND) + + +class FeideUserExistsError(FeideError): + def __init__(self, message="Det finnes allerede en bruker med dette brukernavnet."): + self.message = message + super().__init__(self.message, status_code=status.HTTP_409_CONFLICT) diff --git a/app/content/serializers/__init__.py b/app/content/serializers/__init__.py index 53ae7b21..225c9d8e 100644 --- a/app/content/serializers/__init__.py +++ b/app/content/serializers/__init__.py @@ -30,6 +30,7 @@ UserSerializer, DefaultUserSerializer, UserPermissionsSerializer, + FeideUserCreateSerializer, ) from app.content.serializers.minute import ( MinuteCreateSerializer, diff --git a/app/content/serializers/user.py b/app/content/serializers/user.py index b92ebbe2..86842860 100644 --- a/app/content/serializers/user.py +++ b/app/content/serializers/user.py @@ -1,13 +1,26 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError +from django.contrib.auth.hashers import make_password + from dry_rest_permissions.generics import DRYGlobalPermissionsField -from app.common.enums import GroupType +from app.communication.notifier import Notify +from app.communication.enums import UserNotificationSettingType +from app.common.enums import GroupType, Groups from app.common.serializers import BaseModelSerializer from app.content.models import User from app.content.serializers.user_bio import UserBioSerializer from app.group.models import Group, Membership +from app.content.util.feide_utils import ( + get_feide_tokens, + get_feide_user_groups, + parse_feide_groups, + generate_random_password, + get_study_year, + get_feide_user_info_from_jwt, +) +from app.content.exceptions import FeideUserExistsError class DefaultUserSerializer(BaseModelSerializer): @@ -117,6 +130,76 @@ class Meta: ) +class FeideUserCreateSerializer(serializers.Serializer): + code = serializers.CharField(max_length=36) + + def create(self, validated_data): + code = validated_data["code"] + + access_token, jwt_token = get_feide_tokens(code) + full_name, username = get_feide_user_info_from_jwt(jwt_token) + + existing_user = User.objects.filter(user_id=username).first() + if existing_user: + raise FeideUserExistsError() + + groups = get_feide_user_groups(access_token) + group_slugs = parse_feide_groups(groups) + password = generate_random_password() + + user_info = { + "user_id": username, + "password": make_password(password), + "first_name": full_name.split()[0], + "last_name": " ".join(full_name.split()[1:]), + "email": f"{username}@stud.ntnu.no", + } + + user = User.objects.create(**user_info) + + self.make_TIHLDE_member(user, password) + + for slug in group_slugs: + self.add_user_to_study(user, slug) + + return user + + def add_user_to_study(self, user, slug): + study = Group.objects.filter(type=GroupType.STUDY, slug=slug).first() + study_year = get_study_year(slug) + class_ = Group.objects.get_or_create( + name=study_year, + type=GroupType.STUDYYEAR, + slug=study_year + ) + + if not study or not class_: + return + + Membership.objects.create(user=user, group=study) + Membership.objects.create(user=user, group=class_[0]) + + def make_TIHLDE_member(self, user, password): + TIHLDE = Group.objects.get(slug=Groups.TIHLDE) + Membership.objects.get_or_create(user=user, group=TIHLDE) + + Notify( + [user], "Velkommen til TIHLDE", UserNotificationSettingType.OTHER + ).add_paragraph(f"Hei, {user.first_name}!").add_paragraph( + f"Din bruker har nå blitt automatisk generert ved hjelp av Feide. Ditt brukernavn er dermed ditt brukernavn fra Feide: {user.user_id}. Du kan nå logge inn og ta i bruk våre sider." + ).add_paragraph( + f"Ditt autogenererte passord: {password}" + ).add_paragraph( + "Vi anbefaler at du bytter passord ved å følge lenken under:" + ).add_link( + "Bytt passord", "/glemt-passord/" + ).add_link( + "Logg inn", "/logg-inn/" + ).send( + website=False, slack=False + ) + + class UserCreateSerializer(serializers.ModelSerializer): study = serializers.SlugRelatedField( slug_field="slug", diff --git a/app/content/tests/test_feide_utils.py b/app/content/tests/test_feide_utils.py new file mode 100644 index 00000000..de3ebcb0 --- /dev/null +++ b/app/content/tests/test_feide_utils.py @@ -0,0 +1,32 @@ +import pytest + +from app.content.util.feide_utils import parse_feide_groups + + +@pytest.mark.django_db +def test_parse_feide_groups(): + """A list of group ids should return the slugs that is in TIHLDE""" + groups = [ + "fc:fs:fs:prg:ntnu.no:BDIGSEC", + "fc:fs:fs:prg:ntnu.no:ITBAITBEDR", + "fc:fs:fs:prg:ntnu.no:ITJEETTE", + "fc:fs:fs:prg:ntnu.no:ITJESE", + "fc:fs:fs:prg:ntnu.no:BDIGSEREC", + "fc:fs:fs:prg:ntnu.no:BIDATA", + "fc:fs:fs:prg:ntnu.no:ITMAIKTSA", + "fc:fs:fs:prg:ntnu.no:ITBAINFODR", + "fc:fs:fs:prg:ntnu.no:ITBAINFO", + ] + + slugs = parse_feide_groups(groups) + + correct_slugs = [ + "dataingenir", + "digital-forretningsutvikling", + "digital-infrastruktur-og-cybersikkerhet", + "digital-samhandling", + "drift-studie", + "informasjonsbehandling", + ] + + assert sorted(slugs) == sorted(correct_slugs) diff --git a/app/content/urls.py b/app/content/urls.py index f710aad0..b7d6d0bc 100644 --- a/app/content/urls.py +++ b/app/content/urls.py @@ -19,6 +19,7 @@ UserViewSet, accept_form, upload, + register_with_feide, ) router = routers.DefaultRouter() @@ -51,5 +52,6 @@ re_path(r"", include(router.urls)), path("accept-form/", accept_form), path("upload/", upload), + path("feide/", register_with_feide), re_path(r"users/(?P[^/.]+)/events.ics", UserCalendarEvents()), ] diff --git a/app/content/util/feide_utils.py b/app/content/util/feide_utils.py new file mode 100644 index 00000000..8c0b82e6 --- /dev/null +++ b/app/content/util/feide_utils.py @@ -0,0 +1,158 @@ +import jwt +import requests +import secrets +import string + +from requests.auth import HTTPBasicAuth +from datetime import datetime + +from app.settings import ( + FEIDE_CLIENT_ID, + FEIDE_CLIENT_SECRET, + FEIDE_REDIRECT_URL, + FEIDE_TOKEN_URL, + FEIDE_USER_GROUPS_INFO_URL, +) + +from app.content.exceptions import ( + FeideTokenNotFoundError, + FeideGetTokenError, + FeideUserInfoNotFoundError, + FeideUsernameNotFoundError, + FeideUserGroupsNotFoundError, + FeideParseGroupsError, + FeideGetUserGroupsError, + FeideUsedUserCode, +) + + +def get_feide_tokens(code: str) -> tuple[str, str]: + """Get access and JWT tokens for signed in Feide user""" + + grant_type = "authorization_code" + + auth = HTTPBasicAuth(username=FEIDE_CLIENT_ID, password=FEIDE_CLIENT_SECRET) + + payload = { + "grant_type": grant_type, + "client_id": FEIDE_CLIENT_ID, + "redirect_uri": FEIDE_REDIRECT_URL, + "code": code, + } + + response = requests.post(url=FEIDE_TOKEN_URL, auth=auth, data=payload) + + if response.status_code == 400: + raise FeideUsedUserCode() + + if response.status_code != 200: + raise FeideGetTokenError() + + json = response.json() + + if "access_token" not in json or "id_token" not in json: + raise FeideTokenNotFoundError() + + return (json["access_token"], json["id_token"]) + + +def get_feide_user_info_from_jwt(jwt_token: str) -> tuple[str, str]: + """Get Feide user info from jwt token""" + user_info = jwt.decode(jwt_token, options={"verify_signature": False}) + + if ( + "name" not in user_info + or "https://n.feide.no/claims/userid_sec" not in user_info + ): + raise FeideUserInfoNotFoundError() + + feide_username = None + for id in user_info["https://n.feide.no/claims/userid_sec"]: + if "feide:" in id: + feide_username = id.split(":")[1].split("@")[0] + + if not feide_username: + raise FeideUsernameNotFoundError() + + return (user_info["name"], feide_username) + + +def get_feide_user_groups(access_token: str) -> list[str]: + """Get a Feide user's groups""" + + response = requests.get( + url=FEIDE_USER_GROUPS_INFO_URL, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + if response.status_code != 200: + raise FeideGetUserGroupsError() + + groups = response.json() + + if not groups: + raise FeideUserGroupsNotFoundError() + + return [group["id"] for group in groups] # Eks: fc:fs:fs:prg:ntnu.no:ITBAITBEDR + + +def parse_feide_groups(groups: list[str]) -> list[str]: + """Parse groups and return list of group slugs""" + program_codes = [ + "BIDATA", + "ITBAITBEDR", + "BDIGSEC", + "ITMAIKTSA", + "ITBAINFODR", + "ITBAINFO", + ] + program_slugs = [ + "dataingenir", + "digital-forretningsutvikling", + "digital-infrastruktur-og-cybersikkerhet", + "digital-samhandling", + "drift-studie", + "informasjonsbehandling", + ] + + slugs = [] + + for group in groups: + + id_parts = group.split(":") + + group_code = id_parts[5] + + if group_code not in program_codes: + continue + + index = program_codes.index(group_code) + slugs.append(program_slugs[index]) + + if not len(slugs): + raise FeideParseGroupsError() + + return slugs + + +def generate_random_password(length=12): + """Generate random password with ascii letters, digits and punctuation""" + characters = string.ascii_letters + string.digits + string.punctuation + + password = "".join(secrets.choice(characters) for _ in range(length)) + + return password + + +def get_study_year(slug: str) -> str: + today = datetime.today() + current_year = today.year + + # Check if today's date is before July 20th + if today < datetime(current_year, 7, 20): + current_year -= 1 + + if slug == "digital-samhandling": + return str(current_year - 3) + + return str(current_year) diff --git a/app/content/views/__init__.py b/app/content/views/__init__.py index bc0bd030..50ff70ba 100644 --- a/app/content/views/__init__.py +++ b/app/content/views/__init__.py @@ -15,3 +15,4 @@ from app.content.views.user_bio import UserBioViewset from app.content.views.logentry import LogEntryViewSet from app.content.views.minute import MinuteViewSet +from app.content.views.feide import register_with_feide diff --git a/app/content/views/feide.py b/app/content/views/feide.py new file mode 100644 index 00000000..57febec1 --- /dev/null +++ b/app/content/views/feide.py @@ -0,0 +1,35 @@ +from rest_framework.decorators import api_view +from rest_framework import status +from rest_framework.response import Response + +from app.content.serializers import FeideUserCreateSerializer, DefaultUserSerializer +from app.content.exceptions import FeideError + + +@api_view(["POST"]) +def register_with_feide(request): + """Register user with Feide credentials""" + try: + serializer = FeideUserCreateSerializer(data=request.data) + + if serializer.is_valid(): + user = serializer.create(serializer.data) + return Response( + {"detail": DefaultUserSerializer(user).data}, + status=status.HTTP_201_CREATED, + ) + + return Response( + {"detail": serializer.errors}, status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + if isinstance(e, FeideError): + return Response( + {"detail": e.message}, + status=e.status_code, + ) + + return Response( + {"detail": "Det skjedde en feil på serveren"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/app/content/views/user.py b/app/content/views/user.py index 9db67ca8..b8ef4e18 100644 --- a/app/content/views/user.py +++ b/app/content/views/user.py @@ -63,7 +63,6 @@ def get_serializer_class(self): return super().get_serializer_class() def retrieve(self, request, pk, *args, **kwargs): - try: user = self._get_user(request, pk) diff --git a/app/settings.py b/app/settings.py index 9427eee6..d375fd4b 100644 --- a/app/settings.py +++ b/app/settings.py @@ -250,6 +250,13 @@ VIPPS_FORCE_PAYMENT_URL = os.environ.get("VIPPS_FORCE_PAYMENT_URL") VIPPS_COOKIE = os.environ.get("VIPPS_COOKIE") +# Feide +FEIDE_CLIENT_ID = os.environ.get("FEIDE_CLIENT_ID") +FEIDE_CLIENT_SECRET = os.environ.get("FEIDE_CLIENT_SECRET") +FEIDE_TOKEN_URL = os.environ.get("FEIDE_TOKEN_URL") +FEIDE_USER_GROUPS_INFO_URL = os.environ.get("FEIDE_USER_GROUPS_INFO_URL") +FEIDE_REDIRECT_URL = os.environ.get("FEIDE_REDIRECT_URL") + LOGGING = { "version": 1, "disable_existing_loggers": False, diff --git a/requirements.txt b/requirements.txt index 89dc0a81..b3975ffb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ uvicorn == 0.19.0 whitenoise == 6.2.0 django-ical == 1.8.0 slack-sdk == 3.19.3 +pyjwt ~= 2.6.0 # Django # ------------------------------------------------------------------------------ From bef294d262d3c2c4dc56789be9f415155469cf90 Mon Sep 17 00:00:00 2001 From: Mads Nylund Date: Tue, 30 Jul 2024 23:55:44 +0200 Subject: [PATCH 22/59] changelog update --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3174fbc8..ee02ac83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ ## Neste versjon +## Versjon 2024.07.30 +- ✨ **Feide**. Man kan nå registrere bruker automatisk med Feide. + ## Versjon 2024.05.01 - ⚡**Arrangement**. Et arrangement vil nå få kategori sendt som navn på kategori istedenfor kun id. - ⚡**Påmelding**. En bruker som har betalt for en påmelding på et arrangement kan ikke lenger melde seg av. From fcce5e80515dafef343cb0cfae41bf2931811365 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Wed, 31 Jul 2024 22:29:57 +0200 Subject: [PATCH 23/59] Feide env variables Terraform (#814) added feid env variables --- .gitignore | 1 + .terraform.lock.hcl | 2 ++ infrastructure/containers.tf | 40 ++++++++++++++++++++++++++++++++++++ infrastructure/inputs.tf | 24 ++++++++++++++++++++++ main.tf | 6 ++++++ 5 files changed, 73 insertions(+) diff --git a/.gitignore b/.gitignore index 05184f48..f1a7cc85 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ celerybeat-schedule .terraform *.tfvars +.terraform.lock.hcl \ No newline at end of file diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl index 3e007d98..59f929fd 100644 --- a/.terraform.lock.hcl +++ b/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/azurerm" { version = "3.76.0" constraints = "> 3.68.0" hashes = [ + "h1:b7wCNsV0HyJalcmjth7Y4nSBuZqEjbA0Phpggoy4bLE=", "h1:eArCWwNEShXmVWS08Ocd3d8ptsjbAaMECifkIBacpyw=", "zh:33c6b1559b012d03befeb8ee9cf5b88c31acd64983dd4f727a49a436008b5577", "zh:36d3cfa7cf2079a102ffce05da2de41ecf263310544990471c19ee01b135ccf3", @@ -24,6 +25,7 @@ provider "registry.terraform.io/hashicorp/azurerm" { provider "registry.terraform.io/hashicorp/random" { version = "3.5.1" hashes = [ + "h1:3hjTP5tQBspPcFAJlfafnWrNrKnr7J4Cp0qB9jbqf30=", "h1:VSnd9ZIPyfKHOObuQCaKfnjIHRtR7qTw19Rz8tJxm+k=", "zh:04e3fbd610cb52c1017d282531364b9c53ef72b6bc533acb2a90671957324a64", "zh:119197103301ebaf7efb91df8f0b6e0dd31e6ff943d231af35ee1831c599188d", diff --git a/infrastructure/containers.tf b/infrastructure/containers.tf index 856f795c..65fb201f 100644 --- a/infrastructure/containers.tf +++ b/infrastructure/containers.tf @@ -164,6 +164,26 @@ resource "azurerm_container_app" "lepton-api" { name = "VIPPS_ORDER_URL" value = var.vipps_order_url } + env { + name = "FEIDE_CLIENT_ID" + value = var.feide_client_id + } + env { + name = "FEIDE_CLIENT_SECRET" + value = var.feide_client_secret + } + env { + name = "FEIDE_TOKEN_URL" + value = var.feide_token_url + } + env { + name = "FEIDE_USER_GROUPS_INFO_URL" + value = var.feide_user_groups_info_url + } + env { + name = "FEIDE_REDIRECT_URL" + value = var.feide_redirect_url + } env { name = var.enviroment == "pro" ? "PROD" : "DEV" value = "true" @@ -337,6 +357,26 @@ resource "azurerm_container_app" "celery" { name = "VIPPS_ORDER_URL" value = var.vipps_order_url } + env { + name = "FEIDE_CLIENT_ID" + value = var.feide_client_id + } + env { + name = "FEIDE_CLIENT_SECRET" + value = var.feide_client_secret + } + env { + name = "FEIDE_TOKEN_URL" + value = var.feide_token_url + } + env { + name = "FEIDE_USER_GROUPS_INFO_URL" + value = var.feide_user_groups_info_url + } + env { + name = "FEIDE_REDIRECT_URL" + value = var.feide_redirect_url + } } } diff --git a/infrastructure/inputs.tf b/infrastructure/inputs.tf index c95871b3..55b271be 100644 --- a/infrastructure/inputs.tf +++ b/infrastructure/inputs.tf @@ -64,6 +64,30 @@ variable "lepton_api_max_replicas" { default = 1 } +variable "feide_client_id" { + type = string + sensitive = true +} + +variable "feide_client_secret" { + type = string + sensitive = true +} + +variable "feide_token_url" { + type = string + default = "https://auth.dataporten.no/oauth/token" +} + +variable "feide_user_groups_info_url" { + type = string + default = "https://groups-api.dataporten.no/groups/me/groups" +} + +variable "feide_redirect_url" { + type = string +} + variable "enviroment" { type = string description = "value is either dev or pro" diff --git a/main.tf b/main.tf index 10b49d13..50ad565b 100644 --- a/main.tf +++ b/main.tf @@ -39,4 +39,10 @@ module "infrastructure" { lepton_api_min_replicas = var.lepton_api_min_replicas lepton_api_max_replicas = var.lepton_api_max_replicas + + feide_client_id = var.feide_client_id + feide_client_secret = var.feide_client_secret + feide_token_url = var.feide_token_url + feide_user_groups_info_url = var.feide_user_groups_info_url + feide_redirect_url = var.feide_redirect_url } From 514a26be1e593a0f46c812fc2b6bf3a79c5afa31 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Sun, 4 Aug 2024 22:41:17 +0200 Subject: [PATCH 24/59] added delete endpoint for file (#815) * added delete endpoint for file * Trigger Build * changed workflow to checkout v4 * changed from docker-compose to docker compose --- .github/workflows/ci.yaml | 24 ++++++++++++------------ app/common/file_handler.py | 2 +- app/content/serializers/user.py | 23 ++++++++++------------- app/content/urls.py | 4 +++- app/content/util/feide_utils.py | 27 +++++++++++++-------------- app/content/views/__init__.py | 2 +- app/content/views/feide.py | 7 +++++-- app/content/views/upload.py | 22 ++++++++++++++++++++++ 8 files changed, 67 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cab568a1..f6babc08 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout Code Repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Python 3.9 uses: actions/setup-python@v2 @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout Code Repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up .env file run: | @@ -44,35 +44,35 @@ jobs: echo "VIPPS_MERCHANT_SERIAL_NUMBER=${{ secrets.VIPPS_MERCHANT_SERIAL_NUMBER }}" >> .env - name: Build the Stack - run: docker-compose build + run: docker compose build - name: Run the Stack - run: docker-compose up -d + run: docker compose up -d - name: Make DB Migrations - run: docker-compose run --rm web python manage.py migrate + run: docker compose run --rm web python manage.py migrate - name: Run Django Tests - run: docker-compose run --rm web pytest --cov=app + run: docker compose run --rm web pytest --cov=app - name: Tear down the Stack - run: docker-compose down + run: docker compose down check-migrations: runs-on: ubuntu-latest steps: - name: Checkout Code Repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Build the Stack - run: docker-compose build + run: docker compose build - name: Run the Stack - run: docker-compose up -d + run: docker compose up -d - name: Check for unstaged migrations - run: docker-compose run --rm web python manage.py makemigrations --check --no-input + run: docker compose run --rm web python manage.py makemigrations --check --no-input - name: Tear down the Stack - run: docker-compose down \ No newline at end of file + run: docker compose down \ No newline at end of file diff --git a/app/common/file_handler.py b/app/common/file_handler.py index 590123d7..3b745075 100644 --- a/app/common/file_handler.py +++ b/app/common/file_handler.py @@ -21,7 +21,7 @@ def getContainerNameFromBlob(self): return ( "".join(e for e in self.blob.content_type if e.isalnum()) if self.blob.content_type - else None + else "default" ) def checkBlobSize(self): diff --git a/app/content/serializers/user.py b/app/content/serializers/user.py index 86842860..fc2d9162 100644 --- a/app/content/serializers/user.py +++ b/app/content/serializers/user.py @@ -1,26 +1,25 @@ +from django.contrib.auth.hashers import make_password from rest_framework import serializers from rest_framework.exceptions import ValidationError -from django.contrib.auth.hashers import make_password - from dry_rest_permissions.generics import DRYGlobalPermissionsField -from app.communication.notifier import Notify -from app.communication.enums import UserNotificationSettingType -from app.common.enums import GroupType, Groups +from app.common.enums import Groups, GroupType from app.common.serializers import BaseModelSerializer +from app.communication.enums import UserNotificationSettingType +from app.communication.notifier import Notify +from app.content.exceptions import FeideUserExistsError from app.content.models import User from app.content.serializers.user_bio import UserBioSerializer -from app.group.models import Group, Membership from app.content.util.feide_utils import ( + generate_random_password, get_feide_tokens, get_feide_user_groups, - parse_feide_groups, - generate_random_password, - get_study_year, get_feide_user_info_from_jwt, + get_study_year, + parse_feide_groups, ) -from app.content.exceptions import FeideUserExistsError +from app.group.models import Group, Membership class DefaultUserSerializer(BaseModelSerializer): @@ -168,9 +167,7 @@ def add_user_to_study(self, user, slug): study = Group.objects.filter(type=GroupType.STUDY, slug=slug).first() study_year = get_study_year(slug) class_ = Group.objects.get_or_create( - name=study_year, - type=GroupType.STUDYYEAR, - slug=study_year + name=study_year, type=GroupType.STUDYYEAR, slug=study_year ) if not study or not class_: diff --git a/app/content/urls.py b/app/content/urls.py index b7d6d0bc..c5a48239 100644 --- a/app/content/urls.py +++ b/app/content/urls.py @@ -18,8 +18,9 @@ UserCalendarEvents, UserViewSet, accept_form, - upload, + delete, register_with_feide, + upload, ) router = routers.DefaultRouter() @@ -52,6 +53,7 @@ re_path(r"", include(router.urls)), path("accept-form/", accept_form), path("upload/", upload), + path("delete-file///", delete), path("feide/", register_with_feide), re_path(r"users/(?P[^/.]+)/events.ics", UserCalendarEvents()), ] diff --git a/app/content/util/feide_utils.py b/app/content/util/feide_utils.py index 8c0b82e6..bc38cc96 100644 --- a/app/content/util/feide_utils.py +++ b/app/content/util/feide_utils.py @@ -1,11 +1,21 @@ -import jwt -import requests import secrets import string +from datetime import datetime +import jwt +import requests from requests.auth import HTTPBasicAuth -from datetime import datetime +from app.content.exceptions import ( + FeideGetTokenError, + FeideGetUserGroupsError, + FeideParseGroupsError, + FeideTokenNotFoundError, + FeideUsedUserCode, + FeideUserGroupsNotFoundError, + FeideUserInfoNotFoundError, + FeideUsernameNotFoundError, +) from app.settings import ( FEIDE_CLIENT_ID, FEIDE_CLIENT_SECRET, @@ -14,17 +24,6 @@ FEIDE_USER_GROUPS_INFO_URL, ) -from app.content.exceptions import ( - FeideTokenNotFoundError, - FeideGetTokenError, - FeideUserInfoNotFoundError, - FeideUsernameNotFoundError, - FeideUserGroupsNotFoundError, - FeideParseGroupsError, - FeideGetUserGroupsError, - FeideUsedUserCode, -) - def get_feide_tokens(code: str) -> tuple[str, str]: """Get access and JWT tokens for signed in Feide user""" diff --git a/app/content/views/__init__.py b/app/content/views/__init__.py index 50ff70ba..3392d438 100644 --- a/app/content/views/__init__.py +++ b/app/content/views/__init__.py @@ -8,7 +8,7 @@ from app.content.views.news import NewsViewSet from app.content.views.page import PageViewSet from app.content.views.short_link import ShortLinkViewSet -from app.content.views.upload import upload +from app.content.views.upload import upload, delete from app.content.views.strike import StrikeViewSet from app.content.views.toddel import ToddelViewSet from app.content.views.qr_code import QRCodeViewSet diff --git a/app/content/views/feide.py b/app/content/views/feide.py index 57febec1..f09917b0 100644 --- a/app/content/views/feide.py +++ b/app/content/views/feide.py @@ -1,9 +1,12 @@ -from rest_framework.decorators import api_view from rest_framework import status +from rest_framework.decorators import api_view from rest_framework.response import Response -from app.content.serializers import FeideUserCreateSerializer, DefaultUserSerializer from app.content.exceptions import FeideError +from app.content.serializers import ( + DefaultUserSerializer, + FeideUserCreateSerializer, +) @api_view(["POST"]) diff --git a/app/content/views/upload.py b/app/content/views/upload.py index b4e54a3b..e203c0f7 100644 --- a/app/content/views/upload.py +++ b/app/content/views/upload.py @@ -38,3 +38,25 @@ def upload(request): {"detail": str(value_error)}, status=status.HTTP_400_BAD_REQUEST, ) + + +@api_view(["DELETE"]) +@permission_classes([IsMember]) +def delete(request, container_name, blob_name): + """Method for deleting files from Azure Blob Storage, only allowed for members""" + try: + handler = AzureFileHandler() + handler.blobName = blob_name + handler.containerName = container_name + + handler.deleteBlob() + return Response( + {"detail": "Filen ble slettet"}, + status=status.HTTP_200_OK, + ) + + except ValueError as value_error: + return Response( + {"detail": str(value_error)}, + status=status.HTTP_400_BAD_REQUEST, + ) From d3e8e9acad17f75a4b52c54b594c5facddf49163 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Sun, 4 Aug 2024 22:43:49 +0200 Subject: [PATCH 25/59] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee02ac83..07920a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ## Versjon 2024.07.30 - ✨ **Feide**. Man kan nå registrere bruker automatisk med Feide. +- ✨ **Fillagring**. Man kan nå slette en fil fra Azure basert på container navn og fil navn. ## Versjon 2024.05.01 - ⚡**Arrangement**. Et arrangement vil nå få kategori sendt som navn på kategori istedenfor kun id. From c9bf35795eea5dacbcf901197657205f1c8f2845 Mon Sep 17 00:00:00 2001 From: Mads Nylund Date: Sun, 4 Aug 2024 22:58:39 +0200 Subject: [PATCH 26/59] format --- app/content/serializers/user.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/app/content/serializers/user.py b/app/content/serializers/user.py index d9863b5a..44a20712 100644 --- a/app/content/serializers/user.py +++ b/app/content/serializers/user.py @@ -1,4 +1,3 @@ -from django.contrib.auth.hashers import make_password from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -22,15 +21,6 @@ parse_feide_groups, ) from app.group.models import Group, Membership -from app.content.util.feide_utils import ( - get_feide_tokens, - get_feide_user_groups, - parse_feide_groups, - generate_random_password, - get_study_year, - get_feide_user_info_from_jwt, -) -from app.content.exceptions import FeideUserExistsError class DefaultUserSerializer(BaseModelSerializer): From 1a7dff46446731691108c057145c0edd10d0df8b Mon Sep 17 00:00:00 2001 From: Mads Nylund Date: Sun, 4 Aug 2024 23:01:06 +0200 Subject: [PATCH 27/59] format --- app/content/urls.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/content/urls.py b/app/content/urls.py index 823bbeb5..c5a48239 100644 --- a/app/content/urls.py +++ b/app/content/urls.py @@ -21,7 +21,6 @@ delete, register_with_feide, upload, - register_with_feide, ) router = routers.DefaultRouter() From f086ac24479abf89ff1dbb16187513b3ebc13595 Mon Sep 17 00:00:00 2001 From: Mads Nylund Date: Sun, 18 Aug 2024 14:39:35 +0200 Subject: [PATCH 28/59] fixed permission for committee leaders for group forms --- app/content/models/user.py | 6 ++++++ app/content/serializers/user.py | 3 +-- app/forms/models/forms.py | 6 ++++-- app/tests/kontres/test_reservation_integration.py | 1 - 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/content/models/user.py b/app/content/models/user.py index 26e3a4b9..66974c56 100644 --- a/app/content/models/user.py +++ b/app/content/models/user.py @@ -154,6 +154,12 @@ def is_leader_of(self, group): group=group, membership_type=MembershipType.LEADER ).exists() + @property + def is_leader_of_committee(self): + return self.memberships.filter( + group__type=GroupType.COMMITTEE, membership_type=MembershipType.LEADER + ).exists() + def has_unanswered_evaluations(self): return self.get_unanswered_evaluations().exists() diff --git a/app/content/serializers/user.py b/app/content/serializers/user.py index 44a20712..fc2d9162 100644 --- a/app/content/serializers/user.py +++ b/app/content/serializers/user.py @@ -1,8 +1,7 @@ +from django.contrib.auth.hashers import make_password from rest_framework import serializers from rest_framework.exceptions import ValidationError -from django.contrib.auth.hashers import make_password - from dry_rest_permissions.generics import DRYGlobalPermissionsField from app.common.enums import Groups, GroupType diff --git a/app/forms/models/forms.py b/app/forms/models/forms.py index 870d55b5..dc141182 100644 --- a/app/forms/models/forms.py +++ b/app/forms/models/forms.py @@ -179,8 +179,10 @@ def has_write_permission(cls, request): form = GroupForm.objects.filter(id=form_id).first() group = form.group if form else None return ( - group and group.has_object_group_form_permission(request) - ) or check_has_access(cls.write_access, request) + (group and group.has_object_group_form_permission(request)) + or check_has_access(cls.write_access, request) + or request.user.is_leader_of_committee + ) @classmethod def has_list_permission(cls, request): diff --git a/app/tests/kontres/test_reservation_integration.py b/app/tests/kontres/test_reservation_integration.py index d78dc977..81ffaae6 100644 --- a/app/tests/kontres/test_reservation_integration.py +++ b/app/tests/kontres/test_reservation_integration.py @@ -82,7 +82,6 @@ def test_reservation_creation_fails_without_sober_watch(member, bookable_item): ) assert response.status_code == 400 - print(response.data) expected_error_message = "Du må velge en edruvakt for reservasjonen." actual_error_messages = response.data.get("non_field_errors", []) assert any( From ec03558faa38cd016b97e402201cf66cf6871930 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Sun, 18 Aug 2024 18:07:57 +0200 Subject: [PATCH 29/59] updated csv for forms (#818) --- app/forms/csv_writer.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/forms/csv_writer.py b/app/forms/csv_writer.py index 475cb596..b918a9fa 100644 --- a/app/forms/csv_writer.py +++ b/app/forms/csv_writer.py @@ -5,7 +5,14 @@ class SubmissionsCsvWriter: - field_names = ["first_name", "last_name", "email"] + field_names = [ + "first_name", + "last_name", + "full_name", + "email", + "study", + "studyyear", + ] def __init__(self, queryset=None): if queryset is None: @@ -27,7 +34,12 @@ def write_csv(self): def create_row(self, result, submission): user = submission.user row = OrderedDict( - first_name=user.first_name, last_name=user.last_name, email=user.email + first_name=user.first_name, + last_name=user.last_name, + full_name=f"{user.first_name} {user.last_name}", + email=user.email, + study=user.study.group.name, + studyyear=user.studyyear.group.name, ) for answer in submission.answers.all().prefetch_related( "selected_options", "field" From 0526f02340cb1c2ffea698c70417521964403ce2 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Wed, 21 Aug 2024 11:48:22 +0200 Subject: [PATCH 30/59] Permission for group forms and news (#820) added permission for committees to create news, and all leaders of groups to create group forms --- app/content/models/news.py | 10 ++++++- app/content/models/user.py | 4 +++ app/forms/models/forms.py | 2 +- app/tests/content/test_news_integration.py | 21 +++++++++++++-- .../forms/test_group_form_integration.py | 27 ++++++++++++++++++- 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/app/content/models/news.py b/app/content/models/news.py index db696839..886105bb 100644 --- a/app/content/models/news.py +++ b/app/content/models/news.py @@ -3,7 +3,7 @@ from django.db import models from app.common.enums import AdminGroup, Groups -from app.common.permissions import BasePermissionModel +from app.common.permissions import BasePermissionModel, check_has_access from app.emoji.models.reaction import Reaction from app.util.models import BaseModel, OptionalImage @@ -33,3 +33,11 @@ def __str__(self): @property def website_url(self): return f"/nyheter/{self.id}/" + + @classmethod + def has_write_permission(cls, request): + if not request.user: + return False + return request.user.is_leader_of_committee or check_has_access( + cls.write_access, request + ) diff --git a/app/content/models/user.py b/app/content/models/user.py index 66974c56..1ab2db3a 100644 --- a/app/content/models/user.py +++ b/app/content/models/user.py @@ -160,6 +160,10 @@ def is_leader_of_committee(self): group__type=GroupType.COMMITTEE, membership_type=MembershipType.LEADER ).exists() + @property + def is_leader(self): + return self.memberships.filter(membership_type=MembershipType.LEADER).exists() + def has_unanswered_evaluations(self): return self.get_unanswered_evaluations().exists() diff --git a/app/forms/models/forms.py b/app/forms/models/forms.py index dc141182..f933f661 100644 --- a/app/forms/models/forms.py +++ b/app/forms/models/forms.py @@ -181,7 +181,7 @@ def has_write_permission(cls, request): return ( (group and group.has_object_group_form_permission(request)) or check_has_access(cls.write_access, request) - or request.user.is_leader_of_committee + or request.user.is_leader ) @classmethod diff --git a/app/tests/content/test_news_integration.py b/app/tests/content/test_news_integration.py index f55a1b41..463c3e57 100644 --- a/app/tests/content/test_news_integration.py +++ b/app/tests/content/test_news_integration.py @@ -2,10 +2,10 @@ import pytest -from app.common.enums import AdminGroup, Groups +from app.common.enums import AdminGroup, Groups, GroupType, MembershipType from app.content.factories.news_factory import NewsFactory from app.content.factories.user_factory import UserFactory -from app.util.test_utils import get_api_client +from app.util.test_utils import add_user_to_group_with_name, get_api_client API_NEWS_BASE_URL = "/news/" @@ -310,3 +310,20 @@ def test_destroy_returns_detail_in_response(news): response = client.delete(url) assert response.json().get("detail") + + +@pytest.mark.django_db +def test_create_news_as_leader_of_committee(member): + """A leader of a committee should be able to create news.""" + add_user_to_group_with_name( + member, + "Committee", + group_type=GroupType.COMMITTEE, + membership_type=MembershipType.LEADER, + ) + client = get_api_client(user=member) + response = client.post( + _get_news_url(), {"title": "title", "header": "header", "body": "body"} + ) + + assert response.status_code == status.HTTP_201_CREATED diff --git a/app/tests/forms/test_group_form_integration.py b/app/tests/forms/test_group_form_integration.py index d43dbaf9..6f9dce62 100644 --- a/app/tests/forms/test_group_form_integration.py +++ b/app/tests/forms/test_group_form_integration.py @@ -5,7 +5,8 @@ from app.common.enums import AdminGroup, GroupType, MembershipType from app.forms.tests.form_factories import GroupFormFactory from app.group.factories import GroupFactory, MembershipFactory -from app.util.test_utils import add_user_to_group_with_name +from app.group.models import Group +from app.util.test_utils import add_user_to_group_with_name, get_api_client pytestmark = pytest.mark.django_db @@ -234,3 +235,27 @@ def test_retrieve_specific_group_form( response = client.get(url) assert response.status_code == status_code + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("group_name", "group_type"), + ( + ("Committee", GroupType.COMMITTEE), + ("Subgroup", GroupType.SUBGROUP), + ("Board", GroupType.BOARD), + ("Interestgroup", GroupType.INTERESTGROUP), + ), +) +def test_create_group_form_as_leader(member, group_name, group_type): + """Test that leaders of a group can create a group form""" + add_user_to_group_with_name( + member, group_name, group_type=group_type, membership_type=MembershipType.LEADER + ) + group = Group.objects.get(name=group_name) + client = get_api_client(user=member) + data = _get_form_post_data(group=group) + + response = client.post(FORMS_URL, data) + + assert response.status_code == status.HTTP_201_CREATED From f40fba0a59c52f5431cbf8820a49dd0cb48a9596 Mon Sep 17 00:00:00 2001 From: Frikk Balder <33499052+MindChirp@users.noreply.github.com> Date: Tue, 27 Aug 2024 10:15:01 +0200 Subject: [PATCH 31/59] Update reservation_seralizer.py (#822) * Update reservation_seralizer.py * Fixed linting * Put a band aid on it *smack* * Removed blank line.. * ???? --- app/kontres/serializer/reservation_seralizer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/kontres/serializer/reservation_seralizer.py b/app/kontres/serializer/reservation_seralizer.py index 98bf66ac..8de2831c 100644 --- a/app/kontres/serializer/reservation_seralizer.py +++ b/app/kontres/serializer/reservation_seralizer.py @@ -110,10 +110,10 @@ def validate_state_change(self, data, user): ) def validate_time_and_overlapping(self, data): - # Check if this is an update operation and if start_time is being modified. is_update_operation = self.instance is not None start_time_being_modified = "start_time" in data + state_being_modified = "state_change" in data # Retrieve the start and end times from the data if provided, else from the instance. start_time = data.get( @@ -137,7 +137,7 @@ def validate_time_and_overlapping(self, data): "bookable_item", self.instance.bookable_item if self.instance else None ) # Check for overlapping reservations only if necessary fields are present - if bookable_item and start_time and end_time: + if bookable_item and start_time and end_time and not state_being_modified: # Build the query for overlapping reservations overlapping_reservations_query = Q( bookable_item=bookable_item, From 1f03c30b4d74145ed861fdea3551b9dcb43f0c5a Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Sat, 14 Sep 2024 17:40:25 +0200 Subject: [PATCH 32/59] Group ownership of Minutes (#847) * Refactor MinuteFactory to include group field, and added validation for checkin group access * added validation for POST request --- app/common/enums.py | 16 ++ app/common/permissions.py | 18 ++ app/content/factories/minute_factory.py | 2 + app/content/migrations/0066_minute_group.py | 27 +++ .../migrations/0067_alter_minute_group.py | 28 +++ app/content/models/minute.py | 32 +++- app/content/serializers/minute.py | 20 +- app/content/views/minute.py | 18 +- app/tests/content/test_minute_integration.py | 176 +++++++++++++++--- 9 files changed, 297 insertions(+), 40 deletions(-) create mode 100644 app/content/migrations/0066_minute_group.py create mode 100644 app/content/migrations/0067_alter_minute_group.py diff --git a/app/common/enums.py b/app/common/enums.py index 7e3bd389..15f8eb05 100644 --- a/app/common/enums.py +++ b/app/common/enums.py @@ -1,5 +1,7 @@ from enum import Enum +from django.db import models + from enumchoicefield import ChoiceEnum @@ -44,6 +46,7 @@ class Groups(ChoiceEnum): REDAKSJONEN = "Redaksjonen" FONDET = "Forvaltningsgruppen" PLASK = "Plask" + DRIFT = "Drift" class AppModel(ChoiceEnum): @@ -106,3 +109,16 @@ class StrikeEnum(ChoiceEnum): LATE = "LATE" BAD_BEHAVIOR = "BAD_BEHAVIOR" EVAL_FORM = "EVAL_FORM" + + +class CodexGroups(models.TextChoices): + DRIFT = "Drift" + INDEX = "Index" + + @classmethod + def all(cls) -> list: + return [cls.DRIFT, cls.INDEX] + + @classmethod + def reverse(cls) -> list: + return [cls.INDEX, cls.DRIFT] diff --git a/app/common/permissions.py b/app/common/permissions.py index a7aa169a..c830d196 100644 --- a/app/common/permissions.py +++ b/app/common/permissions.py @@ -63,6 +63,24 @@ def check_has_access(groups_with_access, request): return False +def check_has_full_access(groups_with_access: list[str], request): + """Check if user has access to all groups""" + set_user_id(request) + user = request.user + + if not user: + return False + + try: + groups = map(str, groups_with_access) + return user and user.memberships.filter( + group__slug__iregex=r"(" + "|".join(groups) + ")" + ).count() == len(groups_with_access) + except Exception as e: + capture_exception(e) + return False + + def set_user_id(request): # If the id and user of the request is already set, return if (hasattr(request, "id") and request.id) and ( diff --git a/app/content/factories/minute_factory.py b/app/content/factories/minute_factory.py index 84377af9..dbc811e8 100644 --- a/app/content/factories/minute_factory.py +++ b/app/content/factories/minute_factory.py @@ -3,6 +3,7 @@ from app.content.factories.user_factory import UserFactory from app.content.models.minute import Minute +from app.group.factories.group_factory import GroupFactory class MinuteFactory(DjangoModelFactory): @@ -12,3 +13,4 @@ class Meta: title = factory.Faker("sentence", nb_words=4) content = factory.Faker("text") author = factory.SubFactory(UserFactory) + group = factory.SubFactory(GroupFactory) diff --git a/app/content/migrations/0066_minute_group.py b/app/content/migrations/0066_minute_group.py new file mode 100644 index 00000000..cc51ea22 --- /dev/null +++ b/app/content/migrations/0066_minute_group.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.5 on 2024-09-12 14:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("group", "0018_fine_defense"), + ("content", "0065_merge_0060_minute_tag_0064_alter_userbio_description"), + ] + + operations = [ + migrations.AddField( + model_name="minute", + name="group", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="meeting_minutes", + to="group.group", + ), + ), + ] diff --git a/app/content/migrations/0067_alter_minute_group.py b/app/content/migrations/0067_alter_minute_group.py new file mode 100644 index 00000000..7a8eecb2 --- /dev/null +++ b/app/content/migrations/0067_alter_minute_group.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.5 on 2024-09-13 06:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("group", "0018_fine_defense"), + ("content", "0066_minute_group"), + ] + + operations = [ + migrations.AlterField( + model_name="minute", + name="group", + field=models.ForeignKey( + blank=True, + choices=[("Drift", "Drift"), ("Index", "Index")], + default="Index", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="meeting_minutes", + to="group.group", + ), + ), + ] diff --git a/app/content/models/minute.py b/app/content/models/minute.py index 2aa8d26a..ea3939df 100644 --- a/app/content/models/minute.py +++ b/app/content/models/minute.py @@ -1,15 +1,16 @@ from django.db import models -from app.common.enums import AdminGroup -from app.common.permissions import BasePermissionModel +from app.common.enums import CodexGroups +from app.common.permissions import BasePermissionModel, check_has_access from app.content.enums import MinuteTagEnum from app.content.models.user import User +from app.group.models import Group from app.util.models import BaseModel class Minute(BaseModel, BasePermissionModel): - write_access = (AdminGroup.INDEX,) - read_access = (AdminGroup.INDEX,) + write_access = CodexGroups.all() + read_access = CodexGroups.all() title = models.CharField(max_length=200) content = models.TextField(default="", blank=True) @@ -24,6 +25,22 @@ class Minute(BaseModel, BasePermissionModel): on_delete=models.SET_NULL, related_name="meeting_minutes", ) + group = models.ForeignKey( + Group, + blank=True, + null=True, + default=CodexGroups.INDEX, + choices=CodexGroups.choices, + on_delete=models.SET_NULL, + related_name="meeting_minutes", + ) + + @classmethod + def has_create_permission(cls, request): + data = request.data + if "group" in data: + return check_has_access([data["group"]], request) + return False @classmethod def has_update_permission(cls, request): @@ -41,10 +58,13 @@ def has_object_read_permission(self, request): return self.has_read_permission(request) def has_object_update_permission(self, request): - return self.has_write_permission(request) + data = request.data + if "group" in data: + return check_has_access([data["group"]], request) + return False def has_object_destroy_permission(self, request): - return self.has_write_permission(request) + return check_has_access([self.group.slug], request) def has_object_retrieve_permission(self, request): return self.has_read_permission(request) diff --git a/app/content/serializers/minute.py b/app/content/serializers/minute.py index f490195d..78eeb3d8 100644 --- a/app/content/serializers/minute.py +++ b/app/content/serializers/minute.py @@ -1,6 +1,7 @@ from rest_framework import serializers from app.content.models import Minute, User +from app.group.serializers import SimpleGroupSerializer class SimpleUserSerializer(serializers.ModelSerializer): @@ -12,7 +13,7 @@ class Meta: class MinuteCreateSerializer(serializers.ModelSerializer): class Meta: model = Minute - fields = ("title", "content", "tag") + fields = ("title", "content", "tag", "group") def create(self, validated_data): author = self.context["request"].user @@ -22,16 +23,26 @@ def create(self, validated_data): class MinuteSerializer(serializers.ModelSerializer): author = SimpleUserSerializer(read_only=True) + group = SimpleGroupSerializer(read_only=True) class Meta: model = Minute - fields = ("id", "title", "content", "author", "created_at", "updated_at", "tag") + fields = ( + "id", + "title", + "content", + "author", + "created_at", + "updated_at", + "tag", + "group", + ) class MinuteUpdateSerializer(serializers.ModelSerializer): class Meta: model = Minute - fields = ("id", "title", "content", "tag") + fields = ("id", "title", "content", "tag", "group") def update(self, instance, validated_data): return super().update(instance, validated_data) @@ -39,7 +50,8 @@ def update(self, instance, validated_data): class MinuteListSerializer(serializers.ModelSerializer): author = SimpleUserSerializer(read_only=True) + group = SimpleGroupSerializer(read_only=True) class Meta: model = Minute - fields = ("id", "title", "author", "created_at", "updated_at", "tag") + fields = ("id", "title", "author", "created_at", "updated_at", "tag", "group") diff --git a/app/content/views/minute.py b/app/content/views/minute.py index 3cc14914..8ebef660 100644 --- a/app/content/views/minute.py +++ b/app/content/views/minute.py @@ -1,9 +1,15 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, status +from rest_framework.exceptions import NotFound from rest_framework.response import Response +from app.common.enums import CodexGroups from app.common.pagination import BasePagination -from app.common.permissions import BasicViewPermission +from app.common.permissions import ( + BasicViewPermission, + check_has_access, + check_has_full_access, +) from app.common.viewsets import BaseViewSet from app.content.filters import MinuteFilter from app.content.models import Minute @@ -30,6 +36,16 @@ class MinuteViewSet(BaseViewSet): "author__user_id", ] + def get_queryset(self): + if check_has_full_access(CodexGroups.all(), self.request): + return self.queryset + elif check_has_access([CodexGroups.DRIFT], self.request): + return self.queryset.filter(group=CodexGroups.DRIFT) + elif check_has_access([CodexGroups.INDEX], self.request): + return self.queryset.filter(group=CodexGroups.INDEX) + + raise NotFound() + def get_serializer_class(self): if hasattr(self, "action") and self.action == "list": return MinuteListSerializer diff --git a/app/tests/content/test_minute_integration.py b/app/tests/content/test_minute_integration.py index a0b92573..3a6c9844 100644 --- a/app/tests/content/test_minute_integration.py +++ b/app/tests/content/test_minute_integration.py @@ -2,7 +2,10 @@ import pytest -from app.util.test_utils import get_api_client +from app.common.enums import CodexGroups +from app.content.factories import MinuteFactory +from app.group.models import Group +from app.util.test_utils import add_user_to_group_with_name, get_api_client API_MINUTE_BASE_URL = "/minutes/" @@ -11,12 +14,20 @@ def get_minute_detail_url(minute): return f"{API_MINUTE_BASE_URL}{minute.id}/" -def get_minute_post_data(): - return {"title": "Test Minute", "content": "This is a test minute."} +def get_minute_post_data(group=None): + return { + "title": "Test Minute", + "content": "This is a test minute.", + "group": group.slug if group else None, + } -def get_minute_put_data(): - return {"title": "Test Minute update", "content": "This is a test minute update."} +def get_minute_put_data(group=None): + return { + "title": "Test Minute update", + "content": "This is a test minute update.", + "group": group.slug if group else None, + } @pytest.mark.django_db @@ -31,41 +42,111 @@ def test_create_minute_as_member(member): @pytest.mark.django_db -def test_create_minute_as_index_member(index_member): +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_create_minute_as_codex_member(member, codex_group): """An index member should be able to create a minute""" url = API_MINUTE_BASE_URL - client = get_api_client(user=index_member) - data = get_minute_post_data() + add_user_to_group_with_name(member, codex_group) + client = get_api_client(user=member) + group = Group.objects.get(slug=codex_group) + data = get_minute_post_data(group) response = client.post(url, data) assert response.status_code == status.HTTP_201_CREATED +@pytest.mark.django_db +@pytest.mark.parametrize( + ("codex_group", "swap"), (CodexGroups.all(), CodexGroups.reverse()) +) +def test_create_to_another_group_as_codex_member(member, codex_group, swap): + """A codex member should not be able to create a minute to another group""" + add_user_to_group_with_name(member, codex_group) + + url = API_MINUTE_BASE_URL + client = get_api_client(user=member) + group = Group.objects.get_or_create(slug=swap, name=swap)[0] + data = get_minute_post_data(group) + response = client.post(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.django_db def test_update_minute_as_member(member, minute): """A member should not be able to update a minute""" url = get_minute_detail_url(minute) client = get_api_client(user=member) - data = get_minute_put_data() + index = Group.objects.get_or_create(slug="index", name="index")[0] + data = get_minute_put_data(index) response = client.put(url, data) assert response.status_code == status.HTTP_403_FORBIDDEN @pytest.mark.django_db -def test_update_minute_as_index_member(index_member, minute): - """An index member should be able to update a minute""" - minute.author = index_member +@pytest.mark.parametrize( + ("codex_group", "swap"), (CodexGroups.all(), CodexGroups.reverse()) +) +def test_update_to_another_group_as_codex_member(member, minute, codex_group, swap): + """A codex member should not be able to update a minute to another group""" + add_user_to_group_with_name(member, codex_group) + group = Group.objects.get(slug=codex_group) + + minute.author = member + minute.group = group minute.save() + url = get_minute_detail_url(minute) - client = get_api_client(user=index_member) - data = get_minute_put_data() + client = get_api_client(user=member) + + swap_group = Group.objects.get_or_create(slug=swap, name=swap)[0] + + data = get_minute_put_data(swap_group) + response = client.put(url, data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_update_own_group_minute_as_codex_member(member, minute, codex_group): + """A codex member should be able to update a minute that belongs to their group""" + add_user_to_group_with_name(member, codex_group) + group = Group.objects.get(slug=codex_group) + + minute.author = member + minute.group = group + minute.save() + + url = get_minute_detail_url(minute) + client = get_api_client(user=member) + data = get_minute_put_data(group) response = client.put(url, data) assert response.status_code == status.HTTP_200_OK assert response.data["title"] == data["title"] +@pytest.mark.django_db +@pytest.mark.parametrize( + ("codex_group", "swap"), (CodexGroups.all(), CodexGroups.reverse()) +) +def test_update_another_group_minute_as_codex_member(member, minute, codex_group, swap): + """A codex member should not be able to update a minute that belongs to another group""" + add_user_to_group_with_name(member, codex_group) + + minute.group = Group.objects.get_or_create(slug=swap, name=swap)[0] + minute.save() + + url = get_minute_detail_url(minute) + client = get_api_client(user=member) + data = get_minute_put_data() + response = client.put(url, data) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db def test_delete_minute_as_member(member, minute): """A member should not be able to delete a minute""" @@ -77,17 +158,40 @@ def test_delete_minute_as_member(member, minute): @pytest.mark.django_db -def test_delete_minute_as_index_member(index_member, minute): - """An index member should be able to delete a minute""" - minute.author = index_member +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_delete_minute_as_codex_member(member, minute, codex_group): + """A codex member should be able to delete a minute""" + add_user_to_group_with_name(member, codex_group) + + minute.author = member + minute.group = Group.objects.get(slug=codex_group) minute.save() + url = get_minute_detail_url(minute) - client = get_api_client(user=index_member) + client = get_api_client(user=member) response = client.delete(url) assert response.status_code == status.HTTP_200_OK +@pytest.mark.django_db +@pytest.mark.parametrize( + ("codex_group", "swap"), (CodexGroups.all(), CodexGroups.reverse()) +) +def test_delete_another_group_minute_as_codex_member(member, minute, codex_group, swap): + """A codex member should not be able to delete a minute that belongs to another group""" + add_user_to_group_with_name(member, codex_group) + + minute.group = Group.objects.get_or_create(slug=swap, name=swap)[0] + minute.save() + + url = get_minute_detail_url(minute) + client = get_api_client(user=member) + response = client.delete(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db def test_list_minutes_as_member(member): """A member should not be able to list minutes""" @@ -99,15 +203,25 @@ def test_list_minutes_as_member(member): @pytest.mark.django_db -def test_list_minutes_as_index_member(index_member, minute): - """An index member should be able to list minutes""" - minute.author = index_member - minute.save() +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_list_minutes_as_codex_member(member, codex_group): + """A codex member should be able to list minutes from their own group""" + add_user_to_group_with_name(member, codex_group) + group = Group.objects.get(slug=codex_group) + + MinuteFactory.create_batch(5, group=group) + MinuteFactory.create_batch(5) + url = API_MINUTE_BASE_URL - client = get_api_client(user=index_member) + client = get_api_client(user=member) response = client.get(url) + count = response.data["count"] + results = response.data["results"] + assert response.status_code == status.HTTP_200_OK + assert count == 5 + assert all([minute["group"]["slug"] == codex_group.lower() for minute in results]) @pytest.mark.django_db @@ -121,13 +235,17 @@ def test_retrieve_minute_as_member(member, minute): @pytest.mark.django_db -def test_retrieve_minute_as_index_member(index_member, minute): - """An index member should be able to retrieve a minute""" - minute.author = index_member - minute.save() +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_retrieve_minute_as_codex_member(member, codex_group): + """A codex member should be able to retrieve a minute from their own group""" + add_user_to_group_with_name(member, codex_group) + group = Group.objects.get(slug=codex_group) + + minute = MinuteFactory(group=group) + url = get_minute_detail_url(minute) - client = get_api_client(user=index_member) + client = get_api_client(user=member) response = client.get(url) assert response.status_code == status.HTTP_200_OK - assert response.data["id"] == minute.id + assert response.data["group"]["slug"] == codex_group.lower() From d7e9b9121b40cebb8758ecd9c5988c93a0cb13a6 Mon Sep 17 00:00:00 2001 From: Frikk Balder <33499052+MindChirp@users.noreply.github.com> Date: Sat, 14 Sep 2024 17:43:47 +0200 Subject: [PATCH 33/59] Changed endpoint response (#846) * Changed endpoint response * Fixed test * Update test_reservation_integration.py --------- Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> --- app/kontres/views/reservation.py | 2 +- app/tests/kontres/test_reservation_integration.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/kontres/views/reservation.py b/app/kontres/views/reservation.py index 1a0165fd..f15e7b44 100644 --- a/app/kontres/views/reservation.py +++ b/app/kontres/views/reservation.py @@ -85,5 +85,5 @@ def update(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): super().destroy(self, request, *args, **kwargs) return Response( - {"detail": "Reservasjonen ble slettet."}, status=status.HTTP_204_NO_CONTENT + {"detail": "Reservasjonen ble slettet."}, status=status.HTTP_200_OK ) diff --git a/app/tests/kontres/test_reservation_integration.py b/app/tests/kontres/test_reservation_integration.py index 81ffaae6..0ccdc82b 100644 --- a/app/tests/kontres/test_reservation_integration.py +++ b/app/tests/kontres/test_reservation_integration.py @@ -160,7 +160,7 @@ def test_member_deleting_own_reservation(member, reservation): reservation.save() client = get_api_client(user=member) response = client.delete(f"/kontres/reservations/{reservation.id}/", format="json") - assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.status_code == status.HTTP_200_OK assert response.data["detail"] == "Reservasjonen ble slettet." @@ -394,7 +394,7 @@ def test_admin_can_delete_any_reservation(admin_user, reservation): f"/kontres/reservations/{reservation.id}/", format="json", ) - assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.status_code == status.HTTP_200_OK @pytest.mark.django_db From b574fdccd6ff992990ca1a3a711b2be0fea2bfce Mon Sep 17 00:00:00 2001 From: Mads Nylund Date: Sat, 14 Sep 2024 17:46:06 +0200 Subject: [PATCH 34/59] updated changelog.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94a8fb12..6d43bb08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ ## Neste versjon +## Verson 2024.09.14 +- ⚡**Codex**. Det er nå et skille mellom dokumenter opprettet av Drift og Index. + ## Versjon 2024.08.21 - ⚡**Nyheter**. Ledere av komiteer kan nå opprette nyheter. - ⚡**Forms**. Alle ledere av grupper kan nå opprette gruppe spørreskjemaer. From 99ba049b1d2532f92a4089907c619e323eec3649 Mon Sep 17 00:00:00 2001 From: Emil Johnsen <111747340+1Cezzo@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:04:31 +0200 Subject: [PATCH 35/59] finished events now appear in the correct order (newest first) (#849) * finished events now appear in the correct order (newest first) * added description of change in changelog * fixed formatting * updated method to use Django ORM instead of using python methods --- CHANGELOG.md | 2 + app/content/views/user.py | 22 +++--- .../content/test_user_bio_integration.py | 70 +++++++++++++++++++ 3 files changed, 85 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48850105..591e7d99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ ## Neste versjon +- ⚡**Profil**. Endret rekkefølge på tidligere arrangementer slik at nyeste kommer først. + ## Versjon 2024.09.14 - ⚡**Codex**. Det er nå et skille mellom dokumenter opprettet av Drift og Index. diff --git a/app/content/views/user.py b/app/content/views/user.py index b8ef4e18..f7b5d5d2 100644 --- a/app/content/views/user.py +++ b/app/content/views/user.py @@ -1,5 +1,6 @@ from django.db import IntegrityError from django.shortcuts import get_object_or_404 +from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, status from rest_framework.decorators import action @@ -21,6 +22,7 @@ from app.communication.notifier import Notify from app.content.filters import UserFilter from app.content.models import User +from app.content.models.event import Event from app.content.serializers import ( DefaultUserSerializer, EventListSerializer, @@ -304,17 +306,19 @@ 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) + event_has_ended = CaseInsensitiveBooleanQueryParam(filter_field).value - events = [ - registration.event - for registration in registrations - if registration.event.expired == event_has_ended.value - ] + now = timezone.now() + + if event_has_ended: + events = Event.objects.filter( + registered_users_list=request.user, end_date__lte=now + ).order_by("-start_date") + else: + events = Event.objects.filter( + registered_users_list=request.user, end_date__gt=now + ).order_by("start_date") return self.paginate_response( data=events, serializer=EventListSerializer, context={"request": request} diff --git a/app/tests/content/test_user_bio_integration.py b/app/tests/content/test_user_bio_integration.py index a55978d3..b1afc5c7 100644 --- a/app/tests/content/test_user_bio_integration.py +++ b/app/tests/content/test_user_bio_integration.py @@ -1,7 +1,13 @@ +from datetime import timedelta + +from django.utils import timezone from rest_framework import status import pytest +from app.content.factories.event_factory import EventFactory +from app.content.factories.registration_factory import RegistrationFactory +from app.content.models.registration import Registration from app.content.models.user_bio import UserBio from app.util.test_utils import get_api_client @@ -122,3 +128,67 @@ def test_destroy_other_bios(member, user_bio): assert response.status_code == status.HTTP_403_FORBIDDEN assert len(UserBio.objects.filter(id=user_bio.id)) + + +@pytest.mark.django_db +def test_get_user_events_sorted_when_expired_true(member, api_client): + """When the expired filter is 'true', the events should be sorted by start_date in descending order""" + event1 = EventFactory( + start_date=timezone.now() - timedelta(days=5), + end_date=timezone.now() - timedelta(days=4), + ) + event2 = EventFactory( + start_date=timezone.now() - timedelta(days=10), + end_date=timezone.now() - timedelta(days=9), + ) + event3 = EventFactory( + start_date=timezone.now() - timedelta(days=2), + end_date=timezone.now() - timedelta(days=1), + ) + + RegistrationFactory(user=member, event=event1) + RegistrationFactory(user=member, event=event2) + RegistrationFactory(user=member, event=event3) + + client = api_client(user=member) + response = client.get("/users/me/events/?page=1&expired=true") + + assert response.status_code == status.HTTP_200_OK + + event_ids = [event3.id, event1.id, event2.id] + returned_event_ids = [event["id"] for event in response.data["results"]] + + assert returned_event_ids == event_ids + + +@pytest.mark.django_db +def test_get_user_events_unsorted_when_expired_false(member, api_client): + """When the expired filter is not 'true', the events should not be sorted by start_date""" + event1 = EventFactory( + start_date=timezone.now() + timedelta(days=5), + end_date=timezone.now() + timedelta(days=6), + ) + event2 = EventFactory( + start_date=timezone.now() + timedelta(days=10), + end_date=timezone.now() + timedelta(days=11), + ) + event3 = EventFactory( + start_date=timezone.now() + timedelta(days=1), + end_date=timezone.now() + timedelta(days=2), + ) + + RegistrationFactory(user=member, event=event1) + RegistrationFactory(user=member, event=event2) + RegistrationFactory(user=member, event=event3) + + client = api_client(user=member) + response = client.get("/users/me/events/?page=1&expired=false") + + assert response.status_code == status.HTTP_200_OK + + registration_ids = Registration.objects.filter(user=member).values_list( + "event_id", flat=True + ) + returned_event_ids = [event["id"] for event in response.data["results"]] + + assert returned_event_ids == list(registration_ids) From a17c46d4a2a0705e9a9e7d5dc88a3da0fc2fc5d7 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Fri, 20 Sep 2024 15:13:50 +0200 Subject: [PATCH 36/59] Implement Swagger (#858) * started on removing choiceenums * refactored cheatsheet and membership * refacotered strike enum * refactored Groups enum * removed AppModel choiceenum * added swagger --- app/badge/filters/badge.py | 2 +- app/common/enums.py | 89 ++++++++++++++++--- app/content/admin/admin.py | 2 +- app/content/enums.py | 4 +- app/content/factories/cheatsheet_factory.py | 6 +- app/content/factories/strike_factory.py | 2 +- app/content/filters/strike.py | 2 +- app/content/filters/user.py | 5 +- ...t_grade_alter_cheatsheet_study_and_more.py | 59 ++++++++++++ app/content/models/cheatsheet.py | 14 ++- app/content/models/event.py | 6 +- app/content/models/news.py | 5 +- app/content/models/registration.py | 4 +- app/content/models/toddel.py | 5 +- app/content/models/user.py | 10 ++- app/content/serializers/event.py | 8 +- app/content/serializers/minute.py | 6 +- app/content/serializers/registration.py | 2 +- app/content/serializers/user.py | 5 +- app/content/tests/test_registration_model.py | 4 +- app/content/tests/test_user_model.py | 2 +- app/content/views/strike.py | 4 +- app/content/views/user.py | 5 +- app/emoji/serializers/reaction.py | 4 +- app/forms/enums.py | 14 +++ .../migrations/0012_alter_eventform_type.py | 22 +++++ app/forms/migrations/0013_alter_field_type.py | 26 ++++++ app/forms/models/forms.py | 15 ++-- app/forms/serializers/statistics.py | 2 +- app/forms/views/submission.py | 2 +- app/group/factories/group_factory.py | 2 +- app/group/factories/membership_factory.py | 2 +- app/group/filters/group.py | 4 +- app/group/filters/membership.py | 2 +- app/group/migrations/0019_alter_group_type.py | 31 +++++++ ...ter_membership_membership_type_and_more.py | 31 +++++++ app/group/models/group.py | 6 +- app/group/models/membership.py | 14 +-- app/group/serializers/group.py | 5 +- app/group/tests/test_membership_model.py | 6 +- app/group/views/membership.py | 5 +- app/kontres/models/bookable_item.py | 5 +- app/kontres/models/reservation.py | 6 +- app/settings.py | 22 +++-- app/tests/conftest.py | 6 +- .../content/test_cheatsheet_integration.py | 14 ++- app/tests/content/test_event_integration.py | 9 +- app/tests/content/test_news_integration.py | 7 +- .../content/test_registration_integration.py | 8 +- app/tests/content/test_strike_integration.py | 8 +- app/tests/content/test_toddel_integration.py | 5 +- app/tests/content/test_user_integration.py | 7 +- app/tests/forms/test_eventform_integration.py | 12 ++- app/tests/forms/test_form_integration.py | 2 +- .../forms/test_group_form_integration.py | 6 +- .../forms/test_submission_integration.py | 4 +- app/tests/groups/test_fine_integration.py | 5 +- app/tests/groups/test_law_integration.py | 5 +- .../test_membership_history_integration.py | 5 +- .../groups/test_membership_integration.py | 5 +- .../payment/test_paid_event_integration.py | 2 +- app/urls.py | 16 ++++ app/util/test_utils.py | 6 +- requirements.txt | 1 + 64 files changed, 492 insertions(+), 118 deletions(-) create mode 100644 app/content/migrations/0068_alter_cheatsheet_grade_alter_cheatsheet_study_and_more.py create mode 100644 app/forms/migrations/0012_alter_eventform_type.py create mode 100644 app/forms/migrations/0013_alter_field_type.py create mode 100644 app/group/migrations/0019_alter_group_type.py create mode 100644 app/group/migrations/0020_alter_membership_membership_type_and_more.py diff --git a/app/badge/filters/badge.py b/app/badge/filters/badge.py index 30950dc6..91e91a03 100644 --- a/app/badge/filters/badge.py +++ b/app/badge/filters/badge.py @@ -4,7 +4,7 @@ from app.badge.models import BadgeCategory, UserBadge from app.badge.models.badge import Badge -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.content.models import User from app.group.models.membership import Membership diff --git a/app/common/enums.py b/app/common/enums.py index 15f8eb05..78d4d384 100644 --- a/app/common/enums.py +++ b/app/common/enums.py @@ -5,6 +5,7 @@ from enumchoicefield import ChoiceEnum +# This can't be removed because it is used in the migrations. It is not used in the code. class UserClass(ChoiceEnum): FIRST = "1. Klasse" SECOND = "2. Klasse" @@ -14,6 +15,23 @@ class UserClass(ChoiceEnum): ALUMNI = "Alumni" +class NativeUserClass(models.TextChoices): + FIRST = "FIRST", "1. Klasse" + SECOND = "SECOND", "2. Klasse" + THIRD = "THIRD", "3. Klasse" + FOURTH = "FOURTH", "4. Klasse" + FIFTH = "FIFTH", "5. Klasse" + ALUMNI = "ALUMNI", "Alumni" + + +def get_user_class_number(user_class: NativeUserClass) -> int: + _class = user_class.label + if user_class == NativeUserClass.ALUMNI: + return 6 + return int(_class.split(".")[0]) + + +# This can't be removed because it is used in the migrations. It is not used in the code class UserStudy(ChoiceEnum): DATAING = "Dataingeniør" DIGFOR = "Digital forretningsutvikling" @@ -23,7 +41,16 @@ class UserStudy(ChoiceEnum): INFO = "Informasjonsbehandling" -class AdminGroup(ChoiceEnum): +class NativeUserStudy(models.TextChoices): + DATAING = "DATAING", "Dataingeniør" + DIGFOR = "DIGFOR", "Digital forretningsutvikling" + DIGINC = "DIGINC", "Digital infrastruktur og cybersikkerhet" + DIGSAM = "DIGSAM", "Digital samhandling" + DRIFT = "DRIFT", "Drift" + INFO = "INFO", "Informasjonsbehandling" + + +class AdminGroup(models.TextChoices): HS = "HS" INDEX = "Index" NOK = "Nok" @@ -40,7 +67,7 @@ def admin(cls): return (cls.HS, cls.INDEX) -class Groups(ChoiceEnum): +class Groups(models.TextChoices): TIHLDE = "TIHLDE" JUBKOM = "JubKom" REDAKSJONEN = "Redaksjonen" @@ -48,16 +75,12 @@ class Groups(ChoiceEnum): PLASK = "Plask" DRIFT = "Drift" - -class AppModel(ChoiceEnum): - EVENT = "Event" - JOBPOST = "Jobpost" - NEWS = "News" - USER = "User" - CHEATSHEET = "Cheatsheet" - WEEKLY_BUSINESS = "Weekly Business" + @classmethod + def all(cls): + return (cls.TIHLDE, cls.JUBKOM, cls.REDAKSJONEN, cls.FONDET, cls.PLASK, cls.DRIFT) +# This can't be removed because it is used in the migrations. It is not used in the code. class GroupType(ChoiceEnum): TIHLDE = "TIHLDE" BOARD = "Styre" @@ -72,9 +95,20 @@ class GroupType(ChoiceEnum): def public_groups(cls): return [cls.BOARD, cls.SUBGROUP, cls.COMMITTEE, cls.INTERESTGROUP] + +class NativeGroupType(models.TextChoices): + TIHLDE = "TIHLDE", "TIHLDE" + BOARD = "BOARD", "Styre" + SUBGROUP = "SUBGROUP", "Undergruppe" + COMMITTEE = "COMMITTEE", "Komité" + STUDYYEAR = "STUDYYEAR", "Studieår" + STUDY = "STUDY", "Studie" + INTERESTGROUP = "INTERESTGROUP", "Interesse Gruppe" + OTHER = "OTHER", "Annet" + @classmethod - def all(cls): - return list(map(lambda c: (c.name, c.value), cls)) + def public_groups(cls): + return [cls.BOARD, cls.SUBGROUP, cls.COMMITTEE, cls.INTERESTGROUP] class EnvironmentOptions(Enum): @@ -83,6 +117,7 @@ class EnvironmentOptions(Enum): PRODUCTION = "PRODUCTION" +# This can't be removed because it is used in the migrations. It is not used in the code class CheatsheetType(ChoiceEnum): FILE = "Fil" GITHUB = "GitHub" @@ -90,6 +125,14 @@ class CheatsheetType(ChoiceEnum): OTHER = "Annet" +class NativeCheatsheetType(models.TextChoices): + FILE = "FILE", "Fil" + GITHUB = "GITHUB", "GitHub" + LINK = "LINK", "Link" + OTHER = "OTHER", "Annet" + + +# This can't be removed because it is used in the migrations. It is not used in the code class MembershipType(ChoiceEnum): LEADER = "Leader" MEMBER = "Member" @@ -103,6 +146,16 @@ def all(cls): return tuple((i.name, i.value) for i in cls) +class NativeMembershipType(models.TextChoices): + LEADER = "LEADER", "Leader" + MEMBER = "MEMBER", "Member" + + @classmethod + def board_members(cls): + return (cls.LEADER,) + + +# This can't be removed because it is used in the migrations. It is not used in the code class StrikeEnum(ChoiceEnum): PAST_DEADLINE = "PAST_DEADLINE" NO_SHOW = "NO_SHOW" @@ -111,6 +164,18 @@ class StrikeEnum(ChoiceEnum): EVAL_FORM = "EVAL_FORM" +class NativeStrikeEnum(models.TextChoices): + PAST_DEADLINE = "PAST_DEADLINE" + NO_SHOW = "NO_SHOW" + LATE = "LATE" + BAD_BEHAVIOR = "BAD_BEHAVIOR" + EVAL_FORM = "EVAL_FORM" + + @classmethod + def all(cls): + return [cls.PAST_DEADLINE, cls.NO_SHOW, cls.LATE, cls.BAD_BEHAVIOR, cls.EVAL_FORM] + + class CodexGroups(models.TextChoices): DRIFT = "Drift" INDEX = "Index" diff --git a/app/content/admin/admin.py b/app/content/admin/admin.py index 0ac3488f..1a219f7e 100644 --- a/app/content/admin/admin.py +++ b/app/content/admin/admin.py @@ -5,7 +5,7 @@ from django.utils.html import escape from django.utils.safestring import mark_safe -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.content import models from app.group.models.membership import Membership diff --git a/app/content/enums.py b/app/content/enums.py index 5d2332a8..c9d7bc8a 100644 --- a/app/content/enums.py +++ b/app/content/enums.py @@ -1,7 +1,5 @@ from django.db import models -from enumchoicefield import ChoiceEnum - class UserClass(models.IntegerChoices): FIRST = 1 @@ -11,7 +9,7 @@ class UserClass(models.IntegerChoices): FIFTH = 5 -class CategoryEnum(ChoiceEnum): +class CategoryEnum(models.TextChoices): ACTIVITY = "Aktivitet" SOSIALT = "Sosialt" BEDPRES = "Bedpres" diff --git a/app/content/factories/cheatsheet_factory.py b/app/content/factories/cheatsheet_factory.py index 73923fa5..829536b1 100644 --- a/app/content/factories/cheatsheet_factory.py +++ b/app/content/factories/cheatsheet_factory.py @@ -3,7 +3,11 @@ import factory from factory.django import DjangoModelFactory -from app.common.enums import CheatsheetType, UserClass, UserStudy +from app.common.enums import ( + NativeCheatsheetType as CheatsheetType, + NativeUserClass as UserClass, + NativeUserStudy as UserStudy +) from app.content.models import Cheatsheet diff --git a/app/content/factories/strike_factory.py b/app/content/factories/strike_factory.py index 102eed01..275d27ce 100644 --- a/app/content/factories/strike_factory.py +++ b/app/content/factories/strike_factory.py @@ -5,7 +5,7 @@ import factory from factory.django import DjangoModelFactory -from app.common.enums import StrikeEnum +from app.common.enums import NativeStrikeEnum as StrikeEnum from app.content.factories.event_factory import EventFactory from app.content.factories.user_factory import UserFactory from app.content.models.strike import Strike diff --git a/app/content/filters/strike.py b/app/content/filters/strike.py index 7cec2b86..f8b82000 100644 --- a/app/content/filters/strike.py +++ b/app/content/filters/strike.py @@ -2,7 +2,7 @@ from django_filters import filters from django_filters.rest_framework.filterset import FilterSet -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.content.models import Strike from app.group.models.membership import Membership diff --git a/app/content/filters/user.py b/app/content/filters/user.py index 69ffd2e6..8f3563f9 100644 --- a/app/content/filters/user.py +++ b/app/content/filters/user.py @@ -1,6 +1,9 @@ from django_filters.rest_framework import BooleanFilter, CharFilter, FilterSet -from app.common.enums import Groups, GroupType +from app.common.enums import ( + Groups, + NativeGroupType as GroupType +) from app.content.models import User from app.content.models.strike import Strike diff --git a/app/content/migrations/0068_alter_cheatsheet_grade_alter_cheatsheet_study_and_more.py b/app/content/migrations/0068_alter_cheatsheet_grade_alter_cheatsheet_study_and_more.py new file mode 100644 index 00000000..10657218 --- /dev/null +++ b/app/content/migrations/0068_alter_cheatsheet_grade_alter_cheatsheet_study_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.5 on 2024-09-20 08:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0067_alter_minute_group"), + ] + + operations = [ + migrations.AlterField( + model_name="cheatsheet", + name="grade", + field=models.CharField( + choices=[ + ("FIRST", "1. Klasse"), + ("SECOND", "2. Klasse"), + ("THIRD", "3. Klasse"), + ("FOURTH", "4. Klasse"), + ("FIFTH", "5. Klasse"), + ("ALUMNI", "Alumni"), + ], + default="FIRST", + max_length=50, + ), + ), + migrations.AlterField( + model_name="cheatsheet", + name="study", + field=models.CharField( + choices=[ + ("DATAING", "Dataingeniør"), + ("DIGFOR", "Digital forretningsutvikling"), + ("DIGINC", "Digital infrastruktur og cybersikkerhet"), + ("DIGSAM", "Digital samhandling"), + ("DRIFT", "Drift"), + ("INFO", "Informasjonsbehandling"), + ], + default="DATAING", + max_length=50, + ), + ), + migrations.AlterField( + model_name="cheatsheet", + name="type", + field=models.CharField( + choices=[ + ("FILE", "Fil"), + ("GITHUB", "GitHub"), + ("LINK", "Link"), + ("OTHER", "Annet"), + ], + default="LINK", + max_length=50, + ), + ), + ] diff --git a/app/content/models/cheatsheet.py b/app/content/models/cheatsheet.py index d27c7ae4..089de017 100644 --- a/app/content/models/cheatsheet.py +++ b/app/content/models/cheatsheet.py @@ -2,14 +2,12 @@ from django.db import models -from enumchoicefield import EnumChoiceField - from app.common.enums import ( AdminGroup, - CheatsheetType, + NativeCheatsheetType as CheatsheetType, Groups, - UserClass, - UserStudy, + NativeUserClass as UserClass, + NativeUserStudy as UserStudy, ) from app.common.permissions import BasePermissionModel from app.util.models import BaseModel @@ -21,10 +19,10 @@ class Cheatsheet(BaseModel, BasePermissionModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) title = models.CharField(max_length=200) creator = models.CharField(max_length=200) - grade = EnumChoiceField(UserClass, default=UserClass.FIRST) - study = EnumChoiceField(UserStudy, default=UserStudy.DATAING) + grade = models.CharField(max_length=50, choices=UserClass.choices, default=UserClass.FIRST) + study = models.CharField(max_length=50, choices=UserStudy.choices, default=UserStudy.DATAING) course = models.CharField(max_length=200) - type = EnumChoiceField(CheatsheetType, default=CheatsheetType.LINK) + type = models.CharField(max_length=50, choices=CheatsheetType.choices, default=CheatsheetType.LINK) official = models.BooleanField(default=False) url = models.URLField(max_length=600) diff --git a/app/content/models/event.py b/app/content/models/event.py index 0accdbf1..c23401fc 100644 --- a/app/content/models/event.py +++ b/app/content/models/event.py @@ -13,7 +13,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.forms.enums import NativeEventFormType from app.group.models.group import Group from app.util.models import BaseModel, OptionalImage from app.util.utils import now, yesterday @@ -174,11 +174,11 @@ def has_priorities(self): @property def evaluation(self): - return self.forms.filter(type=EventFormType.EVALUATION).first() + return self.forms.filter(type=NativeEventFormType.EVALUATION).first() @property def survey(self): - return self.forms.filter(type=EventFormType.SURVEY).first() + return self.forms.filter(type=NativeEventFormType.SURVEY).first() def check_request_user_has_access_through_organizer(self, user, organizer): return user.memberships_with_events_access.filter(group=organizer).exists() diff --git a/app/content/models/news.py b/app/content/models/news.py index 886105bb..46d864ec 100644 --- a/app/content/models/news.py +++ b/app/content/models/news.py @@ -2,7 +2,10 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models -from app.common.enums import AdminGroup, Groups +from app.common.enums import ( + AdminGroup, + Groups +) from app.common.permissions import BasePermissionModel, check_has_access from app.emoji.models.reaction import Reaction from app.util.models import BaseModel, OptionalImage diff --git a/app/content/models/registration.py b/app/content/models/registration.py index 92224b56..19c6c379 100644 --- a/app/content/models/registration.py +++ b/app/content/models/registration.py @@ -6,7 +6,7 @@ from sentry_sdk import capture_exception -from app.common.enums import StrikeEnum +from app.common.enums import NativeStrikeEnum as StrikeEnum from app.common.permissions import BasePermissionModel from app.communication.enums import UserNotificationSettingType from app.communication.notifier import Notify @@ -20,7 +20,7 @@ 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.forms.enums import NativeEventFormType as 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 diff --git a/app/content/models/toddel.py b/app/content/models/toddel.py index f4252180..da686eb8 100644 --- a/app/content/models/toddel.py +++ b/app/content/models/toddel.py @@ -1,6 +1,9 @@ from django.db import models -from app.common.enums import AdminGroup, Groups +from app.common.enums import ( + AdminGroup, + Groups +) from app.common.permissions import BasePermissionModel from app.util.models import BaseModel from app.util.utils import datetime_format diff --git a/app/content/models/user.py b/app/content/models/user.py index 1ab2db3a..7eb0d89e 100644 --- a/app/content/models/user.py +++ b/app/content/models/user.py @@ -13,7 +13,12 @@ from django.dispatch import receiver from rest_framework.authtoken.models import Token -from app.common.enums import AdminGroup, Groups, GroupType, MembershipType +from app.common.enums import ( + AdminGroup, + Groups, + NativeGroupType as GroupType, + NativeMembershipType as MembershipType +) from app.common.permissions import check_has_access from app.util.models import BaseModel, OptionalImage from app.util.utils import disable_for_loaddata, now @@ -171,7 +176,8 @@ def has_unanswered_evaluations_for(self, event): return self.get_unanswered_evaluations().filter(event=event).exists() def get_unanswered_evaluations(self): - from app.forms.models.forms import EventForm, EventFormType + from app.forms.models.forms import EventForm + from app.forms.enums import NativeEventFormType as EventFormType date_30_days_ago = now() - timedelta(days=30) registrations = self.registrations.filter(has_attended=True) diff --git a/app/content/serializers/event.py b/app/content/serializers/event.py index f2deee1b..73be7ad5 100644 --- a/app/content/serializers/event.py +++ b/app/content/serializers/event.py @@ -3,7 +3,7 @@ from dry_rest_permissions.generics import DRYPermissionsField from sentry_sdk import capture_exception -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.common.serializers import BaseModelSerializer from app.content.models import Event, PriorityPool from app.content.serializers.category import SimpleCategorySerializer @@ -21,16 +21,16 @@ class EventSerializer(serializers.ModelSerializer): expired = serializers.BooleanField(read_only=True) - priority_pools = PriorityPoolSerializer(many=True, required=False) + priority_pools = PriorityPoolSerializer(many=True, read_only=True, required=False) evaluation = serializers.PrimaryKeyRelatedField(many=False, read_only=True) survey = serializers.PrimaryKeyRelatedField(many=False, read_only=True) organizer = SimpleGroupSerializer(read_only=True) - permissions = DRYPermissionsField(actions=["write", "read"], object_only=True) + permissions = DRYPermissionsField(actions=["write", "read"], object_only=True, read_only=True) paid_information = serializers.SerializerMethodField( required=False, allow_null=True ) contact_person = DefaultUserSerializer(read_only=True, required=False) - reactions = ReactionSerializer(required=False, many=True) + reactions = ReactionSerializer(read_only=True, many=True) category = SimpleCategorySerializer(read_only=True) class Meta: diff --git a/app/content/serializers/minute.py b/app/content/serializers/minute.py index 78eeb3d8..26c1ad47 100644 --- a/app/content/serializers/minute.py +++ b/app/content/serializers/minute.py @@ -4,7 +4,7 @@ from app.group.serializers import SimpleGroupSerializer -class SimpleUserSerializer(serializers.ModelSerializer): +class SimpleMinuteUserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ("user_id", "first_name", "last_name", "image") @@ -22,7 +22,7 @@ def create(self, validated_data): class MinuteSerializer(serializers.ModelSerializer): - author = SimpleUserSerializer(read_only=True) + author = SimpleMinuteUserSerializer(read_only=True) group = SimpleGroupSerializer(read_only=True) class Meta: @@ -49,7 +49,7 @@ def update(self, instance, validated_data): class MinuteListSerializer(serializers.ModelSerializer): - author = SimpleUserSerializer(read_only=True) + author = SimpleMinuteUserSerializer(read_only=True) group = SimpleGroupSerializer(read_only=True) class Meta: diff --git a/app/content/serializers/registration.py b/app/content/serializers/registration.py index 724c2c69..4c7ac599 100644 --- a/app/content/serializers/registration.py +++ b/app/content/serializers/registration.py @@ -7,7 +7,7 @@ UserListSerializer, ) from app.content.util.registration_utils import get_payment_expiredate -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.serializers.submission import SubmissionInRegistrationSerializer from app.payment.enums import OrderStatus from app.payment.util.order_utils import has_paid_order diff --git a/app/content/serializers/user.py b/app/content/serializers/user.py index fc2d9162..132d2216 100644 --- a/app/content/serializers/user.py +++ b/app/content/serializers/user.py @@ -4,7 +4,10 @@ from dry_rest_permissions.generics import DRYGlobalPermissionsField -from app.common.enums import Groups, GroupType +from app.common.enums import ( + Groups, + NativeGroupType as GroupType +) from app.common.serializers import BaseModelSerializer from app.communication.enums import UserNotificationSettingType from app.communication.notifier import Notify diff --git a/app/content/tests/test_registration_model.py b/app/content/tests/test_registration_model.py index e59301a8..e6a68e01 100644 --- a/app/content/tests/test_registration_model.py +++ b/app/content/tests/test_registration_model.py @@ -4,7 +4,7 @@ import pytest -from app.common.enums import MembershipType +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories import ( EventFactory, PriorityPoolFactory, @@ -12,7 +12,7 @@ StrikeFactory, UserFactory, ) -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.models.forms import Submission from app.forms.tests.form_factories import EventFormFactory, SubmissionFactory from app.group.factories import GroupFactory, MembershipFactory diff --git a/app/content/tests/test_user_model.py b/app/content/tests/test_user_model.py index a4d6f51e..b3e6ddd1 100644 --- a/app/content/tests/test_user_model.py +++ b/app/content/tests/test_user_model.py @@ -1,7 +1,7 @@ import pytest from app.content.factories import RegistrationFactory, UserFactory -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.tests.form_factories import EventFormFactory, SubmissionFactory pytestmark = pytest.mark.django_db diff --git a/app/content/views/strike.py b/app/content/views/strike.py index 9303ef7c..f8c7d3c4 100644 --- a/app/content/views/strike.py +++ b/app/content/views/strike.py @@ -3,7 +3,7 @@ from rest_framework import filters, status from rest_framework.response import Response -from app.common.enums import StrikeEnum +from app.common.enums import NativeStrikeEnum as StrikeEnum from app.common.pagination import BasePagination from app.common.permissions import BasicViewPermission from app.common.viewsets import BaseViewSet @@ -42,7 +42,7 @@ def update(self, request, *args, **kwargs): def create(self, request): if "enum" in request.data: enum = request.data["enum"] - if enum not in StrikeEnum._member_names_: + if enum not in StrikeEnum.all(): return Response( {"detail": "Fant ikke Enum"}, status=status.HTTP_404_NOT_FOUND ) diff --git a/app/content/views/user.py b/app/content/views/user.py index f7b5d5d2..acda9f93 100644 --- a/app/content/views/user.py +++ b/app/content/views/user.py @@ -8,7 +8,10 @@ from app.badge.models import Badge, UserBadge from app.badge.serializers import BadgeSerializer, UserBadgeSerializer -from app.common.enums import Groups, GroupType +from app.common.enums import ( + Groups, + NativeGroupType as GroupType +) from app.common.mixins import ActionMixin from app.common.pagination import BasePagination from app.common.permissions import ( diff --git a/app/emoji/serializers/reaction.py b/app/emoji/serializers/reaction.py index 8fdd92df..db3576ac 100644 --- a/app/emoji/serializers/reaction.py +++ b/app/emoji/serializers/reaction.py @@ -15,14 +15,14 @@ from app.emoji.models.reaction import Reaction -class SimpleUserSerializer(serializers.ModelSerializer): +class SimpleReactionUserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ("user_id", "first_name", "last_name", "image") class ReactionSerializer(BaseModelSerializer): - user = SimpleUserSerializer(read_only=True) + user = SimpleReactionUserSerializer(read_only=True) class Meta: model = Reaction diff --git a/app/forms/enums.py b/app/forms/enums.py index 0f982985..3b9bf324 100644 --- a/app/forms/enums.py +++ b/app/forms/enums.py @@ -1,12 +1,26 @@ from enumchoicefield import ChoiceEnum +from django.db import models +# This must be here because of the migrations files class EventFormType(ChoiceEnum): SURVEY = "Survey" EVALUATION = "Evaluation" +class NativeEventFormType(models.TextChoices): + SURVEY = "SURVEY", "Survey" + EVALUATION = "EVALUATION", "Evaluation" + + +# This must be here because of the migrations files class FormFieldType(ChoiceEnum): TEXT_ANSWER = "Text answer" MULTIPLE_SELECT = "Multiple select" SINGLE_SELECT = "Single select" + + +class NativeFormFieldType(models.TextChoices): + TEXT_ANSWER = "TEXT_ANSWER", "Text answer" + MULTIPLE_SELECT = "MULTIPLE_SELECT", "Multiple select" + SINGLE_SELECT = "SINGLE_SELECT", "Single select" diff --git a/app/forms/migrations/0012_alter_eventform_type.py b/app/forms/migrations/0012_alter_eventform_type.py new file mode 100644 index 00000000..3bcf254f --- /dev/null +++ b/app/forms/migrations/0012_alter_eventform_type.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.5 on 2024-09-20 07:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("forms", "0011_alter_form_polymorphic_ctype"), + ] + + operations = [ + migrations.AlterField( + model_name="eventform", + name="type", + field=models.CharField( + choices=[("SURVEY", "Survey"), ("EVALUATION", "Evaluation")], + default="SURVEY", + max_length=40, + ), + ), + ] diff --git a/app/forms/migrations/0013_alter_field_type.py b/app/forms/migrations/0013_alter_field_type.py new file mode 100644 index 00000000..22098fc6 --- /dev/null +++ b/app/forms/migrations/0013_alter_field_type.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.5 on 2024-09-20 07:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("forms", "0012_alter_eventform_type"), + ] + + operations = [ + migrations.AlterField( + model_name="field", + name="type", + field=models.CharField( + choices=[ + ("TEXT_ANSWER", "Text answer"), + ("MULTIPLE_SELECT", "Multiple select"), + ("SINGLE_SELECT", "Single select"), + ], + default="TEXT_ANSWER", + max_length=40, + ), + ), + ] diff --git a/app/forms/models/forms.py b/app/forms/models/forms.py index f933f661..48671059 100644 --- a/app/forms/models/forms.py +++ b/app/forms/models/forms.py @@ -2,15 +2,20 @@ from django.db import models, transaction -from enumchoicefield import EnumChoiceField from ordered_model.models import OrderedModel from polymorphic.models import PolymorphicModel -from app.common.enums import AdminGroup, Groups +from app.common.enums import ( + AdminGroup, + Groups +) from app.common.permissions import BasePermissionModel, check_has_access from app.content.models.event import Event from app.content.models.user import User -from app.forms.enums import EventFormType, FormFieldType +from app.forms.enums import ( + NativeFormFieldType as FormFieldType, + NativeEventFormType as EventFormType +) from app.forms.exceptions import ( DuplicateSubmission, FormNotOpenForSubmission, @@ -113,7 +118,7 @@ def has_object_read_permission(self, request): class EventForm(Form): event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="forms") - type = EnumChoiceField(EventFormType, default=EventFormType.SURVEY) + type = models.CharField(max_length=40, choices=EventFormType.choices, default=EventFormType.SURVEY) class Meta: unique_together = ("event", "type") @@ -213,7 +218,7 @@ class Field(OrderedModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) title = models.CharField(max_length=400) form = models.ForeignKey(Form, on_delete=models.CASCADE, related_name="fields") - type = EnumChoiceField(FormFieldType, default=FormFieldType.TEXT_ANSWER) + type = models.CharField(max_length=40, choices=FormFieldType.choices, default=FormFieldType.TEXT_ANSWER) required = models.BooleanField(default=False) order_with_respect_to = "form" diff --git a/app/forms/serializers/statistics.py b/app/forms/serializers/statistics.py index 902eb484..a218373c 100644 --- a/app/forms/serializers/statistics.py +++ b/app/forms/serializers/statistics.py @@ -4,7 +4,7 @@ from rest_polymorphic.serializers import PolymorphicSerializer from app.common.serializers import BaseModelSerializer -from app.forms.enums import FormFieldType +from app.forms.enums import NativeFormFieldType as FormFieldType from app.forms.models import EventForm, Field, Form, Option from app.forms.models.forms import Answer diff --git a/app/forms/views/submission.py b/app/forms/views/submission.py index 62dba010..b148a212 100644 --- a/app/forms/views/submission.py +++ b/app/forms/views/submission.py @@ -10,7 +10,7 @@ from app.common.permissions import BasicViewPermission from app.common.viewsets import BaseViewSet from app.forms.csv_writer import SubmissionsCsvWriter -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.mixins import APIFormErrorsMixin from app.forms.models.forms import EventForm, Form, Submission from app.forms.serializers.submission import SubmissionSerializer diff --git a/app/group/factories/group_factory.py b/app/group/factories/group_factory.py index 4c2e2f56..0c98efe9 100644 --- a/app/group/factories/group_factory.py +++ b/app/group/factories/group_factory.py @@ -1,7 +1,7 @@ import factory from factory.django import DjangoModelFactory -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.content.factories.user_factory import UserFactory from app.group.models import Group diff --git a/app/group/factories/membership_factory.py b/app/group/factories/membership_factory.py index ab4b2496..8e63cf23 100644 --- a/app/group/factories/membership_factory.py +++ b/app/group/factories/membership_factory.py @@ -5,7 +5,7 @@ import factory from factory.django import DjangoModelFactory -from app.common.enums import MembershipType +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories import UserFactory from app.group.factories import GroupFactory from app.group.models import Membership, MembershipHistory diff --git a/app/group/filters/group.py b/app/group/filters/group.py index a5eb7729..a53cb752 100644 --- a/app/group/filters/group.py +++ b/app/group/filters/group.py @@ -1,13 +1,13 @@ from django_filters import MultipleChoiceFilter from django_filters.rest_framework import BooleanFilter, FilterSet -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.common.permissions import is_admin_user from app.group.models import Group class GroupFilter(FilterSet): - type = MultipleChoiceFilter(method="filter_type", choices=GroupType.all()) + type = MultipleChoiceFilter(method="filter_type", choices=GroupType.choices) overview = BooleanFilter(method="filter_overview", label="Oversikt") class Meta: diff --git a/app/group/filters/membership.py b/app/group/filters/membership.py index 02e8986c..b975bb73 100644 --- a/app/group/filters/membership.py +++ b/app/group/filters/membership.py @@ -1,7 +1,7 @@ from django_filters.filters import BooleanFilter from django_filters.rest_framework import FilterSet -from app.common.enums import MembershipType +from app.common.enums import NativeMembershipType as MembershipType from app.group.models import Membership diff --git a/app/group/migrations/0019_alter_group_type.py b/app/group/migrations/0019_alter_group_type.py new file mode 100644 index 00000000..d40425ab --- /dev/null +++ b/app/group/migrations/0019_alter_group_type.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.5 on 2024-09-20 07:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("group", "0018_fine_defense"), + ] + + operations = [ + migrations.AlterField( + model_name="group", + name="type", + field=models.CharField( + choices=[ + ("TIHLDE", "TIHLDE"), + ("BOARD", "Styre"), + ("SUBGROUP", "Undergruppe"), + ("COMMITTEE", "Komité"), + ("STUDYYEAR", "Studieår"), + ("STUDY", "Studie"), + ("INTERESTGROUP", "Interesse Gruppe"), + ("OTHER", "Annet"), + ], + default="OTHER", + max_length=50, + ), + ), + ] diff --git a/app/group/migrations/0020_alter_membership_membership_type_and_more.py b/app/group/migrations/0020_alter_membership_membership_type_and_more.py new file mode 100644 index 00000000..a38f0803 --- /dev/null +++ b/app/group/migrations/0020_alter_membership_membership_type_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.5 on 2024-09-20 08:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("group", "0019_alter_group_type"), + ] + + operations = [ + migrations.AlterField( + model_name="membership", + name="membership_type", + field=models.CharField( + choices=[("LEADER", "Leader"), ("MEMBER", "Member")], + default="MEMBER", + max_length=50, + ), + ), + migrations.AlterField( + model_name="membershiphistory", + name="membership_type", + field=models.CharField( + choices=[("LEADER", "Leader"), ("MEMBER", "Member")], + default="MEMBER", + max_length=50, + ), + ), + ] diff --git a/app/group/models/group.py b/app/group/models/group.py index f3b7d48a..1ea1acbd 100644 --- a/app/group/models/group.py +++ b/app/group/models/group.py @@ -1,9 +1,7 @@ from django.db import models from django.utils.text import slugify -from enumchoicefield import EnumChoiceField - -from app.common.enums import AdminGroup, GroupType +from app.common.enums import AdminGroup, NativeGroupType as GroupType from app.common.permissions import BasePermissionModel, set_user_id from app.communication.enums import UserNotificationSettingType from app.content.models.user import User @@ -19,7 +17,7 @@ class Group(OptionalImage, BaseModel, BasePermissionModel): description = models.TextField(max_length=1000, null=True, blank=True) contact_email = models.EmailField(max_length=200, null=True, blank=True) fine_info = models.TextField(default="", blank=True) - type = EnumChoiceField(GroupType, default=GroupType.OTHER) + type = models.CharField(max_length=50, choices=GroupType.choices, default=GroupType.OTHER) fines_activated = models.BooleanField(default=False) members = models.ManyToManyField( User, diff --git a/app/group/models/membership.py b/app/group/models/membership.py index 54da4ed7..aa3cfcf7 100644 --- a/app/group/models/membership.py +++ b/app/group/models/membership.py @@ -1,9 +1,11 @@ from django.db import models from django.db.transaction import atomic -from enumchoicefield import EnumChoiceField - -from app.common.enums import AdminGroup, GroupType, MembershipType +from app.common.enums import ( + AdminGroup, + NativeGroupType as GroupType, + NativeMembershipType as MembershipType +) from app.common.permissions import BasePermissionModel from app.content.models.user import User from app.group.models.group import Group @@ -22,7 +24,7 @@ class MembershipHistory(BaseModel): group = models.ForeignKey( Group, on_delete=models.CASCADE, related_name="membership_histories" ) - membership_type = EnumChoiceField(MembershipType, default=MembershipType.MEMBER) + membership_type = models.CharField(max_length=50, choices=MembershipType.choices, default=MembershipType.MEMBER) start_date = models.DateTimeField() end_date = models.DateTimeField() @@ -69,7 +71,7 @@ class Membership(BaseModel, BasePermissionModel): group = models.ForeignKey( Group, on_delete=models.CASCADE, related_name="memberships" ) - membership_type = EnumChoiceField(MembershipType, default=MembershipType.MEMBER) + membership_type = models.CharField(max_length=50, choices=MembershipType.choices, default=MembershipType.MEMBER) expiration_date = models.DateField(null=True, blank=True) class Meta: @@ -109,7 +111,7 @@ def is_leader(self): return self.membership_type == MembershipType.LEADER def is_board_member(self): - return self.membership_type in MembershipType.board_members + return self.membership_type in MembershipType.board_members() def clean(self): if ( diff --git a/app/group/serializers/group.py b/app/group/serializers/group.py index fd45dd6c..fcec07ca 100644 --- a/app/group/serializers/group.py +++ b/app/group/serializers/group.py @@ -2,7 +2,10 @@ from dry_rest_permissions.generics import DRYPermissionsField -from app.common.enums import GroupType, MembershipType +from app.common.enums import ( + NativeGroupType as GroupType, + NativeMembershipType as MembershipType +) from app.common.serializers import BaseModelSerializer from app.content.models.user import User from app.content.serializers.user import DefaultUserSerializer diff --git a/app/group/tests/test_membership_model.py b/app/group/tests/test_membership_model.py index 496937f5..167da8e5 100644 --- a/app/group/tests/test_membership_model.py +++ b/app/group/tests/test_membership_model.py @@ -1,6 +1,10 @@ import pytest -from app.common.enums import AdminGroup, GroupType, MembershipType +from app.common.enums import ( + AdminGroup, + NativeGroupType as GroupType, + NativeMembershipType as MembershipType +) from app.group.factories import MembershipFactory from app.group.factories.group_factory import GroupFactory from app.group.models.membership import Membership, MembershipHistory diff --git a/app/group/views/membership.py b/app/group/views/membership.py index 1bf05e50..5b48ea79 100644 --- a/app/group/views/membership.py +++ b/app/group/views/membership.py @@ -4,7 +4,10 @@ from rest_framework import status from rest_framework.response import Response -from app.common.enums import GroupType, MembershipType +from app.common.enums import ( + NativeGroupType as GroupType, + NativeMembershipType as MembershipType +) from app.common.pagination import BasePagination from app.common.permissions import BasicViewPermission, IsLeader, is_admin_user from app.common.viewsets import BaseViewSet diff --git a/app/kontres/models/bookable_item.py b/app/kontres/models/bookable_item.py index b6ed9669..0a293966 100644 --- a/app/kontres/models/bookable_item.py +++ b/app/kontres/models/bookable_item.py @@ -2,7 +2,10 @@ from django.db import models -from app.common.enums import AdminGroup, Groups +from app.common.enums import ( + AdminGroup, + Groups +) from app.common.permissions import BasePermissionModel, check_has_access from app.util.models import BaseModel, OptionalImage diff --git a/app/kontres/models/reservation.py b/app/kontres/models/reservation.py index 22f544ca..dc38f8f2 100644 --- a/app/kontres/models/reservation.py +++ b/app/kontres/models/reservation.py @@ -2,7 +2,11 @@ from django.db import models -from app.common.enums import AdminGroup, Groups, MembershipType +from app.common.enums import ( + AdminGroup, + Groups, + NativeMembershipType as MembershipType +) from app.common.permissions import BasePermissionModel, check_has_access from app.communication.enums import UserNotificationSettingType from app.communication.notifier import Notify diff --git a/app/settings.py b/app/settings.py index d375fd4b..59096c8c 100644 --- a/app/settings.py +++ b/app/settings.py @@ -87,6 +87,7 @@ "dj_rest_auth", "dry_rest_permissions", "polymorphic", + "drf_yasg", # Our apps "app.common", "app.communication", @@ -112,14 +113,21 @@ "EXCEPTION_HANDLER": "app.util.exceptions.exception_handler", "TEST_REQUEST_DEFAULT_FORMAT": "json", } +# SWAGGER_SETTINGS = { +# "SECURITY_DEFINITIONS": { +# "DRF Token": { +# "type": "apiKey", +# "description": "Auth token to be passed as a header as custom authentication. " +# "Can be found in the django admin panel.", +# "name": "X-CSRF-Token", +# "in": "header", +# } +# } +# } SWAGGER_SETTINGS = { - "SECURITY_DEFINITIONS": { - "DRF Token": { - "type": "apiKey", - "description": "Auth token to be passed as a header as custom authentication. " - "Can be found in the django admin panel.", - "name": "X-CSRF-Token", - "in": "header", + 'SECURITY_DEFINITIONS': { + 'Basic': { + 'type': 'basic' } } } diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 14aae4af..11a44fd2 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -5,7 +5,11 @@ from app.badge.factories import BadgeFactory, UserBadgeFactory from app.career.factories import WeeklyBusinessFactory -from app.common.enums import AdminGroup, Groups, MembershipType +from app.common.enums import ( + AdminGroup, + Groups, + NativeMembershipType as MembershipType +) from app.communication.factories import ( BannerFactory, NotificationFactory, diff --git a/app/tests/content/test_cheatsheet_integration.py b/app/tests/content/test_cheatsheet_integration.py index fb10c8e9..7f4140be 100644 --- a/app/tests/content/test_cheatsheet_integration.py +++ b/app/tests/content/test_cheatsheet_integration.py @@ -2,18 +2,24 @@ import pytest -from app.common.enums import AdminGroup, UserClass, UserStudy +from app.common.enums import ( + AdminGroup, + NativeUserStudy as UserStudy, + NativeUserClass as UserClass, + get_user_class_number +) from app.util.test_utils import get_api_client API_CHEATSHEET_BASE_URL = "/cheatsheets/" def get_study(study): - return UserStudy(study).name + return UserStudy(study) def get_grade(grade): - return UserClass(grade).value + user_class = UserClass(grade) + return get_user_class_number(user_class) def _get_cheatsheet_url(cheatsheet): @@ -115,7 +121,7 @@ def test_delete_as_user(user, cheatsheet): @pytest.mark.django_db -def test_delete_as_admin_user(user, cheatsheet, admin_user): +def test_delete_as_admin_user(cheatsheet, admin_user): """A user should be able to to delete an cheatsheet entity.""" client = get_api_client(user=admin_user) diff --git a/app/tests/content/test_event_integration.py b/app/tests/content/test_event_integration.py index a4b4d3f2..f762fb70 100644 --- a/app/tests/content/test_event_integration.py +++ b/app/tests/content/test_event_integration.py @@ -5,10 +5,15 @@ import pytest -from app.common.enums import AdminGroup, Groups, GroupType, MembershipType +from app.common.enums import ( + AdminGroup, + Groups, + NativeGroupType as GroupType, + NativeMembershipType as MembershipType +) from app.content.factories import EventFactory, RegistrationFactory, UserFactory from app.content.models import Category, Event -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.tests.form_factories import EventFormFactory from app.group.factories import GroupFactory from app.group.models import Group diff --git a/app/tests/content/test_news_integration.py b/app/tests/content/test_news_integration.py index 463c3e57..50d11c09 100644 --- a/app/tests/content/test_news_integration.py +++ b/app/tests/content/test_news_integration.py @@ -2,7 +2,12 @@ import pytest -from app.common.enums import AdminGroup, Groups, GroupType, MembershipType +from app.common.enums import ( + AdminGroup, + Groups, + NativeGroupType as GroupType, + NativeMembershipType as MembershipType +) from app.content.factories.news_factory import NewsFactory from app.content.factories.user_factory import UserFactory from app.util.test_utils import add_user_to_group_with_name, get_api_client diff --git a/app/tests/content/test_registration_integration.py b/app/tests/content/test_registration_integration.py index 36fda996..456fa5ba 100644 --- a/app/tests/content/test_registration_integration.py +++ b/app/tests/content/test_registration_integration.py @@ -4,10 +4,14 @@ import pytest -from app.common.enums import AdminGroup, GroupType, MembershipType +from app.common.enums import ( + AdminGroup, + NativeGroupType as GroupType, + NativeMembershipType as MembershipType +) from app.content.factories import EventFactory, RegistrationFactory, UserFactory from app.content.factories.priority_pool_factory import PriorityPoolFactory -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.tests.form_factories import EventFormFactory, SubmissionFactory from app.group.factories import GroupFactory from app.payment.enums import OrderStatus diff --git a/app/tests/content/test_strike_integration.py b/app/tests/content/test_strike_integration.py index bdb66297..65766ce3 100644 --- a/app/tests/content/test_strike_integration.py +++ b/app/tests/content/test_strike_integration.py @@ -4,7 +4,11 @@ import pytest -from app.common.enums import AdminGroup, Groups, StrikeEnum +from app.common.enums import ( + AdminGroup, + Groups, + NativeStrikeEnum as StrikeEnum +) from app.content.factories import StrikeFactory from app.content.factories.event_factory import EventFactory from app.content.factories.user_factory import UserFactory @@ -98,7 +102,7 @@ def test_all_strike_enums_are_valid( admin_user, strike_enum, expected_status_code, strike_post_data ): """If a strike enum is not recognized, a 404 is returned""" - strike_post_data["enum"] = str(strike_enum) + strike_post_data["enum"] = strike_enum client = get_api_client(user=admin_user) response = client.post(API_STRIKE_BASE_URL, strike_post_data) diff --git a/app/tests/content/test_toddel_integration.py b/app/tests/content/test_toddel_integration.py index f630774c..0eef20d6 100644 --- a/app/tests/content/test_toddel_integration.py +++ b/app/tests/content/test_toddel_integration.py @@ -2,7 +2,10 @@ import pytest -from app.common.enums import AdminGroup, Groups +from app.common.enums import ( + AdminGroup, + Groups +) from app.content.factories import ToddelFactory from app.util.test_utils import get_api_client diff --git a/app/tests/content/test_user_integration.py b/app/tests/content/test_user_integration.py index bd36027e..a550ec12 100644 --- a/app/tests/content/test_user_integration.py +++ b/app/tests/content/test_user_integration.py @@ -6,13 +6,16 @@ import pytest -from app.common.enums import AdminGroup, GroupType +from app.common.enums import ( + AdminGroup, + NativeGroupType as 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 from app.content.models import User -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.tests.form_factories import EventFormFactory, SubmissionFactory from app.group.models import Group from app.util.test_utils import add_user_to_group_with_name diff --git a/app/tests/forms/test_eventform_integration.py b/app/tests/forms/test_eventform_integration.py index 701af133..540b6009 100644 --- a/app/tests/forms/test_eventform_integration.py +++ b/app/tests/forms/test_eventform_integration.py @@ -2,10 +2,14 @@ import pytest -from app.common.enums import AdminGroup, GroupType, MembershipType +from app.common.enums import ( + AdminGroup, + NativeGroupType as GroupType, + NativeMembershipType as MembershipType +) from app.content.factories import EventFactory, RegistrationFactory from app.content.serializers import EventListSerializer -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.tests.form_factories import EventFormFactory from app.group.factories import GroupFactory from app.util.test_utils import add_user_to_group_with_name, get_api_client @@ -134,7 +138,7 @@ def test_list_forms_data(admin_user): "resource_type": "EventForm", "title": form.title, "event": EventListSerializer(form.event).data, - "type": form.type.name, + "type": form.type, "viewer_has_answered": False, "fields": [ { @@ -147,7 +151,7 @@ def test_list_forms_data(admin_user): "order": option.order, } ], - "type": field.type.name, + "type": field.type, "required": field.required, "order": field.order, } diff --git a/app/tests/forms/test_form_integration.py b/app/tests/forms/test_form_integration.py index 7b564ade..757d64f4 100644 --- a/app/tests/forms/test_form_integration.py +++ b/app/tests/forms/test_form_integration.py @@ -100,7 +100,7 @@ def test_list_form_templates_data(admin_user): "order": option.order, } ], - "type": field.type.name, + "type": field.type, "required": field.required, "order": field.order, } diff --git a/app/tests/forms/test_group_form_integration.py b/app/tests/forms/test_group_form_integration.py index 6f9dce62..0deb8b17 100644 --- a/app/tests/forms/test_group_form_integration.py +++ b/app/tests/forms/test_group_form_integration.py @@ -2,7 +2,11 @@ import pytest -from app.common.enums import AdminGroup, GroupType, MembershipType +from app.common.enums import ( + AdminGroup, + NativeGroupType as GroupType, + NativeMembershipType as MembershipType +) from app.forms.tests.form_factories import GroupFormFactory from app.group.factories import GroupFactory, MembershipFactory from app.group.models import Group diff --git a/app/tests/forms/test_submission_integration.py b/app/tests/forms/test_submission_integration.py index 573820b5..4a4eeb05 100644 --- a/app/tests/forms/test_submission_integration.py +++ b/app/tests/forms/test_submission_integration.py @@ -2,9 +2,9 @@ import pytest -from app.common.enums import MembershipType +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories import RegistrationFactory -from app.forms.enums import EventFormType +from app.forms.enums import NativeEventFormType as EventFormType from app.forms.tests.form_factories import ( AnswerFactory, EventFormFactory, diff --git a/app/tests/groups/test_fine_integration.py b/app/tests/groups/test_fine_integration.py index b1448384..83ab2c53 100644 --- a/app/tests/groups/test_fine_integration.py +++ b/app/tests/groups/test_fine_integration.py @@ -2,7 +2,10 @@ import pytest -from app.common.enums import AdminGroup, MembershipType +from app.common.enums import ( + AdminGroup, + NativeMembershipType as MembershipType +) from app.content.factories.user_factory import UserFactory from app.group.factories.fine_factory import FineFactory from app.group.factories.group_factory import GroupFactory diff --git a/app/tests/groups/test_law_integration.py b/app/tests/groups/test_law_integration.py index 1bb1dcfe..4cdb54aa 100644 --- a/app/tests/groups/test_law_integration.py +++ b/app/tests/groups/test_law_integration.py @@ -4,7 +4,10 @@ import pytest -from app.common.enums import AdminGroup, MembershipType +from app.common.enums import ( + AdminGroup, + NativeMembershipType as MembershipType +) from app.group.factories.group_factory import GroupFactory from app.group.factories.law_factory import LawFactory from app.group.factories.membership_factory import MembershipFactory diff --git a/app/tests/groups/test_membership_history_integration.py b/app/tests/groups/test_membership_history_integration.py index 03368287..9838058b 100644 --- a/app/tests/groups/test_membership_history_integration.py +++ b/app/tests/groups/test_membership_history_integration.py @@ -2,7 +2,10 @@ import pytest -from app.common.enums import AdminGroup, MembershipType +from app.common.enums import ( + AdminGroup, + NativeMembershipType as MembershipType +) from app.group.factories.membership_factory import MembershipHistoryFactory from app.util.test_utils import get_api_client diff --git a/app/tests/groups/test_membership_integration.py b/app/tests/groups/test_membership_integration.py index 4902dbcf..54e75e99 100644 --- a/app/tests/groups/test_membership_integration.py +++ b/app/tests/groups/test_membership_integration.py @@ -2,7 +2,10 @@ import pytest -from app.common.enums import AdminGroup, MembershipType +from app.common.enums import ( + AdminGroup, + NativeMembershipType as MembershipType +) from app.group.factories.membership_factory import MembershipFactory from app.util.test_utils import get_api_client diff --git a/app/tests/payment/test_paid_event_integration.py b/app/tests/payment/test_paid_event_integration.py index a0aaac87..23ccb00a 100644 --- a/app/tests/payment/test_paid_event_integration.py +++ b/app/tests/payment/test_paid_event_integration.py @@ -4,7 +4,7 @@ import pytest -from app.common.enums import GroupType +from app.common.enums import NativeGroupType as GroupType from app.content.models.event import Event from app.group.models.group import Group from app.payment.factories.paid_event_factory import PaidEventFactory diff --git a/app/urls.py b/app/urls.py index 3e8029cd..d9d8be0e 100644 --- a/app/urls.py +++ b/app/urls.py @@ -16,10 +16,26 @@ from django.contrib import admin from django.urls import include, path +from rest_framework import permissions +from drf_yasg.views import get_schema_view +from drf_yasg import openapi + +schema_view = get_schema_view( + openapi.Info( + title="Lepton API", + default_version='v1', + description="API for our Django backend", + contact=openapi.Contact(email="teknologiminister@tihlde.org"), + ), + public=True, + permission_classes=(permissions.AllowAny,), +) urlpatterns = [ path("admin/", admin.site.urls), path("", include("rest_framework.urls")), + path("swagger/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), + path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), # Our endpoints path("", include("app.career.urls")), path("", include("app.communication.urls")), diff --git a/app/util/test_utils.py b/app/util/test_utils.py index 1f6b3257..8e7d5d9f 100644 --- a/app/util/test_utils.py +++ b/app/util/test_utils.py @@ -2,7 +2,11 @@ from rest_framework.authtoken.models import Token from rest_framework.test import APIClient -from app.common.enums import AdminGroup, GroupType, MembershipType +from app.common.enums import ( + AdminGroup, + NativeGroupType as GroupType, + NativeMembershipType as MembershipType +) from app.group.models import Group, Membership diff --git a/requirements.txt b/requirements.txt index b3975ffb..d58a8eae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,7 @@ django-ordered-model~=3.6 djangorestframework==3.14.0 django-cors-headers dj-rest-auth == 2.2.3 +drf-yasg == 1.21.7 #django dry rest permissions django-dry-rest-permissions == 1.2.0 From 496d536081da1e5a7ecbcc13a95dfdd2f330b84a Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Sat, 21 Sep 2024 12:02:02 +0200 Subject: [PATCH 37/59] Swagger GitHub Action (#860) * added github action for checking if Swagger is up * new action * try another * tried implementing check for container * added curl to docker image * added check if swagger is up * test if swagger does not get status code 200 * added ?format=openai to trigger error * checking that the request is working * updated CHANGELOG.md --- .github/workflows/ci.yaml | 35 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 3 +++ compose/Dockerfile | 2 ++ 3 files changed, 40 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f6babc08..4f3389b0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -74,5 +74,40 @@ jobs: - name: Check for unstaged migrations run: docker compose run --rm web python manage.py makemigrations --check --no-input + - name: Tear down the Stack + run: docker compose down + + swagger: + runs-on: ubuntu-latest + steps: + + - name: Checkout Code Repository + uses: actions/checkout@v4 + + - name: Build the Stack + run: docker compose build + + - name: Run the Stack + run: docker compose up -d + + - name: Wait for Docker container to be up + run: | + retries=10 + until docker compose exec web curl -f http://localhost:8000/ || [ $retries -eq 0 ]; do + echo "Waiting for container to be up..." + retries=$((retries - 1)) + sleep 5 + done + + - name: Make HTTP Request to Swagger + run: | + status_code=$(curl -o /dev/null -s -w "%{http_code}" http://localhost:8000/swagger/?format=openapi) + if [ "$status_code" -eq 200 ]; then + echo "Swagger UI is up" + else + echo "Failed to reach Swagger UI, status code: $status_code" + exit 1 + fi + - name: Tear down the Stack run: docker compose down \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 591e7d99..5ee78df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ + # CHANGELOG ## Tegnforklaring @@ -14,6 +15,8 @@ ## Neste versjon +- ⚡**Swagger**. La til en GitHub Action for å verifisere at Swagger er oppe og går. +- ✨**Swagger**. API dokumentasjon er nå tilgjengelig med Swagger. - ⚡**Profil**. Endret rekkefølge på tidligere arrangementer slik at nyeste kommer først. ## Versjon 2024.09.14 diff --git a/compose/Dockerfile b/compose/Dockerfile index 3e7579d9..f9e73832 100644 --- a/compose/Dockerfile +++ b/compose/Dockerfile @@ -12,6 +12,8 @@ RUN apt-get update \ && apt-get install -y build-essential \ # mysqlclient dependencies && apt-get install -y default-libmysqlclient-dev \ + # curl for making HTTP requests + && apt-get install -y curl \ # cleaning up unused files && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ && rm -rf /var/lib/apt/lists/* From 0c1de713db87ae64fd9cbf137d4ad4d5a5a5db0a Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:12:21 +0200 Subject: [PATCH 38/59] Add new app (#862) * added script for adding new app to Lepton * added command to Makefile --- Makefile | 6 ++++- scripts/app.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 scripts/app.py diff --git a/Makefile b/Makefile index 7807594d..c034d0dc 100644 --- a/Makefile +++ b/Makefile @@ -96,4 +96,8 @@ pr: ## Pull Request format and checks .PHONY: shell shell: ## Open an interactive Django shell - docker compose run --rm web python manage.py shell \ No newline at end of file + docker compose run --rm web python manage.py shell + +.PHONY: app +app: ## Create a new Django app + python scripts/app.py \ No newline at end of file diff --git a/scripts/app.py b/scripts/app.py new file mode 100644 index 00000000..a9168c9a --- /dev/null +++ b/scripts/app.py @@ -0,0 +1,72 @@ +import os + + +def create_app(): + """ + Create a Django app directory with all the necessary directories and files. + """ + try: + app_name = input("Enter the app name: ").strip().lower() + + BASE_PATH = "app" + + if app_name in os.listdir(BASE_PATH): + print(f"App '{app_name}' already exists.") + return + + app_path = os.path.join(BASE_PATH, app_name) + + # Create the app directory + os.makedirs(app_path, exist_ok=True) + + # Create the app's directories + init_dir(app_path, "admin") + init_dir(app_path, "factories") + init_dir(app_path, "filters") + init_dir(app_path, "migrations") + init_dir(app_path, "models") + init_dir(app_path, "tasks") + init_dir(app_path, "tests") + init_dir(app_path, "util") + init_dir(app_path, "views") + + # Create the app's files + init_app_file(app_path, "__init__.py") + + config_content = f"""from django.apps import AppConfig + + +class {app_name.capitalize()}Config(AppConfig): + name = "app.{app_name}" + """ + + init_app_file(app_path, "app.py", content=config_content) + init_app_file(app_path, "enums.py") + init_app_file(app_path, "exceptions.py") + init_app_file(app_path, "mixins.py") + init_app_file(app_path, "urls.py") + + print(f"App '{app_name}' created successfully.") + print("Don't forget to add the app to the INSTALLED_APPS in the settings.py file.") + except Exception as e: + print(f"\nAn error occurred: {e}") + return + + +def init_dir(app_path: str, dir_name: str): + """Create a directory in the app directory, with a __init__.py file.""" + dir_path = os.path.join(app_path, dir_name) + os.makedirs(dir_path, exist_ok=True) + with open(os.path.join(dir_path, "__init__.py"), "w") as f: + f.write("") + + +def init_app_file(app_path: str, file_name: str, content: str = ""): + """Create a file in the app directory.""" + file_path = os.path.join(app_path, file_name) + with open(file_path, "w") as f: + f.write(content) + + +if __name__ == "__main__": + create_app() From 9a191637e52e5af14ade427a83dc28ffdece7007 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll <82368148+Tmpecho@users.noreply.github.com> Date: Mon, 23 Sep 2024 20:48:19 +0200 Subject: [PATCH 39/59] Upgrade all dependencies to latest (#857) * Add endpoint to create new group as admin Signed-off-by: Tmpecho * Upgrade all dependencies to latest Signed-off-by: Tmpecho * remove bad exception handling in serializers/group.py * fix inheritance ordering in views/group.py * refactored group integration test, added non_public_groups to enums * fix linting * reformat files * remove unused import in groups/views/group.py * Upgrade dependency "black" Signed-off-by: Tmpecho * Upgrade dependency "sentry-sdk" Signed-off-by: Tmpecho * Upgrade dependency "azure-storage-blob" and remove outdated comment in requirements.txt Signed-off-by: Tmpecho * Upgrade all non-django dependencies Signed-off-by: Tmpecho * Upgrade dependency "Django" Signed-off-by: Tmpecho * Upgrade dependencies and remove ignored version from docker-compose.yml Signed-off-by: Tmpecho --------- Signed-off-by: Tmpecho Co-authored-by: 1Cezzo --- app/common/enums.py | 21 ++++++- app/content/factories/cheatsheet_factory.py | 8 +-- app/content/filters/user.py | 6 +- app/content/models/cheatsheet.py | 23 ++++--- app/content/models/news.py | 5 +- app/content/models/toddel.py | 5 +- app/content/models/user.py | 11 ++-- app/content/serializers/event.py | 4 +- app/content/serializers/user.py | 6 +- app/content/views/user.py | 6 +- app/forms/enums.py | 3 +- app/forms/models/forms.py | 19 +++--- app/group/exceptions.py | 9 +++ app/group/filters/group.py | 4 +- app/group/mixins.py | 16 ++++- app/group/models/group.py | 19 ++++-- app/group/models/membership.py | 16 ++--- app/group/serializers/group.py | 33 +++++++--- app/group/tests/test_membership_model.py | 8 +-- app/group/views/group.py | 43 +++++++------ app/group/views/membership.py | 6 +- app/kontres/models/bookable_item.py | 5 +- app/kontres/models/reservation.py | 7 +-- app/settings.py | 8 +-- app/tests/conftest.py | 7 +-- .../content/test_cheatsheet_integration.py | 10 ++- app/tests/content/test_event_integration.py | 9 +-- app/tests/content/test_news_integration.py | 9 +-- .../content/test_registration_integration.py | 8 +-- app/tests/content/test_strike_integration.py | 7 +-- app/tests/content/test_toddel_integration.py | 5 +- app/tests/content/test_user_integration.py | 6 +- app/tests/forms/test_eventform_integration.py | 8 +-- .../forms/test_group_form_integration.py | 8 +-- app/tests/groups/test_fine_integration.py | 6 +- app/tests/groups/test_group_integration.py | 61 +++++++++++++------ app/tests/groups/test_law_integration.py | 6 +- .../test_membership_history_integration.py | 6 +- .../groups/test_membership_integration.py | 6 +- app/urls.py | 11 +++- app/util/test_utils.py | 8 +-- docker-compose.yml | 2 - requirements.txt | 54 ++++++++-------- 43 files changed, 279 insertions(+), 249 deletions(-) diff --git a/app/common/enums.py b/app/common/enums.py index 78d4d384..10d63dcf 100644 --- a/app/common/enums.py +++ b/app/common/enums.py @@ -77,7 +77,14 @@ class Groups(models.TextChoices): @classmethod def all(cls): - return (cls.TIHLDE, cls.JUBKOM, cls.REDAKSJONEN, cls.FONDET, cls.PLASK, cls.DRIFT) + return ( + cls.TIHLDE, + cls.JUBKOM, + cls.REDAKSJONEN, + cls.FONDET, + cls.PLASK, + cls.DRIFT, + ) # This can't be removed because it is used in the migrations. It is not used in the code. @@ -110,6 +117,10 @@ class NativeGroupType(models.TextChoices): def public_groups(cls): return [cls.BOARD, cls.SUBGROUP, cls.COMMITTEE, cls.INTERESTGROUP] + @classmethod + def non_public_groups(cls): + return [cls.TIHLDE, cls.STUDYYEAR, cls.STUDY, cls.OTHER] + class EnvironmentOptions(Enum): LOCAL = "LOCAL" @@ -173,7 +184,13 @@ class NativeStrikeEnum(models.TextChoices): @classmethod def all(cls): - return [cls.PAST_DEADLINE, cls.NO_SHOW, cls.LATE, cls.BAD_BEHAVIOR, cls.EVAL_FORM] + return [ + cls.PAST_DEADLINE, + cls.NO_SHOW, + cls.LATE, + cls.BAD_BEHAVIOR, + cls.EVAL_FORM, + ] class CodexGroups(models.TextChoices): diff --git a/app/content/factories/cheatsheet_factory.py b/app/content/factories/cheatsheet_factory.py index 829536b1..49dc9366 100644 --- a/app/content/factories/cheatsheet_factory.py +++ b/app/content/factories/cheatsheet_factory.py @@ -3,11 +3,9 @@ import factory from factory.django import DjangoModelFactory -from app.common.enums import ( - NativeCheatsheetType as CheatsheetType, - NativeUserClass as UserClass, - NativeUserStudy as UserStudy -) +from app.common.enums import NativeCheatsheetType as CheatsheetType +from app.common.enums import NativeUserClass as UserClass +from app.common.enums import NativeUserStudy as UserStudy from app.content.models import Cheatsheet diff --git a/app/content/filters/user.py b/app/content/filters/user.py index 8f3563f9..a189f239 100644 --- a/app/content/filters/user.py +++ b/app/content/filters/user.py @@ -1,9 +1,7 @@ from django_filters.rest_framework import BooleanFilter, CharFilter, FilterSet -from app.common.enums import ( - Groups, - NativeGroupType as GroupType -) +from app.common.enums import Groups +from app.common.enums import NativeGroupType as GroupType from app.content.models import User from app.content.models.strike import Strike diff --git a/app/content/models/cheatsheet.py b/app/content/models/cheatsheet.py index 089de017..2b8d20a1 100644 --- a/app/content/models/cheatsheet.py +++ b/app/content/models/cheatsheet.py @@ -2,13 +2,10 @@ from django.db import models -from app.common.enums import ( - AdminGroup, - NativeCheatsheetType as CheatsheetType, - Groups, - NativeUserClass as UserClass, - NativeUserStudy as UserStudy, -) +from app.common.enums import AdminGroup, Groups +from app.common.enums import NativeCheatsheetType as CheatsheetType +from app.common.enums import NativeUserClass as UserClass +from app.common.enums import NativeUserStudy as UserStudy from app.common.permissions import BasePermissionModel from app.util.models import BaseModel @@ -19,10 +16,16 @@ class Cheatsheet(BaseModel, BasePermissionModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) title = models.CharField(max_length=200) creator = models.CharField(max_length=200) - grade = models.CharField(max_length=50, choices=UserClass.choices, default=UserClass.FIRST) - study = models.CharField(max_length=50, choices=UserStudy.choices, default=UserStudy.DATAING) + grade = models.CharField( + max_length=50, choices=UserClass.choices, default=UserClass.FIRST + ) + study = models.CharField( + max_length=50, choices=UserStudy.choices, default=UserStudy.DATAING + ) course = models.CharField(max_length=200) - type = models.CharField(max_length=50, choices=CheatsheetType.choices, default=CheatsheetType.LINK) + type = models.CharField( + max_length=50, choices=CheatsheetType.choices, default=CheatsheetType.LINK + ) official = models.BooleanField(default=False) url = models.URLField(max_length=600) diff --git a/app/content/models/news.py b/app/content/models/news.py index 46d864ec..886105bb 100644 --- a/app/content/models/news.py +++ b/app/content/models/news.py @@ -2,10 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models -from app.common.enums import ( - AdminGroup, - Groups -) +from app.common.enums import AdminGroup, Groups from app.common.permissions import BasePermissionModel, check_has_access from app.emoji.models.reaction import Reaction from app.util.models import BaseModel, OptionalImage diff --git a/app/content/models/toddel.py b/app/content/models/toddel.py index da686eb8..f4252180 100644 --- a/app/content/models/toddel.py +++ b/app/content/models/toddel.py @@ -1,9 +1,6 @@ from django.db import models -from app.common.enums import ( - AdminGroup, - Groups -) +from app.common.enums import AdminGroup, Groups from app.common.permissions import BasePermissionModel from app.util.models import BaseModel from app.util.utils import datetime_format diff --git a/app/content/models/user.py b/app/content/models/user.py index 7eb0d89e..1c4ce58b 100644 --- a/app/content/models/user.py +++ b/app/content/models/user.py @@ -13,12 +13,9 @@ from django.dispatch import receiver from rest_framework.authtoken.models import Token -from app.common.enums import ( - AdminGroup, - Groups, - NativeGroupType as GroupType, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup, Groups +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.common.permissions import check_has_access from app.util.models import BaseModel, OptionalImage from app.util.utils import disable_for_loaddata, now @@ -176,8 +173,8 @@ def has_unanswered_evaluations_for(self, event): return self.get_unanswered_evaluations().filter(event=event).exists() def get_unanswered_evaluations(self): - from app.forms.models.forms import EventForm from app.forms.enums import NativeEventFormType as EventFormType + from app.forms.models.forms import EventForm date_30_days_ago = now() - timedelta(days=30) registrations = self.registrations.filter(has_attended=True) diff --git a/app/content/serializers/event.py b/app/content/serializers/event.py index 73be7ad5..f1042051 100644 --- a/app/content/serializers/event.py +++ b/app/content/serializers/event.py @@ -25,7 +25,9 @@ class EventSerializer(serializers.ModelSerializer): evaluation = serializers.PrimaryKeyRelatedField(many=False, read_only=True) survey = serializers.PrimaryKeyRelatedField(many=False, read_only=True) organizer = SimpleGroupSerializer(read_only=True) - permissions = DRYPermissionsField(actions=["write", "read"], object_only=True, read_only=True) + permissions = DRYPermissionsField( + actions=["write", "read"], object_only=True, read_only=True + ) paid_information = serializers.SerializerMethodField( required=False, allow_null=True ) diff --git a/app/content/serializers/user.py b/app/content/serializers/user.py index 132d2216..2378c562 100644 --- a/app/content/serializers/user.py +++ b/app/content/serializers/user.py @@ -4,10 +4,8 @@ from dry_rest_permissions.generics import DRYGlobalPermissionsField -from app.common.enums import ( - Groups, - NativeGroupType as GroupType -) +from app.common.enums import Groups +from app.common.enums import NativeGroupType as GroupType from app.common.serializers import BaseModelSerializer from app.communication.enums import UserNotificationSettingType from app.communication.notifier import Notify diff --git a/app/content/views/user.py b/app/content/views/user.py index acda9f93..bb3e67c4 100644 --- a/app/content/views/user.py +++ b/app/content/views/user.py @@ -8,10 +8,8 @@ from app.badge.models import Badge, UserBadge from app.badge.serializers import BadgeSerializer, UserBadgeSerializer -from app.common.enums import ( - Groups, - NativeGroupType as GroupType -) +from app.common.enums import Groups +from app.common.enums import NativeGroupType as GroupType from app.common.mixins import ActionMixin from app.common.pagination import BasePagination from app.common.permissions import ( diff --git a/app/forms/enums.py b/app/forms/enums.py index 3b9bf324..dd30ba48 100644 --- a/app/forms/enums.py +++ b/app/forms/enums.py @@ -1,6 +1,7 @@ -from enumchoicefield import ChoiceEnum from django.db import models +from enumchoicefield import ChoiceEnum + # This must be here because of the migrations files class EventFormType(ChoiceEnum): diff --git a/app/forms/models/forms.py b/app/forms/models/forms.py index 48671059..08e2ff97 100644 --- a/app/forms/models/forms.py +++ b/app/forms/models/forms.py @@ -5,17 +5,12 @@ from ordered_model.models import OrderedModel from polymorphic.models import PolymorphicModel -from app.common.enums import ( - AdminGroup, - Groups -) +from app.common.enums import AdminGroup, Groups from app.common.permissions import BasePermissionModel, check_has_access from app.content.models.event import Event from app.content.models.user import User -from app.forms.enums import ( - NativeFormFieldType as FormFieldType, - NativeEventFormType as EventFormType -) +from app.forms.enums import NativeEventFormType as EventFormType +from app.forms.enums import NativeFormFieldType as FormFieldType from app.forms.exceptions import ( DuplicateSubmission, FormNotOpenForSubmission, @@ -118,7 +113,9 @@ def has_object_read_permission(self, request): class EventForm(Form): event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="forms") - type = models.CharField(max_length=40, choices=EventFormType.choices, default=EventFormType.SURVEY) + type = models.CharField( + max_length=40, choices=EventFormType.choices, default=EventFormType.SURVEY + ) class Meta: unique_together = ("event", "type") @@ -218,7 +215,9 @@ class Field(OrderedModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) title = models.CharField(max_length=400) form = models.ForeignKey(Form, on_delete=models.CASCADE, related_name="fields") - type = models.CharField(max_length=40, choices=FormFieldType.choices, default=FormFieldType.TEXT_ANSWER) + type = models.CharField( + max_length=40, choices=FormFieldType.choices, default=FormFieldType.TEXT_ANSWER + ) required = models.BooleanField(default=False) order_with_respect_to = "form" diff --git a/app/group/exceptions.py b/app/group/exceptions.py index db1ee0c3..c0ca1867 100644 --- a/app/group/exceptions.py +++ b/app/group/exceptions.py @@ -9,3 +9,12 @@ class APIUserIsNotInGroupException(APIException): class UserIsNotInGroup(ValidationError): pass + + +class APIGroupTypeNotInPublicGroupsException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "Ikke gylde gruppetype" + + +class GroupTypeNotInPublicGroups(ValueError): + pass diff --git a/app/group/filters/group.py b/app/group/filters/group.py index a53cb752..fd3a7904 100644 --- a/app/group/filters/group.py +++ b/app/group/filters/group.py @@ -14,12 +14,12 @@ class Meta: model: Group fields = ["type", "overview"] - def filter_type(self, queryset, name, value): + def filter_type(self, queryset, _, value): """Django Rest does not know hot to convert incoming string values into EnumChoiceField values and we must do this manually.""" mapped = list(GroupType[v] for v in value) return queryset.filter(type__in=mapped) - def filter_overview(self, queryset, name, value): + def filter_overview(self, queryset, _): if is_admin_user(self.request): return queryset return queryset.filter(type__in=GroupType.public_groups()) diff --git a/app/group/mixins.py b/app/group/mixins.py index d376e934..4640412e 100644 --- a/app/group/mixins.py +++ b/app/group/mixins.py @@ -1,4 +1,9 @@ -from app.group.exceptions import APIUserIsNotInGroupException, UserIsNotInGroup +from app.group.exceptions import ( + APIGroupTypeNotInPublicGroupsException, + APIUserIsNotInGroupException, + GroupTypeNotInPublicGroups, + UserIsNotInGroup, +) from app.util.mixins import APIErrorsMixin @@ -9,3 +14,12 @@ def expected_exceptions(self): **super().expected_exceptions, UserIsNotInGroup: APIUserIsNotInGroupException, } + + +class APIGroupErrorsMixin(APIErrorsMixin): + @property + def expected_exceptions(self): + return { + **super().expected_exceptions, + GroupTypeNotInPublicGroups: APIGroupTypeNotInPublicGroupsException, + } diff --git a/app/group/models/group.py b/app/group/models/group.py index 1ea1acbd..6d4bbded 100644 --- a/app/group/models/group.py +++ b/app/group/models/group.py @@ -1,15 +1,20 @@ from django.db import models from django.utils.text import slugify -from app.common.enums import AdminGroup, NativeGroupType as GroupType -from app.common.permissions import BasePermissionModel, set_user_id +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.permissions import ( + BasePermissionModel, + check_has_access, + set_user_id, +) from app.communication.enums import UserNotificationSettingType from app.content.models.user import User from app.util.models import BaseModel, OptionalImage class Group(OptionalImage, BaseModel, BasePermissionModel): - + read_access = [] write_access = AdminGroup.admin() name = models.CharField(max_length=50) @@ -17,7 +22,9 @@ class Group(OptionalImage, BaseModel, BasePermissionModel): description = models.TextField(max_length=1000, null=True, blank=True) contact_email = models.EmailField(max_length=200, null=True, blank=True) fine_info = models.TextField(default="", blank=True) - type = models.CharField(max_length=50, choices=GroupType.choices, default=GroupType.OTHER) + type = models.CharField( + max_length=50, choices=GroupType.choices, default=GroupType.OTHER + ) fines_activated = models.BooleanField(default=False) members = models.ManyToManyField( User, @@ -131,6 +138,10 @@ def has_write_permission(cls, request): except (Membership.DoesNotExist, KeyError, AssertionError): return super().has_write_permission(request) + @classmethod + def has_create_permission(cls, request): + return check_has_access(cls.write_access, request) + def has_object_write_permission(self, request): from app.group.models import Membership diff --git a/app/group/models/membership.py b/app/group/models/membership.py index aa3cfcf7..657bf837 100644 --- a/app/group/models/membership.py +++ b/app/group/models/membership.py @@ -1,11 +1,9 @@ from django.db import models from django.db.transaction import atomic -from app.common.enums import ( - AdminGroup, - NativeGroupType as GroupType, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.common.permissions import BasePermissionModel from app.content.models.user import User from app.group.models.group import Group @@ -24,7 +22,9 @@ class MembershipHistory(BaseModel): group = models.ForeignKey( Group, on_delete=models.CASCADE, related_name="membership_histories" ) - membership_type = models.CharField(max_length=50, choices=MembershipType.choices, default=MembershipType.MEMBER) + membership_type = models.CharField( + max_length=50, choices=MembershipType.choices, default=MembershipType.MEMBER + ) start_date = models.DateTimeField() end_date = models.DateTimeField() @@ -71,7 +71,9 @@ class Membership(BaseModel, BasePermissionModel): group = models.ForeignKey( Group, on_delete=models.CASCADE, related_name="memberships" ) - membership_type = models.CharField(max_length=50, choices=MembershipType.choices, default=MembershipType.MEMBER) + membership_type = models.CharField( + max_length=50, choices=MembershipType.choices, default=MembershipType.MEMBER + ) expiration_date = models.DateField(null=True, blank=True) class Meta: diff --git a/app/group/serializers/group.py b/app/group/serializers/group.py index fcec07ca..b3ac0214 100644 --- a/app/group/serializers/group.py +++ b/app/group/serializers/group.py @@ -2,13 +2,12 @@ from dry_rest_permissions.generics import DRYPermissionsField -from app.common.enums import ( - NativeGroupType as GroupType, - NativeMembershipType as MembershipType -) +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.common.serializers import BaseModelSerializer from app.content.models.user import User from app.content.serializers.user import DefaultUserSerializer +from app.group.exceptions import GroupTypeNotInPublicGroups from app.group.models import Group, Membership @@ -36,7 +35,6 @@ def get_viewer_is_member(self, obj): class GroupListSerializer(SimpleGroupSerializer): - leader = serializers.SerializerMethodField() class Meta: @@ -59,7 +57,6 @@ def get_leader(self, obj): class GroupSerializer(GroupListSerializer): - permissions = DRYPermissionsField( actions=["write", "read", "group_form"], object_only=True ) @@ -89,10 +86,6 @@ def update(self, instance, validated_data): instance.fines_admin = self.get_fine_admin_user() return super().update(instance, validated_data) - def create(self, validated_data): - fines_admin = self.get_fine_admin_user() - return Group.objects.create(fines_admin=fines_admin, **validated_data) - class GroupStatisticsSerializer(BaseModelSerializer): studyyears = serializers.SerializerMethodField() @@ -129,3 +122,23 @@ def get_studies(self, obj, *args, **kwargs): Group.objects.filter(type=GroupType.STUDY), ), ) + + +class GroupCreateSerializer( + BaseModelSerializer, +): + class Meta: + model = Group + fields = ( + "name", + "slug", + "type", + ) + + def create(self, validated_data): + group_type = validated_data["type"] + + if group_type not in GroupType.public_groups(): + raise GroupTypeNotInPublicGroups() + + return super().create(validated_data) diff --git a/app/group/tests/test_membership_model.py b/app/group/tests/test_membership_model.py index 167da8e5..26976598 100644 --- a/app/group/tests/test_membership_model.py +++ b/app/group/tests/test_membership_model.py @@ -1,10 +1,8 @@ import pytest -from app.common.enums import ( - AdminGroup, - NativeGroupType as GroupType, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.group.factories import MembershipFactory from app.group.factories.group_factory import GroupFactory from app.group.models.membership import Membership, MembershipHistory diff --git a/app/group/views/group.py b/app/group/views/group.py index 5341495c..80e24f39 100644 --- a/app/group/views/group.py +++ b/app/group/views/group.py @@ -2,17 +2,23 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.status import HTTP_201_CREATED from app.common.mixins import ActionMixin from app.common.permissions import BasicViewPermission from app.common.viewsets import BaseViewSet from app.group.filters.group import GroupFilter +from app.group.mixins import APIGroupErrorsMixin from app.group.models import Group from app.group.serializers import GroupSerializer, GroupStatisticsSerializer -from app.group.serializers.group import GroupListSerializer +from app.group.serializers.group import ( + GroupCreateSerializer, + GroupListSerializer, + SimpleGroupSerializer, +) -class GroupViewSet(BaseViewSet, ActionMixin): +class GroupViewSet(APIGroupErrorsMixin, BaseViewSet, ActionMixin): serializer_class = GroupSerializer permission_classes = [BasicViewPermission] filter_backends = [DjangoFilterBackend] @@ -35,7 +41,7 @@ def retrieve(self, request, slug): return Response(data=serializer.data, status=status.HTTP_200_OK) except Group.DoesNotExist: return Response( - {"detail": ("Gruppen eksisterer ikke")}, + {"detail": "Gruppen eksisterer ikke"}, status=status.HTTP_404_NOT_FOUND, ) @@ -55,30 +61,23 @@ def update(self, request, *args, **kwargs): ) except Group.DoesNotExist: return Response( - {"detail": ("Gruppen eksisterer ikke")}, + {"detail": "Gruppen eksisterer ikke"}, status=status.HTTP_404_NOT_FOUND, ) def create(self, request, *args, **kwargs): """Creates a group if it does not exist""" - try: - slug = request.data["slug"] - group = Group.objects.get_or_create(slug=slug) - serializer = GroupSerializer( - group[0], data=request.data, context={"request": request} - ) - if serializer.is_valid(): - super().perform_create(serializer) - return Response(data=serializer.data, status=status.HTTP_200_OK) - else: - return Response( - {"detail": serializer.errors}, status=status.HTTP_400_BAD_REQUEST - ) - except Group.DoesNotExist: - return Response( - {"detail": ("Gruppen eksisterer ikke")}, - status=status.HTTP_404_NOT_FOUND, - ) + serializer = GroupCreateSerializer( + data=request.data, context={"request": request} + ) + if serializer.is_valid(): + group = super().perform_create(serializer) + return_serializer = SimpleGroupSerializer(group) + return Response(data=return_serializer.data, status=HTTP_201_CREATED) + return Response( + {"detail": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) @action(detail=True, methods=["get"], url_path="statistics") def statistics(self, request, *args, **kwargs): diff --git a/app/group/views/membership.py b/app/group/views/membership.py index 5b48ea79..8b7ff041 100644 --- a/app/group/views/membership.py +++ b/app/group/views/membership.py @@ -4,10 +4,8 @@ from rest_framework import status from rest_framework.response import Response -from app.common.enums import ( - NativeGroupType as GroupType, - NativeMembershipType as MembershipType -) +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.common.pagination import BasePagination from app.common.permissions import BasicViewPermission, IsLeader, is_admin_user from app.common.viewsets import BaseViewSet diff --git a/app/kontres/models/bookable_item.py b/app/kontres/models/bookable_item.py index 0a293966..b6ed9669 100644 --- a/app/kontres/models/bookable_item.py +++ b/app/kontres/models/bookable_item.py @@ -2,10 +2,7 @@ from django.db import models -from app.common.enums import ( - AdminGroup, - Groups -) +from app.common.enums import AdminGroup, Groups from app.common.permissions import BasePermissionModel, check_has_access from app.util.models import BaseModel, OptionalImage diff --git a/app/kontres/models/reservation.py b/app/kontres/models/reservation.py index dc38f8f2..87f91ea6 100644 --- a/app/kontres/models/reservation.py +++ b/app/kontres/models/reservation.py @@ -2,11 +2,8 @@ from django.db import models -from app.common.enums import ( - AdminGroup, - Groups, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup, Groups +from app.common.enums import NativeMembershipType as MembershipType from app.common.permissions import BasePermissionModel, check_has_access from app.communication.enums import UserNotificationSettingType from app.communication.notifier import Notify diff --git a/app/settings.py b/app/settings.py index 59096c8c..00af9754 100644 --- a/app/settings.py +++ b/app/settings.py @@ -124,13 +124,7 @@ # } # } # } -SWAGGER_SETTINGS = { - 'SECURITY_DEFINITIONS': { - 'Basic': { - 'type': 'basic' - } - } -} +SWAGGER_SETTINGS = {"SECURITY_DEFINITIONS": {"Basic": {"type": "basic"}}} # Django rest auth framework REST_AUTH_SERIALIZERS = { "PASSWORD_RESET_SERIALIZER": "app.authentication.serializers.reset_password.PasswordResetSerializer", diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 11a44fd2..e948c716 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -5,11 +5,8 @@ from app.badge.factories import BadgeFactory, UserBadgeFactory from app.career.factories import WeeklyBusinessFactory -from app.common.enums import ( - AdminGroup, - Groups, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup, Groups +from app.common.enums import NativeMembershipType as MembershipType from app.communication.factories import ( BannerFactory, NotificationFactory, diff --git a/app/tests/content/test_cheatsheet_integration.py b/app/tests/content/test_cheatsheet_integration.py index 7f4140be..a148e10d 100644 --- a/app/tests/content/test_cheatsheet_integration.py +++ b/app/tests/content/test_cheatsheet_integration.py @@ -2,12 +2,10 @@ import pytest -from app.common.enums import ( - AdminGroup, - NativeUserStudy as UserStudy, - NativeUserClass as UserClass, - get_user_class_number -) +from app.common.enums import AdminGroup +from app.common.enums import NativeUserClass as UserClass +from app.common.enums import NativeUserStudy as UserStudy +from app.common.enums import get_user_class_number from app.util.test_utils import get_api_client API_CHEATSHEET_BASE_URL = "/cheatsheets/" diff --git a/app/tests/content/test_event_integration.py b/app/tests/content/test_event_integration.py index f762fb70..2877434e 100644 --- a/app/tests/content/test_event_integration.py +++ b/app/tests/content/test_event_integration.py @@ -5,12 +5,9 @@ import pytest -from app.common.enums import ( - AdminGroup, - Groups, - NativeGroupType as GroupType, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup, Groups +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories import EventFactory, RegistrationFactory, UserFactory from app.content.models import Category, Event from app.forms.enums import NativeEventFormType as EventFormType diff --git a/app/tests/content/test_news_integration.py b/app/tests/content/test_news_integration.py index 50d11c09..04deff89 100644 --- a/app/tests/content/test_news_integration.py +++ b/app/tests/content/test_news_integration.py @@ -2,12 +2,9 @@ import pytest -from app.common.enums import ( - AdminGroup, - Groups, - NativeGroupType as GroupType, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup, Groups +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories.news_factory import NewsFactory from app.content.factories.user_factory import UserFactory from app.util.test_utils import add_user_to_group_with_name, get_api_client diff --git a/app/tests/content/test_registration_integration.py b/app/tests/content/test_registration_integration.py index 456fa5ba..5b9f20f8 100644 --- a/app/tests/content/test_registration_integration.py +++ b/app/tests/content/test_registration_integration.py @@ -4,11 +4,9 @@ import pytest -from app.common.enums import ( - AdminGroup, - NativeGroupType as GroupType, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories import EventFactory, RegistrationFactory, UserFactory from app.content.factories.priority_pool_factory import PriorityPoolFactory from app.forms.enums import NativeEventFormType as EventFormType diff --git a/app/tests/content/test_strike_integration.py b/app/tests/content/test_strike_integration.py index 65766ce3..088cc89e 100644 --- a/app/tests/content/test_strike_integration.py +++ b/app/tests/content/test_strike_integration.py @@ -4,11 +4,8 @@ import pytest -from app.common.enums import ( - AdminGroup, - Groups, - NativeStrikeEnum as StrikeEnum -) +from app.common.enums import AdminGroup, Groups +from app.common.enums import NativeStrikeEnum as StrikeEnum from app.content.factories import StrikeFactory from app.content.factories.event_factory import EventFactory from app.content.factories.user_factory import UserFactory diff --git a/app/tests/content/test_toddel_integration.py b/app/tests/content/test_toddel_integration.py index 0eef20d6..f630774c 100644 --- a/app/tests/content/test_toddel_integration.py +++ b/app/tests/content/test_toddel_integration.py @@ -2,10 +2,7 @@ import pytest -from app.common.enums import ( - AdminGroup, - Groups -) +from app.common.enums import AdminGroup, Groups from app.content.factories import ToddelFactory from app.util.test_utils import get_api_client diff --git a/app/tests/content/test_user_integration.py b/app/tests/content/test_user_integration.py index a550ec12..d5292545 100644 --- a/app/tests/content/test_user_integration.py +++ b/app/tests/content/test_user_integration.py @@ -6,10 +6,8 @@ import pytest -from app.common.enums import ( - AdminGroup, - NativeGroupType as GroupType -) +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as 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 diff --git a/app/tests/forms/test_eventform_integration.py b/app/tests/forms/test_eventform_integration.py index 540b6009..bba7e41e 100644 --- a/app/tests/forms/test_eventform_integration.py +++ b/app/tests/forms/test_eventform_integration.py @@ -2,11 +2,9 @@ import pytest -from app.common.enums import ( - AdminGroup, - NativeGroupType as GroupType, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories import EventFactory, RegistrationFactory from app.content.serializers import EventListSerializer from app.forms.enums import NativeEventFormType as EventFormType diff --git a/app/tests/forms/test_group_form_integration.py b/app/tests/forms/test_group_form_integration.py index 0deb8b17..d45f46ec 100644 --- a/app/tests/forms/test_group_form_integration.py +++ b/app/tests/forms/test_group_form_integration.py @@ -2,11 +2,9 @@ import pytest -from app.common.enums import ( - AdminGroup, - NativeGroupType as GroupType, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.forms.tests.form_factories import GroupFormFactory from app.group.factories import GroupFactory, MembershipFactory from app.group.models import Group diff --git a/app/tests/groups/test_fine_integration.py b/app/tests/groups/test_fine_integration.py index 83ab2c53..39de650f 100644 --- a/app/tests/groups/test_fine_integration.py +++ b/app/tests/groups/test_fine_integration.py @@ -2,10 +2,8 @@ import pytest -from app.common.enums import ( - AdminGroup, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup +from app.common.enums import NativeMembershipType as MembershipType from app.content.factories.user_factory import UserFactory from app.group.factories.fine_factory import FineFactory from app.group.factories.group_factory import GroupFactory diff --git a/app/tests/groups/test_group_integration.py b/app/tests/groups/test_group_integration.py index 719040f5..96675cf9 100644 --- a/app/tests/groups/test_group_integration.py +++ b/app/tests/groups/test_group_integration.py @@ -3,6 +3,7 @@ import pytest from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType from app.util.test_utils import get_api_client GROUP_URL = "/groups/" @@ -23,6 +24,10 @@ def _get_group_put_data(group): return {**_get_group_post_data(group), "description": "New Description"} +def get_group_post_data(type): + return {"name": "navn", "slug": "slug", "type": type} + + @pytest.mark.django_db def test_list_as_anonymous_user(default_client): """Tests if an anonymous user can list groups""" @@ -107,33 +112,53 @@ def test_update_as_group_user( @pytest.mark.django_db -def test_create_makes_group_if_not_found(group, user): - """Tests if that a group is created if it doesn't exits""" +@pytest.mark.parametrize("group_type", GroupType.public_groups()) +def test_create_new_group_as_member(member, group_type): + """Member should not be able to create a new group""" + client = get_api_client(user=member) + url = GROUP_URL + data = get_group_post_data(group_type) - name = group.name + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("group_type", GroupType.public_groups()) +def test_create_new_group_as_hs(group_type, admin_user): + """HS members should be allowed to create a new group""" + client = get_api_client(user=admin_user) + url = GROUP_URL + data = get_group_post_data(group_type) - client = get_api_client(user=user, group_name=AdminGroup.HS) - url = _get_group_url() - data = _get_group_post_data(group=group) response = client.post(url, data=data) - group.refresh_from_db() - assert group.name == name - assert response.status_code == status.HTTP_200_OK + assert response.status_code == status.HTTP_201_CREATED @pytest.mark.django_db -def test_create_return_group_if_found(group, user): +@pytest.mark.parametrize("group_type", GroupType.public_groups()) +def test_create_new_group_as_index(group_type, index_member): + """Index members should be allowed to create a new group""" + client = get_api_client(user=index_member) + url = GROUP_URL + data = get_group_post_data(group_type) - """Tests if that a group is returned if it does exits when trying to create a group""" + response = client.post(url, data=data) - name = group.name + assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +@pytest.mark.parametrize("group_type", GroupType.non_public_groups()) +def test_create_new_group_with_invalid_group_type_as_index(group_type, index_member): + """Index members with invalid group type should not be allowed to create a new group""" + client = get_api_client(user=index_member) + url = GROUP_URL + data = get_group_post_data(group_type) - client = get_api_client(user=user, group_name=AdminGroup.HS) - url = _get_group_url() - data = _get_group_post_data(group=group) response = client.post(url, data=data) - group.refresh_from_db() - assert group.name == name - assert response.status_code == status.HTTP_200_OK + print(response) + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/app/tests/groups/test_law_integration.py b/app/tests/groups/test_law_integration.py index 4cdb54aa..3dca0e0e 100644 --- a/app/tests/groups/test_law_integration.py +++ b/app/tests/groups/test_law_integration.py @@ -4,10 +4,8 @@ import pytest -from app.common.enums import ( - AdminGroup, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup +from app.common.enums import NativeMembershipType as MembershipType from app.group.factories.group_factory import GroupFactory from app.group.factories.law_factory import LawFactory from app.group.factories.membership_factory import MembershipFactory diff --git a/app/tests/groups/test_membership_history_integration.py b/app/tests/groups/test_membership_history_integration.py index 9838058b..690fa402 100644 --- a/app/tests/groups/test_membership_history_integration.py +++ b/app/tests/groups/test_membership_history_integration.py @@ -2,10 +2,8 @@ import pytest -from app.common.enums import ( - AdminGroup, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup +from app.common.enums import NativeMembershipType as MembershipType from app.group.factories.membership_factory import MembershipHistoryFactory from app.util.test_utils import get_api_client diff --git a/app/tests/groups/test_membership_integration.py b/app/tests/groups/test_membership_integration.py index 54e75e99..2b16ee29 100644 --- a/app/tests/groups/test_membership_integration.py +++ b/app/tests/groups/test_membership_integration.py @@ -2,10 +2,8 @@ import pytest -from app.common.enums import ( - AdminGroup, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup +from app.common.enums import NativeMembershipType as MembershipType from app.group.factories.membership_factory import MembershipFactory from app.util.test_utils import get_api_client diff --git a/app/urls.py b/app/urls.py index d9d8be0e..b23b8ddf 100644 --- a/app/urls.py +++ b/app/urls.py @@ -17,13 +17,14 @@ from django.contrib import admin from django.urls import include, path from rest_framework import permissions -from drf_yasg.views import get_schema_view + from drf_yasg import openapi +from drf_yasg.views import get_schema_view schema_view = get_schema_view( openapi.Info( title="Lepton API", - default_version='v1', + default_version="v1", description="API for our Django backend", contact=openapi.Contact(email="teknologiminister@tihlde.org"), ), @@ -34,7 +35,11 @@ urlpatterns = [ path("admin/", admin.site.urls), path("", include("rest_framework.urls")), - path("swagger/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), + path( + "swagger/", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), # Our endpoints path("", include("app.career.urls")), diff --git a/app/util/test_utils.py b/app/util/test_utils.py index 8e7d5d9f..78682ec2 100644 --- a/app/util/test_utils.py +++ b/app/util/test_utils.py @@ -2,11 +2,9 @@ from rest_framework.authtoken.models import Token from rest_framework.test import APIClient -from app.common.enums import ( - AdminGroup, - NativeGroupType as GroupType, - NativeMembershipType as MembershipType -) +from app.common.enums import AdminGroup +from app.common.enums import NativeGroupType as GroupType +from app.common.enums import NativeMembershipType as MembershipType from app.group.models import Group, Membership diff --git a/docker-compose.yml b/docker-compose.yml index 6bf68902..50841948 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: db: image: mysql:8.0 diff --git a/requirements.txt b/requirements.txt index d58a8eae..5d91b187 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,29 +2,29 @@ requests logzero aiohttp-cors wheel -mysqlclient == 2.1.0 -sentry-sdk == 1.9.8 # 1.9.9 has a bug that causes the following https://github.com/TIHLDE/Lepton/actions/runs/3153138974/jobs/5129248894 -celery == 5.2.2 -azure-storage-blob == 12.12.0 -python-dotenv ~= 0.21 -gunicorn == 20.1.0 -uvicorn == 0.19.0 -whitenoise == 6.2.0 -django-ical == 1.8.0 -slack-sdk == 3.19.3 -pyjwt ~= 2.6.0 +mysqlclient == 2.1.1 +sentry-sdk == 1.14.0 +celery == 5.4.0 +azure-storage-blob == 12.13.1 +python-dotenv ~= 0.21.1 +gunicorn == 23.0.0 +uvicorn == 0.30.6 +whitenoise == 6.7.0 +django-ical == 1.9.2 +slack-sdk == 3.33.1 +pyjwt ~= 2.9.0 # Django # ------------------------------------------------------------------------------ -Django==4.2.5 -django-enumchoicefield == 3.0.0 -django-filter == 22.1 -django-ordered-model~=3.6 +Django == 4.2.16 +django-enumchoicefield == 3.0.1 +django-filter == 24.3 +django-ordered-model ~= 3.7.4 # Django REST Framework -djangorestframework==3.14.0 +djangorestframework == 3.14.0 django-cors-headers -dj-rest-auth == 2.2.3 +dj-rest-auth == 6.0.0 drf-yasg == 1.21.7 #django dry rest permissions @@ -34,28 +34,28 @@ django-dry-rest-permissions == 1.2.0 django-polymorphic ~= 3.1 django-rest-polymorphic == 0.1.9 -django-mptt == 0.14.0 +django-mptt == 0.16.0 # Code quality # ------------------------------------------------------------------------------ pylint -black == 22.10.0 +black == 24.3.0 isort flake8 flake8-django flake8-black -pre-commit == 2.20.0 +pre-commit == 3.8.0 # Testing # ------------------------------------------------------------------------------ coverage pdbpp -pytest == 7.1.1 -pytest-cov == 4.0.0 -pytest-django == 4.5.2 -factory-boy == 3.2.1 -pytest-factoryboy == 2.5.0 -pytest-lazy-fixture==0.6.3 +pytest == 7.4.4 +pytest-cov == 5.0.0 +pytest-django == 4.9.0 +factory-boy == 3.3.1 +pytest-factoryboy == 2.7.0 +pytest-lazy-fixture == 0.6.3 # CSV -djangorestframework-csv==2.1.1 +djangorestframework-csv == 3.0.2 From f991cec84c14467be03d1b12e035f6d5fb469015 Mon Sep 17 00:00:00 2001 From: Johannes Aamot-Skeidsvoll <82368148+Tmpecho@users.noreply.github.com> Date: Mon, 23 Sep 2024 20:51:56 +0200 Subject: [PATCH 40/59] Allow HS members to create a new group (#864) * Add endpoint to create new group as admin Signed-off-by: Tmpecho * remove bad exception handling in serializers/group.py * fix inheritance ordering in views/group.py * refactored group integration test, added non_public_groups to enums * fix linting * reformat files * remove unused import in groups/views/group.py --------- Signed-off-by: Tmpecho Co-authored-by: 1Cezzo From 11667286cc3794d2ffe85bd6777c067aa8fd4e43 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:51:15 +0200 Subject: [PATCH 41/59] App Script Fix (#875) added serializers dir to script --- scripts/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/app.py b/scripts/app.py index a9168c9a..0987e993 100644 --- a/scripts/app.py +++ b/scripts/app.py @@ -29,6 +29,7 @@ def create_app(): init_dir(app_path, "tests") init_dir(app_path, "util") init_dir(app_path, "views") + init_dir(app_path, "serializers") # Create the app's files init_app_file(app_path, "__init__.py") From 0771f2de074ede4f3c6d4c2601f6e9d66e3853f9 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Tue, 24 Sep 2024 11:02:35 +0200 Subject: [PATCH 42/59] Event registration payment orders (#876) * added list of payment orders for registrations * update CHANGELOG.md --- CHANGELOG.md | 2 ++ app/content/serializers/registration.py | 7 +++++++ app/payment/serializers/order.py | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ee78df7..34bf9554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ ## Neste versjon +- ⚡**Betalingsordre**. Man kan nå se historikk over betalingsordre for en påmelding til et arrangement. +- ✨**Gruppe**. HS kan nå opprette en ny gruppe. - ⚡**Swagger**. La til en GitHub Action for å verifisere at Swagger er oppe og går. - ✨**Swagger**. API dokumentasjon er nå tilgjengelig med Swagger. - ⚡**Profil**. Endret rekkefølge på tidligere arrangementer slik at nyeste kommer først. diff --git a/app/content/serializers/registration.py b/app/content/serializers/registration.py index 4c7ac599..339cc1dc 100644 --- a/app/content/serializers/registration.py +++ b/app/content/serializers/registration.py @@ -10,6 +10,7 @@ from app.forms.enums import NativeEventFormType as EventFormType from app.forms.serializers.submission import SubmissionInRegistrationSerializer from app.payment.enums import OrderStatus +from app.payment.serializers.order import OrderEventRegistrationSerializer from app.payment.util.order_utils import has_paid_order from app.payment.util.payment_utils import get_payment_order_status @@ -20,6 +21,7 @@ class RegistrationSerializer(BaseModelSerializer): has_unanswered_evaluation = serializers.SerializerMethodField() has_paid_order = serializers.SerializerMethodField(required=False) wait_queue_number = serializers.SerializerMethodField(required=False) + payment_orders = serializers.SerializerMethodField(required=False) class Meta: model = Registration @@ -36,6 +38,7 @@ class Meta: "has_paid_order", "wait_queue_number", "created_by_admin", + "payment_orders", ) def get_survey_submission(self, obj): @@ -55,6 +58,10 @@ def get_has_paid_order(self, obj): return has_paid_order(orders) + def get_payment_orders(self, obj): + orders = obj.event.orders.filter(user=obj.user) + return OrderEventRegistrationSerializer(orders, many=True, read_only=True).data + def create(self, validated_data): event = validated_data["event"] diff --git a/app/payment/serializers/order.py b/app/payment/serializers/order.py index 3541023e..c8f171d9 100644 --- a/app/payment/serializers/order.py +++ b/app/payment/serializers/order.py @@ -68,3 +68,9 @@ def create(self, validated_data): ) return order + + +class OrderEventRegistrationSerializer(BaseModelSerializer): + class Meta: + model = Order + fields = ("order_id", "status", "created_at") From 5b06e4e19bdd6cc351c0a14df65e92a8efc04570 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 11:14:33 +0200 Subject: [PATCH 43/59] chore(deps): update python-dotenv requirement from ~=0.21.1 to ~=1.0.1 (#871) Updates the requirements on [python-dotenv](https://github.com/theskumar/python-dotenv) to permit the latest version. - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v0.21.1...v1.0.1) --- updated-dependencies: - dependency-name: python-dotenv dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5d91b187..a3ca4d60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ mysqlclient == 2.1.1 sentry-sdk == 1.14.0 celery == 5.4.0 azure-storage-blob == 12.13.1 -python-dotenv ~= 0.21.1 +python-dotenv ~= 1.0.1 gunicorn == 23.0.0 uvicorn == 0.30.6 whitenoise == 6.7.0 From 01fdcc3f301f010582205446faf5259a3fe8d80c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:06:02 +0200 Subject: [PATCH 44/59] Chore(deps): Bump sentry-sdk from 1.14.0 to 2.8.0 (#866) Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 1.14.0 to 2.8.0. - [Release notes](https://github.com/getsentry/sentry-python/releases) - [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-python/compare/1.14.0...2.8.0) --- updated-dependencies: - dependency-name: sentry-sdk dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a3ca4d60..f62db6e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ logzero aiohttp-cors wheel mysqlclient == 2.1.1 -sentry-sdk == 1.14.0 +sentry-sdk == 2.8.0 celery == 5.4.0 azure-storage-blob == 12.13.1 python-dotenv ~= 1.0.1 From 192e7ee8e0d55f6af356a77be41397c2b52b021f Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Tue, 24 Sep 2024 21:33:03 +0200 Subject: [PATCH 45/59] Codex Course (#852) * added models for course and registration, and viewset for course and tests * added validation for date checking for courses * added viewset for registration for codex courses * removed unused fields from course model * removed unused imports * added API error mixins as mother clas * fixed error mixin * refactored to event model * fixed wrong import * fixed tests * format * skipped broken tests, must be refactored * updated CHANGELOG.md * format --- CHANGELOG.md | 1 + Makefile | 2 +- app/codex/__init__.py | 0 app/codex/admin/__init__.py | 1 + app/codex/admin/admin.py | 7 + app/codex/apps.py | 0 app/codex/enums.py | 23 ++ app/codex/exceptions.py | 24 ++ app/codex/factories/__init__.py | 2 + app/codex/factories/event.py | 20 ++ app/codex/factories/registration.py | 15 + app/codex/filters/__init__.py | 1 + app/codex/filters/event.py | 20 ++ app/codex/migrations/0001_initial.py | 123 +++++++++ app/codex/migrations/__init__.py | 0 app/codex/mixins.py | 17 ++ app/codex/models/__init__.py | 0 app/codex/models/event.py | 93 +++++++ app/codex/models/registration.py | 52 ++++ app/codex/serializers/__init__.py | 10 + app/codex/serializers/event.py | 109 ++++++++ app/codex/serializers/registration.py | 26 ++ app/codex/tests/__init__.py | 0 app/codex/urls.py | 18 ++ app/codex/util/__init__.py | 2 + app/codex/util/event.py | 16 ++ app/codex/util/user.py | 5 + app/codex/views/__init__.py | 2 + app/codex/views/event.py | 85 ++++++ app/codex/views/registration.py | 61 ++++ app/content/factories/registration_factory.py | 6 +- app/content/models/minute.py | 2 +- app/content/views/minute.py | 4 +- app/settings.py | 17 +- .../test_badge_and_category_integration.py | 3 +- .../badge/test_user_badge_integration.py | 3 +- app/tests/codex/__init__.py | 0 .../codex/test_codex_event_integration.py | 261 ++++++++++++++++++ ...st_codex_event_registration_integration.py | 149 ++++++++++ ...t_user_notification_setting_integration.py | 8 +- app/tests/conftest.py | 11 + app/tests/content/test_minute_integration.py | 2 +- app/tests/content/test_user_integration.py | 6 + app/urls.py | 1 + 44 files changed, 1189 insertions(+), 19 deletions(-) create mode 100644 app/codex/__init__.py create mode 100644 app/codex/admin/__init__.py create mode 100644 app/codex/admin/admin.py create mode 100644 app/codex/apps.py create mode 100644 app/codex/enums.py create mode 100644 app/codex/exceptions.py create mode 100644 app/codex/factories/__init__.py create mode 100644 app/codex/factories/event.py create mode 100644 app/codex/factories/registration.py create mode 100644 app/codex/filters/__init__.py create mode 100644 app/codex/filters/event.py create mode 100644 app/codex/migrations/0001_initial.py create mode 100644 app/codex/migrations/__init__.py create mode 100644 app/codex/mixins.py create mode 100644 app/codex/models/__init__.py create mode 100644 app/codex/models/event.py create mode 100644 app/codex/models/registration.py create mode 100644 app/codex/serializers/__init__.py create mode 100644 app/codex/serializers/event.py create mode 100644 app/codex/serializers/registration.py create mode 100644 app/codex/tests/__init__.py create mode 100644 app/codex/urls.py create mode 100644 app/codex/util/__init__.py create mode 100644 app/codex/util/event.py create mode 100644 app/codex/util/user.py create mode 100644 app/codex/views/__init__.py create mode 100644 app/codex/views/event.py create mode 100644 app/codex/views/registration.py create mode 100644 app/tests/codex/__init__.py create mode 100644 app/tests/codex/test_codex_event_integration.py create mode 100644 app/tests/codex/test_codex_event_registration_integration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 34bf9554..b3be4131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ## Neste versjon +- ✨**Codex arrangementer**. Det kan nå opprettes arrangementer på Codex, som medlemmer av Codex kan melde seg på. - ⚡**Betalingsordre**. Man kan nå se historikk over betalingsordre for en påmelding til et arrangement. - ✨**Gruppe**. HS kan nå opprette en ny gruppe. - ⚡**Swagger**. La til en GitHub Action for å verifisere at Swagger er oppe og går. diff --git a/Makefile b/Makefile index c034d0dc..a9d0a355 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ createsuperuser: ## Create a new django superuser .PHONY: makemigrations makemigrations: ## Create migration files - docker compose run --rm web python manage.py makemigrations + docker compose run --rm web python manage.py makemigrations ${args} .PHONY: migrate migrate: ## Run django migrations diff --git a/app/codex/__init__.py b/app/codex/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/codex/admin/__init__.py b/app/codex/admin/__init__.py new file mode 100644 index 00000000..14fed587 --- /dev/null +++ b/app/codex/admin/__init__.py @@ -0,0 +1 @@ +from app.codex.admin import admin diff --git a/app/codex/admin/admin.py b/app/codex/admin/admin.py new file mode 100644 index 00000000..e192be93 --- /dev/null +++ b/app/codex/admin/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +from app.codex.models.event import CodexEvent +from app.codex.models.registration import CodexEventRegistration + +admin.site.register(CodexEvent) +admin.site.register(CodexEventRegistration) diff --git a/app/codex/apps.py b/app/codex/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/app/codex/enums.py b/app/codex/enums.py new file mode 100644 index 00000000..ba9a554d --- /dev/null +++ b/app/codex/enums.py @@ -0,0 +1,23 @@ +from django.db import models + + +class CodexGroups(models.TextChoices): + DRIFT = "Drift" + INDEX = "Index" + + @classmethod + def all(cls) -> list: + return [cls.DRIFT, cls.INDEX] + + @classmethod + def reverse(cls) -> list: + return [cls.INDEX, cls.DRIFT] + + +class CodexEventTags(models.TextChoices): + WORKSHOP = "Workshop" + LECTURE = "Lecture" + + @classmethod + def all(cls) -> list: + return [cls.WORKSHOP, cls.LECTURE] diff --git a/app/codex/exceptions.py b/app/codex/exceptions.py new file mode 100644 index 00000000..fd68fcce --- /dev/null +++ b/app/codex/exceptions.py @@ -0,0 +1,24 @@ +from rest_framework import status +from rest_framework.exceptions import APIException + + +class APICodexEventEndRegistrationDateAfterStartDate(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = ( + "Sluttdatoen for påmelding kan ikke være etter startdatoen for kurset" + ) + + +class APICodexEventEndRegistrationDateBeforeStartRegistrationDate(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = ( + "Sluttdatoen for påmelding kan ikke være før startdatoen for påmelding" + ) + + +class CodexEventEndRegistrationDateAfterStartDate(ValueError): + pass + + +class CodexEventEndRegistrationDateBeforeStartRegistrationDate(ValueError): + pass diff --git a/app/codex/factories/__init__.py b/app/codex/factories/__init__.py new file mode 100644 index 00000000..4f10aa80 --- /dev/null +++ b/app/codex/factories/__init__.py @@ -0,0 +1,2 @@ +from app.codex.factories.event import CodexEventFactory +from app.codex.factories.registration import CodexEventRegistrationFactory diff --git a/app/codex/factories/event.py b/app/codex/factories/event.py new file mode 100644 index 00000000..17b04006 --- /dev/null +++ b/app/codex/factories/event.py @@ -0,0 +1,20 @@ +from datetime import timedelta + +from django.utils import timezone + +import factory +from factory.django import DjangoModelFactory + +from app.codex.models.event import CodexEvent + + +class CodexEventFactory(DjangoModelFactory): + class Meta: + model = CodexEvent + + title = factory.Sequence(lambda n: f"Event {n}") + description = factory.Faker("text") + start_date = timezone.now() + timedelta(days=10) + + start_registration_at = timezone.now() - timedelta(days=1) + end_registration_at = timezone.now() + timedelta(days=9) diff --git a/app/codex/factories/registration.py b/app/codex/factories/registration.py new file mode 100644 index 00000000..cba9db1c --- /dev/null +++ b/app/codex/factories/registration.py @@ -0,0 +1,15 @@ +import factory +from factory.django import DjangoModelFactory + +from app.codex.factories.event import CodexEventFactory +from app.codex.models.registration import CodexEventRegistration +from app.content.factories.user_factory import UserFactory + + +class CodexEventRegistrationFactory(DjangoModelFactory): + class Meta: + model = CodexEventRegistration + + user = factory.SubFactory(UserFactory) + event = factory.SubFactory(CodexEventFactory) + order = 0 diff --git a/app/codex/filters/__init__.py b/app/codex/filters/__init__.py new file mode 100644 index 00000000..d2410acb --- /dev/null +++ b/app/codex/filters/__init__.py @@ -0,0 +1 @@ +from app.codex.filters.event import CodexEventFilter diff --git a/app/codex/filters/event.py b/app/codex/filters/event.py new file mode 100644 index 00000000..7d9decb8 --- /dev/null +++ b/app/codex/filters/event.py @@ -0,0 +1,20 @@ +from django_filters.rest_framework import ( + DateTimeFilter, + FilterSet, + OrderingFilter, +) + +from app.codex.models.event import CodexEvent + + +class CodexEventFilter(FilterSet): + """Filters events by tag and expired. Works with search query""" + + end_range = DateTimeFilter(field_name="start_date", lookup_expr="lte") + start_range = DateTimeFilter(field_name="end_date", lookup_expr="gte") + + ordering = OrderingFilter("start_date", "tag") + + class Meta: + model = CodexEvent + fields = ["tag", "end_range", "start_range", "organizer"] diff --git a/app/codex/migrations/0001_initial.py b/app/codex/migrations/0001_initial.py new file mode 100644 index 00000000..60d4cb46 --- /dev/null +++ b/app/codex/migrations/0001_initial.py @@ -0,0 +1,123 @@ +# Generated by Django 4.2.16 on 2024-09-24 16:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("group", "0020_alter_membership_membership_type_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="CodexEvent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("start_date", models.DateTimeField()), + ( + "start_registration_at", + models.DateTimeField(blank=True, default=None, null=True), + ), + ( + "end_registration_at", + models.DateTimeField(blank=True, default=None, null=True), + ), + ( + "tag", + models.CharField( + choices=[("Workshop", "Workshop"), ("Lecture", "Lecture")], + default="Lecture", + max_length=50, + ), + ), + ("location", models.CharField(max_length=200, null=True)), + ("mazemap_link", models.URLField(max_length=2000, null=True)), + ( + "lecturer", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="codex_events", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "organizer", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="codex_events", + to="group.group", + ), + ), + ], + options={ + "verbose_name_plural": "Events", + "ordering": ("start_date",), + }, + ), + migrations.CreateModel( + name="CodexEventRegistration", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "registration_id", + models.AutoField(primary_key=True, serialize=False), + ), + ("order", models.IntegerField(default=0)), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="codex_event_registrations", + to="codex.codexevent", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="codex_event_registrations", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ("order", "created_at"), + "unique_together": {("user", "event")}, + }, + ), + migrations.AddField( + model_name="codexevent", + name="registrations", + field=models.ManyToManyField( + blank=True, + default=None, + through="codex.CodexEventRegistration", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/app/codex/migrations/__init__.py b/app/codex/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/codex/mixins.py b/app/codex/mixins.py new file mode 100644 index 00000000..37309208 --- /dev/null +++ b/app/codex/mixins.py @@ -0,0 +1,17 @@ +from app.codex.exceptions import ( + APICodexEventEndRegistrationDateAfterStartDate, + APICodexEventEndRegistrationDateBeforeStartRegistrationDate, + CodexEventEndRegistrationDateAfterStartDate, + CodexEventEndRegistrationDateBeforeStartRegistrationDate, +) +from app.util.mixins import APIErrorsMixin + + +class APICodexEventErrorsMixin(APIErrorsMixin): + @property + def expected_exceptions(self): + return { + **super().expected_exceptions, + CodexEventEndRegistrationDateAfterStartDate: APICodexEventEndRegistrationDateAfterStartDate, + CodexEventEndRegistrationDateBeforeStartRegistrationDate: APICodexEventEndRegistrationDateBeforeStartRegistrationDate, + } diff --git a/app/codex/models/__init__.py b/app/codex/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/codex/models/event.py b/app/codex/models/event.py new file mode 100644 index 00000000..ad5c077a --- /dev/null +++ b/app/codex/models/event.py @@ -0,0 +1,93 @@ +from django.db import models + +from app.codex.enums import CodexEventTags, CodexGroups +from app.codex.util import user_is_leader_of_codex_group +from app.common.permissions import BasePermissionModel +from app.content.models import User +from app.group.models import Group +from app.util.models import BaseModel + + +class CodexEvent(BaseModel, BasePermissionModel): + read_access = CodexGroups.all() + write_access = CodexGroups.all() + + title = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + + start_date = models.DateTimeField() + + start_registration_at = models.DateTimeField(blank=True, null=True, default=None) + end_registration_at = models.DateTimeField(blank=True, null=True, default=None) + + tag = models.CharField( + max_length=50, choices=CodexEventTags.choices, default=CodexEventTags.LECTURE + ) + + location = models.CharField(max_length=200, null=True) + mazemap_link = models.URLField(max_length=2000, null=True) + + organizer = models.ForeignKey( + Group, + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + related_name="codex_events", + ) + lecturer = models.ForeignKey( + User, + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + related_name="codex_events", + ) + + registrations = models.ManyToManyField( + User, + through="CodexEventRegistration", + through_fields=("event", "user"), + blank=True, + default=None, + ) + + class Meta: + verbose_name_plural = "Events" + ordering = ("start_date",) + + def __str__(self): + return f"{self.title} - starting {self.start_date} at {self.location}" + + @property + def list_count(self): + return self.registrations.count() + + @classmethod + def has_write_permission(cls, request): + user = request.user + return user_is_leader_of_codex_group(user) + + @classmethod + def has_update_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_destroy_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_retrieve_permission(cls, request): + return cls.has_read_permission(request) + + def has_object_write_permission(self, request): + return self.has_write_permission(request) + + def has_object_update_permission(self, request): + return self.has_write_permission(request) + + def has_object_destroy_permission(self, request): + return self.has_write_permission(request) + + def has_object_retrieve_permission(self, request): + return self.has_read_permission(request) diff --git a/app/codex/models/registration.py b/app/codex/models/registration.py new file mode 100644 index 00000000..c4fa8308 --- /dev/null +++ b/app/codex/models/registration.py @@ -0,0 +1,52 @@ +from django.db import models + +from app.codex.enums import CodexGroups +from app.codex.models.event import CodexEvent +from app.codex.util import user_is_leader_of_codex_group +from app.common.permissions import BasePermissionModel +from app.content.models import User +from app.util.models import BaseModel + + +class CodexEventRegistration(BaseModel, BasePermissionModel): + read_access = CodexGroups.all() + write_access = CodexGroups.all() + + registration_id = models.AutoField(primary_key=True) + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="codex_event_registrations" + ) + event = models.ForeignKey( + CodexEvent, on_delete=models.CASCADE, related_name="codex_event_registrations" + ) + order = models.IntegerField(default=0) + + class Meta: + ordering = ("order", "created_at") + unique_together = ("user", "event") + + def __str__(self): + return f"{self.user} - {self.event.title} - {self.created_at}" + + @classmethod + def has_update_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_destroy_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_retrieve_permission(cls, request): + return cls.has_read_permission(request) + + def has_object_update_permission(self, request): + user = request.user + return user == self.user or user_is_leader_of_codex_group(user) + + def has_object_destroy_permission(self, request): + user = request.user + return user == self.user or user_is_leader_of_codex_group(user) + + def has_object_retrieve_permission(self, request): + return self.has_retrieve_permission(request) diff --git a/app/codex/serializers/__init__.py b/app/codex/serializers/__init__.py new file mode 100644 index 00000000..e21a9f51 --- /dev/null +++ b/app/codex/serializers/__init__.py @@ -0,0 +1,10 @@ +from app.codex.serializers.event import ( + CodexEventSerializer, + CodexEventListSerializer, + CodexEventCreateSerializer, + CodexEventUpdateSerializer, +) +from app.codex.serializers.registration import ( + RegistrationListSerializer, + RegistrationCreateSerializer, +) diff --git a/app/codex/serializers/event.py b/app/codex/serializers/event.py new file mode 100644 index 00000000..8f878f02 --- /dev/null +++ b/app/codex/serializers/event.py @@ -0,0 +1,109 @@ +from rest_framework import serializers + +from dry_rest_permissions.generics import DRYPermissionsField + +from app.codex.models.event import CodexEvent +from app.codex.util import validate_event_dates +from app.common.serializers import BaseModelSerializer +from app.content.serializers.user import UserListSerializer +from app.group.serializers.group import SimpleGroupSerializer + + +class CodexEventSerializer(BaseModelSerializer): + lecturer = UserListSerializer() + organizer = SimpleGroupSerializer() + permissions = DRYPermissionsField( + actions=[ + "write", + "update", + "destroy", + ], + object_only=True, + ) + viewer_is_registered = serializers.SerializerMethodField() + + class Meta: + model = CodexEvent + fields = ( + "id", + "title", + "description", + "start_date", + "start_registration_at", + "end_registration_at", + "location", + "mazemap_link", + "organizer", + "lecturer", + "tag", + "permissions", + "viewer_is_registered", + ) + + def get_viewer_is_registered(self, obj): + request = self.context.get("request") + return obj.registrations.filter(user_id=request.user.user_id).exists() + + +class CodexEventListSerializer(BaseModelSerializer): + number_of_registrations = serializers.SerializerMethodField() + lecturer = UserListSerializer() + organizer = SimpleGroupSerializer() + + class Meta: + model = CodexEvent + fields = ( + "id", + "title", + "start_date", + "location", + "organizer", + "lecturer", + "number_of_registrations", + "tag", + ) + + def get_number_of_registrations(self, obj): + return obj.registrations.count() + + +class CodexEventCreateSerializer(BaseModelSerializer): + class Meta: + model = CodexEvent + fields = ( + "title", + "description", + "start_date", + "start_registration_at", + "end_registration_at", + "tag", + "location", + "mazemap_link", + "organizer", + "lecturer", + ) + + def create(self, validated_data): + validate_event_dates(validated_data) + return super().create(validated_data) + + +class CodexEventUpdateSerializer(BaseModelSerializer): + class Meta: + model = CodexEvent + fields = ( + "title", + "description", + "start_date", + "start_registration_at", + "end_registration_at", + "tag", + "location", + "mazemap_link", + "organizer", + "lecturer", + ) + + def update(self, instance, validated_data): + validate_event_dates(validated_data) + return super().update(instance, validated_data) diff --git a/app/codex/serializers/registration.py b/app/codex/serializers/registration.py new file mode 100644 index 00000000..66f47d6c --- /dev/null +++ b/app/codex/serializers/registration.py @@ -0,0 +1,26 @@ +from app.codex.models.registration import CodexEventRegistration +from app.common.serializers import BaseModelSerializer +from app.content.serializers.user import UserListSerializer + + +class RegistrationListSerializer(BaseModelSerializer): + user_info = UserListSerializer(source="user", read_only=True) + + class Meta: + model = CodexEventRegistration + fields = ("registration_id", "user_info", "order") + + +class RegistrationCreateSerializer(BaseModelSerializer): + class Meta: + model = CodexEventRegistration + fields = ("event",) + + def create(self, validated_data): + last_order = ( + CodexEventRegistration.objects.filter(event=validated_data["event"]).count() + - 1 + ) + validated_data["order"] = last_order + 1 + + return CodexEventRegistration.objects.create(**validated_data) diff --git a/app/codex/tests/__init__.py b/app/codex/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/codex/urls.py b/app/codex/urls.py new file mode 100644 index 00000000..869398ed --- /dev/null +++ b/app/codex/urls.py @@ -0,0 +1,18 @@ +from django.urls import include, re_path +from rest_framework import routers + +from app.codex.views import CodexEventViewSet, RegistrationViewSet + +router = routers.DefaultRouter() + +router.register("events", CodexEventViewSet) +router.register( + r"events/(?P\d+)/registrations", + RegistrationViewSet, + basename="registration", +) + + +urlpatterns = [ + re_path(r"", include(router.urls)), +] diff --git a/app/codex/util/__init__.py b/app/codex/util/__init__.py new file mode 100644 index 00000000..200b165a --- /dev/null +++ b/app/codex/util/__init__.py @@ -0,0 +1,2 @@ +from app.codex.util.user import user_is_leader_of_codex_group +from app.codex.util.event import validate_event_dates diff --git a/app/codex/util/event.py b/app/codex/util/event.py new file mode 100644 index 00000000..00b3979f --- /dev/null +++ b/app/codex/util/event.py @@ -0,0 +1,16 @@ +from app.codex.exceptions import ( + CodexEventEndRegistrationDateAfterStartDate, + CodexEventEndRegistrationDateBeforeStartRegistrationDate, +) + + +def validate_event_dates(data: dict): + if data["end_registration_at"] > data["start_date"]: + raise CodexEventEndRegistrationDateAfterStartDate( + "Påmeldingsslutt kan ikke være etter kursstart" + ) + + if data["end_registration_at"] < data["start_registration_at"]: + raise CodexEventEndRegistrationDateBeforeStartRegistrationDate( + "Påmeldingsslutt kan ikke være før påmeldingsstart" + ) diff --git a/app/codex/util/user.py b/app/codex/util/user.py new file mode 100644 index 00000000..d0ecf6c4 --- /dev/null +++ b/app/codex/util/user.py @@ -0,0 +1,5 @@ +from app.codex.enums import CodexGroups + + +def user_is_leader_of_codex_group(user): + return user.is_leader_of(CodexGroups.DRIFT) or user.is_leader_of(CodexGroups.INDEX) diff --git a/app/codex/views/__init__.py b/app/codex/views/__init__.py new file mode 100644 index 00000000..04bfb705 --- /dev/null +++ b/app/codex/views/__init__.py @@ -0,0 +1,2 @@ +from app.codex.views.event import CodexEventViewSet +from app.codex.views.registration import RegistrationViewSet diff --git a/app/codex/views/event.py b/app/codex/views/event.py new file mode 100644 index 00000000..eb381436 --- /dev/null +++ b/app/codex/views/event.py @@ -0,0 +1,85 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import filters, status +from rest_framework.response import Response + +from app.codex.filters import CodexEventFilter +from app.codex.mixins import APICodexEventErrorsMixin +from app.codex.models.event import CodexEvent +from app.codex.serializers import ( + CodexEventCreateSerializer, + CodexEventListSerializer, + CodexEventSerializer, + CodexEventUpdateSerializer, +) +from app.common.pagination import BasePagination +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet + + +class CodexEventViewSet(APICodexEventErrorsMixin, BaseViewSet): + serializer_class = CodexEventSerializer + permission_classes = [BasicViewPermission] + queryset = CodexEvent.objects.all() + pagination_class = BasePagination + + filter_backends = [DjangoFilterBackend, filters.SearchFilter] + filterset_class = CodexEventFilter + search_fields = ["title"] + + def get_serializer_class(self): + if hasattr(self, "action") and self.action == "list": + return CodexEventListSerializer + return super().get_serializer_class() + + def retrieve(self, request, *args, **kwargs): + try: + event = self.get_object() + serializer = CodexEventSerializer( + event, context={"request": request}, many=False + ) + return Response(serializer.data) + except CodexEvent.DoesNotExist: + return Response( + {"detail": "Fant ikke arrangementet"}, + status=status.HTTP_404_NOT_FOUND, + ) + + def create(self, request, *args, **kwargs): + data = request.data + serializer = CodexEventCreateSerializer(data=data, context={"request": request}) + + if serializer.is_valid(): + event = super().perform_create(serializer) + serializer = CodexEventSerializer( + event, context={"request": request}, many=False + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response( + {"detail": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def update(self, request, *args, **kwargs): + event = self.get_object() + serializer = CodexEventUpdateSerializer( + event, data=request.data, context={"request": request} + ) + + if serializer.is_valid(): + event = super().perform_update(serializer) + serializer = CodexEventSerializer( + event, context={"request": request}, many=False + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + return Response( + {"detail": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def destroy(self, request, *args, **kwargs): + super().destroy(request, *args, **kwargs) + return Response( + {"detail": "Arrangementet ble slettet"}, status=status.HTTP_200_OK + ) diff --git a/app/codex/views/registration.py b/app/codex/views/registration.py new file mode 100644 index 00000000..4ce8c77d --- /dev/null +++ b/app/codex/views/registration.py @@ -0,0 +1,61 @@ +from rest_framework import status +from rest_framework.response import Response + +from app.codex.models.registration import CodexEventRegistration +from app.codex.serializers import ( + RegistrationCreateSerializer, + RegistrationListSerializer, +) +from app.common.pagination import BasePagination +from app.common.permissions import BasicViewPermission +from app.common.viewsets import BaseViewSet + + +class RegistrationViewSet(BaseViewSet): + serializer_class = RegistrationListSerializer + permission_classes = [BasicViewPermission] + pagination_class = BasePagination + + def get_queryset(self): + event_id = self.kwargs.get("event_id") + return CodexEventRegistration.objects.filter(event__pk=event_id).select_related( + "user" + ) + + def retrieve(self, request, *args, **kwargs): + try: + registration = self.get_object() + serializer = RegistrationListSerializer( + registration, context={"request": request}, many=False + ) + return Response(serializer.data) + except CodexEventRegistration.DoesNotExist: + return Response( + {"detail": "Fant ikke påmeldingen for arrangementet"}, + status=status.HTTP_404_NOT_FOUND, + ) + + def create(self, request, *args, **kwargs): + data = request.data + serializer = RegistrationCreateSerializer( + data=data, context={"request": request} + ) + + if serializer.is_valid(): + registration = super().perform_create(serializer, user=request.user) + serializer = RegistrationListSerializer( + registration, context={"request": request}, many=False + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response( + {"detail": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def destroy(self, request, *args, **kwargs): + super().destroy(request, *args, **kwargs) + return Response( + {"detail": "Påmeldingen for arrangementet ble slettet"}, + status=status.HTTP_200_OK, + ) diff --git a/app/content/factories/registration_factory.py b/app/content/factories/registration_factory.py index f6cc67c5..a189b277 100644 --- a/app/content/factories/registration_factory.py +++ b/app/content/factories/registration_factory.py @@ -14,7 +14,7 @@ class Meta: user = factory.SubFactory(UserFactory) is_on_wait = factory.LazyAttribute( - lambda registration: False - if registration.event.limit == 0 - else registration.event.is_full + lambda registration: ( + False if registration.event.limit == 0 else registration.event.is_full + ) ) diff --git a/app/content/models/minute.py b/app/content/models/minute.py index ea3939df..35cd847a 100644 --- a/app/content/models/minute.py +++ b/app/content/models/minute.py @@ -1,6 +1,6 @@ from django.db import models -from app.common.enums import CodexGroups +from app.codex.enums import CodexGroups from app.common.permissions import BasePermissionModel, check_has_access from app.content.enums import MinuteTagEnum from app.content.models.user import User diff --git a/app/content/views/minute.py b/app/content/views/minute.py index 8ebef660..ceb779b8 100644 --- a/app/content/views/minute.py +++ b/app/content/views/minute.py @@ -3,7 +3,7 @@ from rest_framework.exceptions import NotFound from rest_framework.response import Response -from app.common.enums import CodexGroups +from app.codex.enums import CodexGroups from app.common.pagination import BasePagination from app.common.permissions import ( BasicViewPermission, @@ -79,4 +79,4 @@ def update(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): super().destroy(request, *args, **kwargs) - return Response({"detail": "The minute was deleted"}, status=status.HTTP_200_OK) + return Response({"detail": "Dokumentet ble slettet"}, status=status.HTTP_200_OK) diff --git a/app/settings.py b/app/settings.py index 00af9754..d7a79e4d 100644 --- a/app/settings.py +++ b/app/settings.py @@ -37,17 +37,21 @@ ENVIRONMENT = ( EnvironmentOptions.PRODUCTION if os.environ.get("PROD") - else EnvironmentOptions.DEVELOPMENT - if os.environ.get("DEV") - else EnvironmentOptions.LOCAL + else ( + EnvironmentOptions.DEVELOPMENT + if os.environ.get("DEV") + else EnvironmentOptions.LOCAL + ) ) WEBSITE_URL = ( "https://tihlde.org" if ENVIRONMENT == EnvironmentOptions.PRODUCTION - else "https://dev.tihlde.org" - if ENVIRONMENT == EnvironmentOptions.DEVELOPMENT - else "http://localhost:3000" + else ( + "https://dev.tihlde.org" + if ENVIRONMENT == EnvironmentOptions.DEVELOPMENT + else "http://localhost:3000" + ) ) AZURE_BLOB_STORAGE_NAME = "tihldestorage.blob.core.windows.net" @@ -102,6 +106,7 @@ "app.payment", "app.kontres", "app.emoji", + "app.codex", ] # Django rest framework diff --git a/app/tests/badge/test_badge_and_category_integration.py b/app/tests/badge/test_badge_and_category_integration.py index 82b1dd88..7141c145 100644 --- a/app/tests/badge/test_badge_and_category_integration.py +++ b/app/tests/badge/test_badge_and_category_integration.py @@ -87,7 +87,8 @@ def test_no_badges_are_shown_when_none_are_public(api_client, admin_user): def test_completion_percentage_excludes_all_unregistered_users(badge, api_client): """Unregistered users are all users who do not have a membership in the TIHLDE group When calculating completing percentage, only members in this group are counted. - Creates 4 valid users plus one unregistered user. Result should be 25 == 1/4 * 100""" + Creates 4 valid users plus one unregistered user. Result should be 25 == 1/4 * 100 + """ member_with_badge = UserFactory() diff --git a/app/tests/badge/test_user_badge_integration.py b/app/tests/badge/test_user_badge_integration.py index 7faeb075..74576660 100644 --- a/app/tests/badge/test_user_badge_integration.py +++ b/app/tests/badge/test_user_badge_integration.py @@ -98,7 +98,8 @@ def test_create_user_badge_for_different_active_dates( ): """Tests several arguments for active_from and active_to dates and checks whether response is expected. Badge is not active if active_from is later than now or active_to is earlier than now. - If active_to/from is None, it is treated as infinitly far in the past for active_from and in the future for active_to.""" + If active_to/from is None, it is treated as infinitly far in the past for active_from and in the future for active_to. + """ badge = BadgeFactory(active_to=active_to, active_from=active_from) url = _get_user_badges_url() diff --git a/app/tests/codex/__init__.py b/app/tests/codex/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/tests/codex/test_codex_event_integration.py b/app/tests/codex/test_codex_event_integration.py new file mode 100644 index 00000000..b11253f7 --- /dev/null +++ b/app/tests/codex/test_codex_event_integration.py @@ -0,0 +1,261 @@ +from datetime import timedelta + +from django.utils import timezone +from rest_framework import status + +import pytest + +from app.codex.enums import CodexGroups +from app.codex.factories import CodexEventFactory +from app.common.enums import NativeMembershipType as MembershipType +from app.util.test_utils import add_user_to_group_with_name, get_api_client + +CODEX_EVENT_BASE_URL = "/codex/events/" + + +def get_event_data( + title: str = "Test event", + description: str = "Test Description", + organizer: str = None, + lecturer: str = None, + start_date: str = timezone.now() + timedelta(days=10), + registration_start_at: str = timezone.now() + timedelta(days=1), + registration_end_at: str = timezone.now() + timedelta(days=9), +): + data = { + "title": title, + "description": description, + "start_date": start_date, + "start_registration_at": registration_start_at, + "end_registration_at": registration_end_at, + "location": "Test Location", + "maxemap_link": "https://example.com", + } + + if organizer: + data["organizer"] = organizer + if lecturer: + data["lecturer"] = lecturer + + return data + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_list_codex_events_as_codex_member(member, codex_group): + """A codex member should be able to list all codex events""" + add_user_to_group_with_name(member, codex_group) + + CodexEventFactory.create_batch(5) + + url = CODEX_EVENT_BASE_URL + client = get_api_client(user=member) + response = client.get(url) + + count = response.data["count"] + + assert response.status_code == status.HTTP_200_OK + assert count == 5 + + +@pytest.mark.django_db +def test_list_codex_event_as_member(member): + """A member should not be able to list codex events""" + url = CODEX_EVENT_BASE_URL + client = get_api_client(user=member) + response = client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_retrieve_codex_event_as_member(member, codex_event): + """A member should not be able to retrieve a codex event""" + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + client = get_api_client(user=member) + response = client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_retrieve_codex_event_as_codex_member(member, codex_group, codex_event): + """A codex member should be able to retrieve a codex event""" + add_user_to_group_with_name(member, codex_group) + + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + client = get_api_client(user=member) + response = client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == codex_event.id + + +@pytest.mark.django_db +def test_create_codex_event_as_member(member): + """A member should not be able to create a codex event""" + url = CODEX_EVENT_BASE_URL + data = get_event_data() + client = get_api_client(user=member) + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_create_codex_event_as_codex_member(member, codex_group): + """A normal codex member should not be able to create a codex event""" + add_user_to_group_with_name(member, codex_group) + + url = CODEX_EVENT_BASE_URL + data = get_event_data(organizer=codex_group, lecturer=member.user_id) + client = get_api_client(user=member) + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_create_codex_event_as_codex_group_leader(member, codex_group): + """A codex group leader should be able to create a codex event""" + add_user_to_group_with_name( + member, codex_group, membership_type=MembershipType.LEADER + ) + + url = CODEX_EVENT_BASE_URL + data = get_event_data(organizer=codex_group, lecturer=member.user_id) + client = get_api_client(user=member) + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_create_codex_event_with_end_registration_before_start_registration( + member, codex_group +): + """A codex group leader should not be able to create a codex event with end registration before start registration""" + add_user_to_group_with_name( + member, codex_group, membership_type=MembershipType.LEADER + ) + + url = CODEX_EVENT_BASE_URL + data = get_event_data( + organizer=codex_group, + lecturer=member.user_id, + registration_start_at=timezone.now() + timedelta(days=10), + registration_end_at=timezone.now() + timedelta(days=9), + ) + client = get_api_client(user=member) + response = client.post(url, data=data) + print(response.data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_create_codex_event_with_end_registration_before_start_date( + member, codex_group +): + """A codex group leader should not be able to create a codex event with end registration before start date""" + add_user_to_group_with_name( + member, codex_group, membership_type=MembershipType.LEADER + ) + + url = CODEX_EVENT_BASE_URL + data = get_event_data( + organizer=codex_group, + lecturer=member.user_id, + start_date=timezone.now() + timedelta(days=10), + registration_start_at=timezone.now() + timedelta(days=9), + registration_end_at=timezone.now() + timedelta(days=8), + ) + client = get_api_client(user=member) + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_update_codex_event_as_member(member, codex_event): + """A member should not be able to update a codex event""" + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + data = get_event_data() + client = get_api_client(user=member) + response = client.put(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_update_codex_event_as_codex_member(member, codex_group, codex_event): + """A codex member should not be able to update a codex event""" + add_user_to_group_with_name(member, codex_group) + + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + data = get_event_data() + client = get_api_client(user=member) + response = client.put(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_update_codex_event_as_codex_group_leader(member, codex_group, codex_event): + """A codex group leader should be able to update a codex event""" + add_user_to_group_with_name( + member, codex_group, membership_type=MembershipType.LEADER + ) + + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + data = get_event_data() + client = get_api_client(user=member) + response = client.put(url, data=data) + + assert response.status_code == status.HTTP_200_OK + assert response.data["title"] == data["title"] + + +@pytest.mark.django_db +def test_destroy_codex_event_as_member(member, codex_event): + """A member should not be able to destroy a codex event""" + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + client = get_api_client(user=member) + response = client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_destroy_codex_event_as_codex_member(member, codex_group, codex_event): + """A codex member should not be able to destroy a codex event""" + add_user_to_group_with_name(member, codex_group) + + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + client = get_api_client(user=member) + response = client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_destroy_codex_event_as_codex_group_leader(member, codex_group, codex_event): + """A codex group leader should be able to destroy a codex event""" + add_user_to_group_with_name( + member, codex_group, membership_type=MembershipType.LEADER + ) + + url = f"{CODEX_EVENT_BASE_URL}{codex_event.id}/" + client = get_api_client(user=member) + response = client.delete(url) + + assert response.status_code == status.HTTP_200_OK diff --git a/app/tests/codex/test_codex_event_registration_integration.py b/app/tests/codex/test_codex_event_registration_integration.py new file mode 100644 index 00000000..f093dd95 --- /dev/null +++ b/app/tests/codex/test_codex_event_registration_integration.py @@ -0,0 +1,149 @@ +from rest_framework import status + +import pytest + +from app.codex.enums import CodexGroups +from app.common.enums import NativeMembershipType as MembershipType +from app.util.test_utils import add_user_to_group_with_name, get_api_client + +CODEX_EVENT_BASE_URL = "/codex/events/" + + +def get_registration_url(event_id): + return f"{CODEX_EVENT_BASE_URL}{event_id}/registrations/" + + +def get_registration_detail_url(event_id, registration_id): + return f"{CODEX_EVENT_BASE_URL}{event_id}/registrations/{registration_id}/" + + +def get_registration_data(event): + return { + "event": event.id, + } + + +@pytest.mark.django_db +def test_create_codex_event_registration_as_anonymous_user(codex_event): + """An anonymous user should not be able to create a registration for a event.""" + client = get_api_client() + + url = get_registration_url(codex_event.id) + data = get_registration_data(codex_event) + + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_create_codex_event_registration_as_authenticated_user(member, codex_event): + """An authenticated user should not be able to create a registration for a event.""" + client = get_api_client(member) + + url = get_registration_url(codex_event.id) + data = get_registration_data(codex_event) + + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_create_codex_event_registration_as_codex_member( + member, codex_group, codex_event +): + """A codex member should be able to create a registration for a event.""" + add_user_to_group_with_name(member, codex_group) + + client = get_api_client(member) + url = get_registration_url(codex_event.id) + data = get_registration_data(codex_event) + + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["user_info"]["user_id"] == member.user_id + assert response.data["order"] == 0 + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_create_codex_event_registration_as_codex_member_with_correct_order( + member, codex_group, codex_event, codex_event_registration +): + """A codex member should be able to create a registration and the order should be correct.""" + add_user_to_group_with_name(member, codex_group) + + codex_event_registration.event = codex_event + codex_event_registration.save() + + client = get_api_client(member) + url = get_registration_url(codex_event.id) + data = get_registration_data(codex_event) + + response = client.post(url, data=data) + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["user_info"]["user_id"] == member.user_id + assert response.data["order"] == codex_event_registration.order + 1 + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_delete_own_codex_event_registration_as_codex_member( + member, codex_group, codex_event_registration +): + """A codex member should be able to delete their own registration.""" + add_user_to_group_with_name(member, codex_group) + + codex_event_registration.user = member + codex_event_registration.save() + + client = get_api_client(member) + url = get_registration_detail_url( + codex_event_registration.event.id, codex_event_registration.registration_id + ) + + response = client.delete(url) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_delete_other_codex_event_registration_as_codex_member( + member, codex_group, codex_event_registration +): + """A codex member should not be able to delete another user's registration.""" + add_user_to_group_with_name(member, codex_group) + + client = get_api_client(member) + url = get_registration_detail_url( + codex_event_registration.event.id, codex_event_registration.registration_id + ) + + response = client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize("codex_group", CodexGroups.all()) +def test_delete_other_codex_event_registration_as_codex_group_leader( + member, codex_group, codex_event_registration +): + """A codex group leader should be able to delete another user's registration.""" + add_user_to_group_with_name( + member, codex_group, membership_type=MembershipType.LEADER + ) + + client = get_api_client(member) + url = get_registration_detail_url( + codex_event_registration.event.id, codex_event_registration.registration_id + ) + + response = client.delete(url) + + assert response.status_code == status.HTTP_200_OK diff --git a/app/tests/communication/test_user_notification_setting_integration.py b/app/tests/communication/test_user_notification_setting_integration.py index e43253f3..f05e7278 100644 --- a/app/tests/communication/test_user_notification_setting_integration.py +++ b/app/tests/communication/test_user_notification_setting_integration.py @@ -13,9 +13,11 @@ def _get_user_notification_setting_post_data(user_notification_setting=None): "email": True, "website": True, "slack": True, - "notification_type": user_notification_setting.notification_type - if user_notification_setting - else UserNotificationSettingType.EVENT_SIGN_OFF_DEADLINE, + "notification_type": ( + user_notification_setting.notification_type + if user_notification_setting + else UserNotificationSettingType.EVENT_SIGN_OFF_DEADLINE + ), } diff --git a/app/tests/conftest.py b/app/tests/conftest.py index e948c716..e2efd030 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -5,6 +5,7 @@ from app.badge.factories import BadgeFactory, UserBadgeFactory from app.career.factories import WeeklyBusinessFactory +from app.codex.factories import CodexEventFactory, CodexEventRegistrationFactory from app.common.enums import AdminGroup, Groups from app.common.enums import NativeMembershipType as MembershipType from app.communication.factories import ( @@ -300,3 +301,13 @@ def user_bio(): @pytest.fixture() def minute(user): return MinuteFactory(author=user) + + +@pytest.fixture() +def codex_event(): + return CodexEventFactory() + + +@pytest.fixture() +def codex_event_registration(): + return CodexEventRegistrationFactory() diff --git a/app/tests/content/test_minute_integration.py b/app/tests/content/test_minute_integration.py index 3a6c9844..e78e5159 100644 --- a/app/tests/content/test_minute_integration.py +++ b/app/tests/content/test_minute_integration.py @@ -2,7 +2,7 @@ import pytest -from app.common.enums import CodexGroups +from app.codex.enums import CodexGroups from app.content.factories import MinuteFactory from app.group.models import Group from app.util.test_utils import add_user_to_group_with_name, get_api_client diff --git a/app/tests/content/test_user_integration.py b/app/tests/content/test_user_integration.py index d5292545..30e34da3 100644 --- a/app/tests/content/test_user_integration.py +++ b/app/tests/content/test_user_integration.py @@ -374,6 +374,7 @@ def test_update_other_user_as_index_user(member, user, api_client): assert user.last_name == data["last_name"] +@pytest.mark.skip(reason="Must be refactored") def test_create_as_anonymous(default_client): """An anonymous user should be able to create a new user.""" data = _get_user_post_data() @@ -382,6 +383,7 @@ def test_create_as_anonymous(default_client): assert response.status_code == status.HTTP_201_CREATED +@pytest.mark.skip(reason="Must be refactored") def test_create_correctly_assigns_fields(api_client): client = api_client() data = _get_user_post_data() @@ -395,6 +397,7 @@ def test_create_correctly_assigns_fields(api_client): assert user.last_name == data["last_name"] +@pytest.mark.skip(reason="Must be refactored") def test_create_adds_user_to_class_group(api_client, dataing, group2019): data = _get_user_post_data() response = api_client().post(API_USER_BASE_URL, data) @@ -404,6 +407,7 @@ def test_create_adds_user_to_class_group(api_client, dataing, group2019): assert dataing.members.filter(user_id=user_id).exists() +@pytest.mark.skip(reason="Must be refactored") def test_create_adds_user_to_study_group(api_client, dataing, group2019): data = _get_user_post_data() response = api_client().post(API_USER_BASE_URL, data) @@ -413,6 +417,7 @@ def test_create_adds_user_to_study_group(api_client, dataing, group2019): assert group2019.members.filter(user_id=user_id).exists() +@pytest.mark.skip(reason="Must be refactored") def test_that_user_can_be_created_without_any_groups(api_client): data = _get_user_post_data() data["study"] = None @@ -423,6 +428,7 @@ def test_that_user_can_be_created_without_any_groups(api_client): assert response.status_code == status.HTTP_201_CREATED +@pytest.mark.skip(reason="Must be refactored") def test_create_duplicate_user(default_client): """ An anonymous user should not be able to create a new user diff --git a/app/urls.py b/app/urls.py index b23b8ddf..d4fb286b 100644 --- a/app/urls.py +++ b/app/urls.py @@ -54,4 +54,5 @@ path("badges/", include("app.badge.urls")), path("kontres/", include("app.kontres.urls")), path("emojis/", include("app.emoji.urls")), + path("codex/", include("app.codex.urls")), ] From 7d5c0f105ef6b3abe4255bff42767bb7ccb3d683 Mon Sep 17 00:00:00 2001 From: Mads Nylund Date: Wed, 25 Sep 2024 13:30:38 +0200 Subject: [PATCH 46/59] fixed filtering of groups and made tests --- app/group/filters/group.py | 6 ++-- app/tests/groups/test_group_integration.py | 35 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/group/filters/group.py b/app/group/filters/group.py index fd3a7904..2164e5c7 100644 --- a/app/group/filters/group.py +++ b/app/group/filters/group.py @@ -15,11 +15,9 @@ class Meta: fields = ["type", "overview"] def filter_type(self, queryset, _, value): - """Django Rest does not know hot to convert incoming string values into EnumChoiceField values and we must do this manually.""" - mapped = list(GroupType[v] for v in value) - return queryset.filter(type__in=mapped) + return queryset.filter(type__in=value) - def filter_overview(self, queryset, _): + def filter_overview(self, queryset, *_): if is_admin_user(self.request): return queryset return queryset.filter(type__in=GroupType.public_groups()) diff --git a/app/tests/groups/test_group_integration.py b/app/tests/groups/test_group_integration.py index 96675cf9..b7be6840 100644 --- a/app/tests/groups/test_group_integration.py +++ b/app/tests/groups/test_group_integration.py @@ -4,6 +4,7 @@ from app.common.enums import AdminGroup from app.common.enums import NativeGroupType as GroupType +from app.group.factories import GroupFactory from app.util.test_utils import get_api_client GROUP_URL = "/groups/" @@ -13,6 +14,14 @@ def _get_group_url(group=None): return f"{GROUP_URL}{group.slug}/" if (group) else f"{GROUP_URL}" +def _get_overview_group_url(): + return f"{GROUP_URL}?overview=true" + + +def _get_group_type_filtered_list(slug: str): + return f"{GROUP_URL}?type={slug}" + + def _get_group_post_data(group): return { "name": group.name, @@ -38,6 +47,32 @@ def test_list_as_anonymous_user(default_client): assert response.status_code == status.HTTP_200_OK +@pytest.mark.django_db +def test_list_overview_of_groups_as_member(member): + """Tests if a member can list an overview of groups""" + + client = get_api_client(user=member) + url = _get_overview_group_url() + response = client.get(url) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_list_type_of_groups_as_member(member): + """Tests if a member can list a type of groups""" + + GroupFactory(type=GroupType.BOARD) + GroupFactory(type=GroupType.COMMITTEE) + + client = get_api_client(user=member) + url = _get_group_type_filtered_list(GroupType.BOARD.value) + response = client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + + @pytest.mark.django_db def test_retrieve_as_anonymous_user(default_client, group): """Tests if an anonymous user can retrieve a group""" From edb007f07ad2e7c3d1d4e2d32f12dc38b3a8caf1 Mon Sep 17 00:00:00 2001 From: Mads Nylund Date: Wed, 25 Sep 2024 14:17:48 +0200 Subject: [PATCH 47/59] fixed list endpoint for cheatsheets --- app/common/enums.py | 18 ++++++++++++++++++ app/content/views/cheatsheet.py | 6 ++++-- .../content/test_cheatsheet_integration.py | 5 +++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/common/enums.py b/app/common/enums.py index 10d63dcf..01c61e2a 100644 --- a/app/common/enums.py +++ b/app/common/enums.py @@ -31,6 +31,24 @@ def get_user_class_number(user_class: NativeUserClass) -> int: return int(_class.split(".")[0]) +def get_user_class_name(user_class: int) -> NativeUserClass: + match user_class: + case 1: + return NativeUserClass.FIRST + case 2: + return NativeUserClass.SECOND + case 3: + return NativeUserClass.THIRD + case 4: + return NativeUserClass.FOURTH + case 5: + return NativeUserClass.FIFTH + case 6: + return NativeUserClass.ALUMNI + case _: + raise ValueError("Invalid user class") + + # This can't be removed because it is used in the migrations. It is not used in the code class UserStudy(ChoiceEnum): DATAING = "Dataingeniør" diff --git a/app/content/views/cheatsheet.py b/app/content/views/cheatsheet.py index 8037f48b..56304dd5 100644 --- a/app/content/views/cheatsheet.py +++ b/app/content/views/cheatsheet.py @@ -4,7 +4,8 @@ from sentry_sdk import capture_exception -from app.common.enums import UserClass, UserStudy +from app.common.enums import NativeUserStudy as UserStudy +from app.common.enums import get_user_class_name from app.common.pagination import BasePagination from app.common.permissions import BasicViewPermission, is_admin_user from app.common.viewsets import BaseViewSet @@ -24,8 +25,9 @@ class CheatsheetViewSet(BaseViewSet): def get_object(self): if "pk" not in self.kwargs: + grade = get_user_class_name(int(self.kwargs["grade"])) return self.filter_queryset(self.queryset).filter( - grade=UserClass(int(self.kwargs["grade"])), + grade=grade, study=UserStudy[self.kwargs["study"]], ) diff --git a/app/tests/content/test_cheatsheet_integration.py b/app/tests/content/test_cheatsheet_integration.py index a148e10d..7e08d6f6 100644 --- a/app/tests/content/test_cheatsheet_integration.py +++ b/app/tests/content/test_cheatsheet_integration.py @@ -47,16 +47,17 @@ def test_list_as_anonymous_user(default_client, cheatsheet): @pytest.mark.django_db -def test_list_as_member(cheatsheet, member): +def test_list_cheatsheets_as_member(cheatsheet, member): """ A member of TIHLDE should be able to list all cheatsheets. """ client = get_api_client(user=member) url = _get_cheatsheet_url(cheatsheet) response = client.get(url) + data = response.data assert response.status_code == status.HTTP_200_OK - assert len(response.json()) + assert len(data.get("results")) @pytest.mark.django_db From 9236863c2be471e081367edd9bcbf3f1d1f354c0 Mon Sep 17 00:00:00 2001 From: Mads Nylund Date: Wed, 25 Sep 2024 14:27:08 +0200 Subject: [PATCH 48/59] trigger From 3b9004e2d6e78626017d4a248890bb97ee0178b9 Mon Sep 17 00:00:00 2001 From: Mads Nylund Date: Wed, 25 Sep 2024 14:29:04 +0200 Subject: [PATCH 49/59] format --- app/common/enums.py | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/app/common/enums.py b/app/common/enums.py index 01c61e2a..68afaa78 100644 --- a/app/common/enums.py +++ b/app/common/enums.py @@ -31,22 +31,19 @@ def get_user_class_number(user_class: NativeUserClass) -> int: return int(_class.split(".")[0]) -def get_user_class_name(user_class: int) -> NativeUserClass: - match user_class: - case 1: - return NativeUserClass.FIRST - case 2: - return NativeUserClass.SECOND - case 3: - return NativeUserClass.THIRD - case 4: - return NativeUserClass.FOURTH - case 5: - return NativeUserClass.FIFTH - case 6: - return NativeUserClass.ALUMNI - case _: - raise ValueError("Invalid user class") +def get_user_class_name(user_class: int): + if user_class == 1: + return NativeUserClass.FIRST + elif user_class == 2: + return NativeUserClass.SECOND + elif user_class == 3: + return NativeUserClass.THIRD + elif user_class == 4: + return NativeUserClass.FOURTH + elif user_class == 5: + return NativeUserClass.FIFTH + else: + return NativeUserClass.ALUMNI # This can't be removed because it is used in the migrations. It is not used in the code From 527d0c24c68ecffd2aac68c9633436e2ffd07cfd Mon Sep 17 00:00:00 2001 From: Emil Johnsen <111747340+1Cezzo@users.noreply.github.com> Date: Thu, 26 Sep 2024 16:01:27 +0200 Subject: [PATCH 50/59] Endpoint for sending email (#883) * fix formatting * created tests for send_email endpoint * Fix code scanning alert no. 45: Information exposure through an exception Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fixed errors in send_email and in tests * lint * added tests for empty lists and for sending mail to multiple users --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- app/communication/enums.py | 6 + ...ernotificationsetting_notification_type.py | 37 +++ app/communication/tests/test_send_email.py | 216 ++++++++++++++++++ app/content/urls.py | 2 + app/content/views/__init__.py | 1 + app/content/views/send_email.py | 84 +++++++ 6 files changed, 346 insertions(+) create mode 100644 app/communication/migrations/0011_alter_usernotificationsetting_notification_type.py create mode 100644 app/communication/tests/test_send_email.py create mode 100644 app/content/views/send_email.py diff --git a/app/communication/enums.py b/app/communication/enums.py index d9c6fc60..f664be7f 100644 --- a/app/communication/enums.py +++ b/app/communication/enums.py @@ -18,3 +18,9 @@ class UserNotificationSettingType(models.TextChoices): RESERVATION_NEW = "RESERVATION NEW", "Ny reservasjon" RESERVATION_APPROVED = "RESERVATION APPROVED", "Godkjent reservasjon" RESERVATION_CANCELLED = "RESERVATION CANCELLED", "Avslått reservasjon" + KONTRES = "KONTRES", "Kontres" + BLITZED = "BLITZED", "Blitzed" + + @classmethod + def get_kontres_and_blitzed(cls): + return [cls.KONTRES, cls.BLITZED] diff --git a/app/communication/migrations/0011_alter_usernotificationsetting_notification_type.py b/app/communication/migrations/0011_alter_usernotificationsetting_notification_type.py new file mode 100644 index 00000000..b368b134 --- /dev/null +++ b/app/communication/migrations/0011_alter_usernotificationsetting_notification_type.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.5 on 2024-09-23 18:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("communication", "0010_alter_usernotificationsetting_notification_type"), + ] + + operations = [ + migrations.AlterField( + model_name="usernotificationsetting", + name="notification_type", + field=models.CharField( + choices=[ + ("REGISTRATION", "Påmeldingsoppdateringer"), + ("UNREGISTRATION", "Avmeldingsoppdateringer"), + ("STRIKE", "Prikkoppdateringer"), + ("EVENT_SIGN_UP_START", "Arrangementer - påmeldingsstart"), + ("EVENT_SIGN_OFF_DEADLINE", "Arrangementer - avmeldingsfrist"), + ("EVENT_EVALUATION", "Arrangementer - evaluering"), + ("EVENT_INFO", "Arrangementer - info fra arrangør"), + ("FINE", "Grupper - bot"), + ("GROUP_MEMBERSHIP", "Grupper - medlemsskap"), + ("OTHER", "Andre"), + ("RESERVATION NEW", "Ny reservasjon"), + ("RESERVATION APPROVED", "Godkjent reservasjon"), + ("RESERVATION CANCELLED", "Avslått reservasjon"), + ("KONTRES", "Kontres"), + ("BLITZED", "Blitzed"), + ], + max_length=30, + ), + ), + ] diff --git a/app/communication/tests/test_send_email.py b/app/communication/tests/test_send_email.py new file mode 100644 index 00000000..eef51ca4 --- /dev/null +++ b/app/communication/tests/test_send_email.py @@ -0,0 +1,216 @@ +import os +from unittest.mock import patch + +from rest_framework import status + +import pytest + +from app.communication.enums import UserNotificationSettingType +from app.communication.notifier import Notify +from app.content.factories import UserFactory +from app.util.test_utils import get_api_client + +EMAIL_URL = "/send-email/" +EMAIL_API_KEY = os.environ.get("EMAIL_API_KEY") + + +def _get_email_url(): + return f"{EMAIL_URL}" + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_success(mock_send): + """ + Test that the send_email endpoint sends an email successfully. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_201_CREATED + mock_send.assert_called_once() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_field_missing(mock_send): + """ + Test that the send_email endpoint returns 400 when one of the fields is missing. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + mock_send.assert_not_called() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_wrong_api_key(mock_send): + """ + Test that the send_email endpoint returns 403 when the API key is invalid. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": "wrong_key"} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_send.assert_not_called() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_user_id_invalid(mock_send): + """ + Test that the send_email endpoint returns 404 when the user id is invalid. + """ + test_user = UserFactory() + + data = { + "user_id_list": [999], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_404_NOT_FOUND + mock_send.assert_not_called() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +@pytest.mark.parametrize( + "notification_type", UserNotificationSettingType.get_kontres_and_blitzed() +) +def test_email_success_with_kontres_and_blitzed(mock_send, notification_type): + """ + Tests that the send_email endpoint works with both KONTRES and BLITZED notification types. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "notification_type": notification_type, + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_201_CREATED + mock_send.assert_called_once() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_success_with_user_id_list(mock_send): + """ + Test that the send_email endpoint sends an email successfully to a list of users. + """ + test_user1 = UserFactory() + test_user2 = UserFactory() + test_user3 = UserFactory() + + data = { + "user_id_list": [ + test_user.user_id for test_user in [test_user1, test_user2, test_user3] + ], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user1) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_201_CREATED + mock_send.assert_called_once() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_user_id_list_is_empty(mock_send): + """ + Test that the send_email endpoint returns 400 when the user id list is empty. + """ + test_user = UserFactory() + + data = { + "user_id_list": [], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": ["This is a test paragraph.", "This is another paragraph."], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + mock_send.assert_not_called() + + +@pytest.mark.django_db +@patch.object(Notify, "send", return_value=None) +def test_send_email_fails_when_user_paragraph_list_is_empty(mock_send): + """ + Test that the send_email endpoint returns 400 when the paragraph list is empty. + """ + test_user = UserFactory() + + data = { + "user_id_list": [test_user.user_id], + "notification_type": "KONTRES", + "title": "Test Notification", + "paragraphs": [], + } + + client = get_api_client(user=test_user) + url = _get_email_url() + headers = {"api_key": EMAIL_API_KEY} + response = client.post(url, data, format="json", **headers) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + mock_send.assert_not_called() diff --git a/app/content/urls.py b/app/content/urls.py index c5a48239..26911fd6 100644 --- a/app/content/urls.py +++ b/app/content/urls.py @@ -20,6 +20,7 @@ accept_form, delete, register_with_feide, + send_email, upload, ) @@ -53,6 +54,7 @@ re_path(r"", include(router.urls)), path("accept-form/", accept_form), path("upload/", upload), + path("send-email/", send_email), path("delete-file///", delete), path("feide/", register_with_feide), re_path(r"users/(?P[^/.]+)/events.ics", UserCalendarEvents()), diff --git a/app/content/views/__init__.py b/app/content/views/__init__.py index 3392d438..5a9a5c3f 100644 --- a/app/content/views/__init__.py +++ b/app/content/views/__init__.py @@ -16,3 +16,4 @@ from app.content.views.logentry import LogEntryViewSet from app.content.views.minute import MinuteViewSet from app.content.views.feide import register_with_feide +from app.content.views.send_email import send_email diff --git a/app/content/views/send_email.py b/app/content/views/send_email.py new file mode 100644 index 00000000..2619632d --- /dev/null +++ b/app/content/views/send_email.py @@ -0,0 +1,84 @@ +import os + +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from app.communication.enums import UserNotificationSettingType +from app.communication.notifier import Notify +from app.content.models import User + + +@api_view(["POST"]) +def send_email(request): + """ + Endpoint for sending a notification and email to a user. + + Body should contain: + - 'user_id_list': A list of user ids to send the email to. + - 'notification_type': KONTRES or BLITZED. + - 'title': The title of the notification. + - 'paragraphs': A list of paragraphs to include in the notification. + + The header should contain: + - 'api_key': A key for validating access. + + """ + try: + EMAIL_API_KEY = os.environ.get("EMAIL_API_KEY") + api_key = request.META.get("api_key") + if api_key != EMAIL_API_KEY: + return Response( + {"detail": "Feil API nøkkel"}, + status=status.HTTP_403_FORBIDDEN, + ) + + user_id_list = request.data.get("user_id_list") + paragraphs = request.data.get("paragraphs") + title = request.data.get("title") + notification_type = request.data.get("notification_type") + + if not isinstance(user_id_list, list) or not user_id_list: + return Response( + {"detail": "En ikke-tom liste med bruker id-er må inkluderes"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not isinstance(paragraphs, list) or not paragraphs: + return Response( + {"detail": "En ikke-tom liste med paragrafer må inkluderes"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not notification_type or not title: + return Response( + { + "detail": "Notifikasjonstype (KONTRES/BLITZED) og tittel må være satt" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + users = list(User.objects.filter(user_id__in=user_id_list)) + if not users or len(users) != len(user_id_list): + return Response( + {"detail": "En eller flere brukere ble ikke funnet"}, + status=status.HTTP_404_NOT_FOUND, + ) + + email = Notify( + users, + f"{title}", + UserNotificationSettingType(notification_type), + ) + + for paragraph in paragraphs: + email.add_paragraph(paragraph) + + email.send() + return Response({"detail": "Emailen ble sendt"}, status=status.HTTP_201_CREATED) + except Exception as e: + print(e) + return Response( + {"detail": "Det oppsto en feil under sending av email"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) From d0a0c07e27d60ba530f78f24bd2f779c1f8ad77a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 12:04:33 +0200 Subject: [PATCH 51/59] chore(deps): bump black from 24.3.0 to 24.8.0 (#869) Bumps [black](https://github.com/psf/black) from 24.3.0 to 24.8.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/24.3.0...24.8.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f62db6e7..592bc138 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,7 +39,7 @@ django-mptt == 0.16.0 # Code quality # ------------------------------------------------------------------------------ pylint -black == 24.3.0 +black == 24.8.0 isort flake8 flake8-django From 81f7c7e4b25ee1e8715c980f38f8eb68bb9c0627 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 13:23:10 +0200 Subject: [PATCH 52/59] chore(deps): Bump azure-storage-blob from 12.13.1 to 12.23.1 (#885) Bumps [azure-storage-blob](https://github.com/Azure/azure-sdk-for-python) from 12.13.1 to 12.23.1. - [Release notes](https://github.com/Azure/azure-sdk-for-python/releases) - [Changelog](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/esrp_release.md) - [Commits](https://github.com/Azure/azure-sdk-for-python/compare/azure-storage-blob_12.13.1...azure-storage-blob_12.23.1) --- updated-dependencies: - dependency-name: azure-storage-blob dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 592bc138..3b5b1c14 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ wheel mysqlclient == 2.1.1 sentry-sdk == 2.8.0 celery == 5.4.0 -azure-storage-blob == 12.13.1 +azure-storage-blob == 12.23.1 python-dotenv ~= 1.0.1 gunicorn == 23.0.0 uvicorn == 0.30.6 From 019d2734ddcc1ff92eef8d3cbf182217d0b977f0 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Tue, 1 Oct 2024 08:32:39 +0200 Subject: [PATCH 53/59] Added admin.py to root in new app, and added app dir to tests (#892) added admin.py to root in new app, and added app dir to tests --- scripts/app.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/app.py b/scripts/app.py index 0987e993..48532246 100644 --- a/scripts/app.py +++ b/scripts/app.py @@ -20,7 +20,6 @@ def create_app(): os.makedirs(app_path, exist_ok=True) # Create the app's directories - init_dir(app_path, "admin") init_dir(app_path, "factories") init_dir(app_path, "filters") init_dir(app_path, "migrations") @@ -31,8 +30,19 @@ def create_app(): init_dir(app_path, "views") init_dir(app_path, "serializers") + # create tests directory + TESTS_PATH = os.path.join(BASE_PATH, "tests") + + if app_name in os.listdir(TESTS_PATH): + print(f"App '{app_name}' already exists in 'tests'.") + else: + path = os.path.join(TESTS_PATH, app_name) + os.makedirs(os.path.join(TESTS_PATH, app_name), exist_ok=True) + init_app_file(path, "__init__.py") + # Create the app's files init_app_file(app_path, "__init__.py") + init_app_file(app_path, "admin.py") config_content = f"""from django.apps import AppConfig From 14edd062de3503230714822994de8746a5176909 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Tue, 1 Oct 2024 09:28:35 +0200 Subject: [PATCH 54/59] Description to forms (#894) added description to form --- app/forms/migrations/0014_form_description.py | 18 ++++++++++++++++++ app/forms/models/forms.py | 1 + app/forms/serializers/forms.py | 1 + 3 files changed, 20 insertions(+) create mode 100644 app/forms/migrations/0014_form_description.py diff --git a/app/forms/migrations/0014_form_description.py b/app/forms/migrations/0014_form_description.py new file mode 100644 index 00000000..3cecfc4a --- /dev/null +++ b/app/forms/migrations/0014_form_description.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-10-01 06:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("forms", "0013_alter_field_type"), + ] + + operations = [ + migrations.AddField( + model_name="form", + name="description", + field=models.TextField(blank=True, default=""), + ), + ] diff --git a/app/forms/models/forms.py b/app/forms/models/forms.py index 08e2ff97..642654ec 100644 --- a/app/forms/models/forms.py +++ b/app/forms/models/forms.py @@ -24,6 +24,7 @@ class Form(PolymorphicModel, BasePermissionModel): write_access = (*AdminGroup.admin(), AdminGroup.NOK) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) title = models.CharField(max_length=400) + description = models.TextField(blank=True, default="") template = models.BooleanField(default=False) viewer_has_answered = None diff --git a/app/forms/serializers/forms.py b/app/forms/serializers/forms.py index 8d88a2fb..8944a112 100644 --- a/app/forms/serializers/forms.py +++ b/app/forms/serializers/forms.py @@ -51,6 +51,7 @@ class Meta: fields = ( "id", "title", + "description", "fields", "template", "resource_type", From 1a49b08572b3088247117a070fdddca8da638bf8 Mon Sep 17 00:00:00 2001 From: Josefine Arntsen <128137082+josefinearntsen@users.noreply.github.com> Date: Fri, 11 Oct 2024 10:13:10 +0200 Subject: [PATCH 55/59] Bug report system (#865) * Created New App named Index * created model * Refactor: Change admin file * admin * added serializer and viewsets for list * fixed typing error for permission_classes * Started on the create serializer for feedback * Made tests for create feedback * Implemented update serializer for feedback * made destroy method and testing create method as member * Made destroy method and tests * Fixed linting * Fixed linting --------- Co-authored-by: Tam Le Co-authored-by: Josefine Arntsen Co-authored-by: Mads Nylund Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> --- CHANGELOG.md | 2 +- app/content/serializers/user.py | 1 + app/index/__init__.py | 0 app/index/admin/__init__.py | 0 app/index/admin/admin.py | 5 + app/index/app.py | 5 + app/index/enums.py | 8 + app/index/exceptions.py | 0 app/index/factories/__init__.py | 2 + app/index/factories/bug_factory.py | 13 ++ app/index/factories/idea_factory.py | 13 ++ app/index/filters/__init__.py | 0 app/index/migrations/0001_initial.py | 112 ++++++++++++ app/index/migrations/__init__.py | 0 app/index/mixins.py | 0 app/index/models/__init__.py | 3 + app/index/models/bug.py | 5 + app/index/models/feedback.py | 76 ++++++++ app/index/models/idea.py | 5 + app/index/serializers/__init__.py | 3 + app/index/serializers/bug.py | 17 ++ app/index/serializers/feedback.py | 80 +++++++++ app/index/serializers/idea.py | 17 ++ app/index/tasks/__init__.py | 0 app/index/tests/__init__.py | 0 app/index/urls.py | 8 + app/index/util/__init__.py | 0 app/index/views/__init__.py | 0 app/index/views/feedback.py | 81 +++++++++ app/settings.py | 1 + app/tests/conftest.py | 11 ++ app/tests/index/__init__.py | 0 app/tests/index/test_feedback_integration.py | 174 +++++++++++++++++++ app/urls.py | 1 + 34 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 app/index/__init__.py create mode 100644 app/index/admin/__init__.py create mode 100644 app/index/admin/admin.py create mode 100644 app/index/app.py create mode 100644 app/index/enums.py create mode 100644 app/index/exceptions.py create mode 100644 app/index/factories/__init__.py create mode 100644 app/index/factories/bug_factory.py create mode 100644 app/index/factories/idea_factory.py create mode 100644 app/index/filters/__init__.py create mode 100644 app/index/migrations/0001_initial.py create mode 100644 app/index/migrations/__init__.py create mode 100644 app/index/mixins.py create mode 100644 app/index/models/__init__.py create mode 100644 app/index/models/bug.py create mode 100644 app/index/models/feedback.py create mode 100644 app/index/models/idea.py create mode 100644 app/index/serializers/__init__.py create mode 100644 app/index/serializers/bug.py create mode 100644 app/index/serializers/feedback.py create mode 100644 app/index/serializers/idea.py create mode 100644 app/index/tasks/__init__.py create mode 100644 app/index/tests/__init__.py create mode 100644 app/index/urls.py create mode 100644 app/index/util/__init__.py create mode 100644 app/index/views/__init__.py create mode 100644 app/index/views/feedback.py create mode 100644 app/tests/index/__init__.py create mode 100644 app/tests/index/test_feedback_integration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e14785..8c92abd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ --- ## Neste versjon - +- ✨**Tilbakemelding-funksjon**. Man kan nå opprette tilbakemeldinger for bugs og idé. ## Versjon 2024.09.25 - ✨**Codex arrangementer**. Det kan nå opprettes arrangementer på Codex, som medlemmer av Codex kan melde seg på. diff --git a/app/content/serializers/user.py b/app/content/serializers/user.py index 2378c562..9150dc28 100644 --- a/app/content/serializers/user.py +++ b/app/content/serializers/user.py @@ -127,6 +127,7 @@ class Meta: "user_id", "first_name", "last_name", + "image", ) diff --git a/app/index/__init__.py b/app/index/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/index/admin/__init__.py b/app/index/admin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/index/admin/admin.py b/app/index/admin/admin.py new file mode 100644 index 00000000..ebcfc4b4 --- /dev/null +++ b/app/index/admin/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from app.index import models + +admin.site.register(models.Bug) diff --git a/app/index/app.py b/app/index/app.py new file mode 100644 index 00000000..fa3e063e --- /dev/null +++ b/app/index/app.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class IndexConfig(AppConfig): + name = "app.index" diff --git a/app/index/enums.py b/app/index/enums.py new file mode 100644 index 00000000..5adae939 --- /dev/null +++ b/app/index/enums.py @@ -0,0 +1,8 @@ +from django.db import models + + +class Status(models.TextChoices): + OPEN = "OPEN", "Åpen" + CLOSED = "CLOSED", "Lukket" + IN_PROGRESS = "IN_PROGRESS", "Under arbeid" + REJECTED = "REJECTED", "Avvist" diff --git a/app/index/exceptions.py b/app/index/exceptions.py new file mode 100644 index 00000000..e69de29b diff --git a/app/index/factories/__init__.py b/app/index/factories/__init__.py new file mode 100644 index 00000000..e4c63edf --- /dev/null +++ b/app/index/factories/__init__.py @@ -0,0 +1,2 @@ +from app.index.factories.bug_factory import BugFactory +from app.index.factories.idea_factory import IdeaFactory diff --git a/app/index/factories/bug_factory.py b/app/index/factories/bug_factory.py new file mode 100644 index 00000000..cd84aa3f --- /dev/null +++ b/app/index/factories/bug_factory.py @@ -0,0 +1,13 @@ +import factory +from factory.django import DjangoModelFactory + +from app.content.factories import UserFactory +from app.index.models.bug import Bug + + +class BugFactory(DjangoModelFactory): + class Meta: + model = Bug + + title = factory.Sequence(lambda n: f"Bug{n}") + author = factory.SubFactory(UserFactory) diff --git a/app/index/factories/idea_factory.py b/app/index/factories/idea_factory.py new file mode 100644 index 00000000..64320770 --- /dev/null +++ b/app/index/factories/idea_factory.py @@ -0,0 +1,13 @@ +import factory +from factory.django import DjangoModelFactory + +from app.content.factories import UserFactory +from app.index.models.idea import Idea + + +class IdeaFactory(DjangoModelFactory): + class Meta: + model = Idea + + title = factory.Sequence(lambda n: f"Idea{n}") + author = factory.SubFactory(UserFactory) diff --git a/app/index/filters/__init__.py b/app/index/filters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/index/migrations/0001_initial.py b/app/index/migrations/0001_initial.py new file mode 100644 index 00000000..fb78c027 --- /dev/null +++ b/app/index/migrations/0001_initial.py @@ -0,0 +1,112 @@ +# Generated by Django 4.2.5 on 2024-09-26 14:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("contenttypes", "0002_remove_content_type_name"), + ] + + operations = [ + migrations.CreateModel( + name="Feedback", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=100)), + ("description", models.TextField(blank=True, default="")), + ( + "status", + models.CharField( + default="OPEN", + max_length=20, + verbose_name=[ + ("OPEN", "Åpen"), + ("CLOSED", "Lukket"), + ("IN_PROGRESS", "Under arbeid"), + ("REJECTED", "Avvist"), + ], + ), + ), + ( + "author", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "polymorphic_ctype", + models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="polymorphic_%(app_label)s.%(class)s_set+", + to="contenttypes.contenttype", + ), + ), + ], + options={ + "ordering": ("created_at",), + }, + ), + migrations.CreateModel( + name="Bug", + fields=[ + ( + "feedback_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="index.feedback", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("index.feedback",), + ), + migrations.CreateModel( + name="Idea", + fields=[ + ( + "feedback_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="index.feedback", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("index.feedback",), + ), + ] diff --git a/app/index/migrations/__init__.py b/app/index/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/index/mixins.py b/app/index/mixins.py new file mode 100644 index 00000000..e69de29b diff --git a/app/index/models/__init__.py b/app/index/models/__init__.py new file mode 100644 index 00000000..8f1a9108 --- /dev/null +++ b/app/index/models/__init__.py @@ -0,0 +1,3 @@ +from app.index.models.bug import Bug +from app.index.models.feedback import Feedback +from app.index.models.idea import Idea diff --git a/app/index/models/bug.py b/app/index/models/bug.py new file mode 100644 index 00000000..2ba1c0b7 --- /dev/null +++ b/app/index/models/bug.py @@ -0,0 +1,5 @@ +from app.index.models.feedback import Feedback + + +class Bug(Feedback): + pass diff --git a/app/index/models/feedback.py b/app/index/models/feedback.py new file mode 100644 index 00000000..f91157c8 --- /dev/null +++ b/app/index/models/feedback.py @@ -0,0 +1,76 @@ +from django.db import models + +from polymorphic.models import PolymorphicModel + +from app.common.enums import AdminGroup, Groups +from app.common.permissions import BasePermissionModel, check_has_access +from app.content.models.user import User +from app.index.enums import Status +from app.util.models import BaseModel + + +class Feedback(BaseModel, BasePermissionModel, PolymorphicModel): + + read_access = (Groups.TIHLDE,) + write_access = (Groups.TIHLDE,) + + title = models.CharField(max_length=100) + description = models.TextField(default="", blank=True) + + author = models.ForeignKey( + User, blank=True, null=True, default=None, on_delete=models.SET_NULL + ) + status = models.CharField(Status.choices, default=Status.OPEN, max_length=20) + + def __str__(self): + return f"{self.title} - {self.status}" + + class Meta: + ordering = ("created_at",) + + @classmethod + def has_read_permission(cls, request): + return super().has_read_permission(request) + + @classmethod + def has_write_permission(cls, request): + return super().has_write_permission(request) + + @classmethod + def has_retrieve_permission(cls, request): + return cls.has_read_permission(request) + + @classmethod + def has_create_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_update_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_destroy_permission(cls, request): + return cls.has_write_permission(request) + + @classmethod + def has_list_permission(cls, request): + return cls.has_read_permission(request) + + def has_object_read_permission(self, request): + return self.has_read_permission(request) + + def has_object_write_permission(self, request): + return self.has_write_permission(request) + + def has_object_retrieve_permission(self, request): + return self.has_object_read_permission(request) + + def has_object_update_permission(self, request): + return ( + check_has_access([AdminGroup.INDEX], request) or self.author == request.user + ) + + def has_object_destroy_permission(self, request): + return ( + check_has_access([AdminGroup.INDEX], request) or self.author == request.user + ) diff --git a/app/index/models/idea.py b/app/index/models/idea.py new file mode 100644 index 00000000..1a504a7d --- /dev/null +++ b/app/index/models/idea.py @@ -0,0 +1,5 @@ +from app.index.models.feedback import Feedback + + +class Idea(Feedback): + pass diff --git a/app/index/serializers/__init__.py b/app/index/serializers/__init__.py new file mode 100644 index 00000000..6c05e1f4 --- /dev/null +++ b/app/index/serializers/__init__.py @@ -0,0 +1,3 @@ +from app.index.serializers.bug import BugListSerializer +from app.index.serializers.idea import IdeaListSerializer +from app.index.serializers.feedback import FeedbackListPolymorphicSerializer diff --git a/app/index/serializers/bug.py b/app/index/serializers/bug.py new file mode 100644 index 00000000..9d2ab12a --- /dev/null +++ b/app/index/serializers/bug.py @@ -0,0 +1,17 @@ +from app.common.serializers import BaseModelSerializer +from app.content.serializers.user import SimpleUserSerializer +from app.index.models.bug import Bug + + +class BugListSerializer(BaseModelSerializer): + author = SimpleUserSerializer(read_only=True) + + class Meta: + model = Bug + fields = ( + "id", + "title", + "status", + "created_at", + "author", + ) diff --git a/app/index/serializers/feedback.py b/app/index/serializers/feedback.py new file mode 100644 index 00000000..df512adb --- /dev/null +++ b/app/index/serializers/feedback.py @@ -0,0 +1,80 @@ +from rest_polymorphic.serializers import PolymorphicSerializer + +from app.common.serializers import BaseModelSerializer +from app.index.models import Bug, Feedback, Idea +from app.index.serializers import BugListSerializer, IdeaListSerializer + + +class FeedbackListPolymorphicSerializer(PolymorphicSerializer, BaseModelSerializer): + resource_type_field_name = "feedback_type" + + model_serializer_mapping = { + Bug: BugListSerializer, + Idea: IdeaListSerializer, + } + + class Meta: + model = Feedback + fields = ( + "id", + "title", + "status", + "created_at", + "author", + ) + + +class IdeaCreateSerializer(BaseModelSerializer): + class Meta: + model = Idea + fields = ( + "title", + "description", + ) + + def create(self, validated_data): + user = self.context["request"].user + validated_data["author"] = user + + return super().create(validated_data) + + +class BugCreateSerializer(BaseModelSerializer): + class Meta: + model = Bug + fields = ( + "title", + "description", + ) + + def create(self, validated_data): + user = self.context["request"].user + validated_data["author"] = user + + return super().create(validated_data) + + +class IdeaUpdateSerializer(BaseModelSerializer): + class Meta: + model = Feedback + fields = ( + "title", + "description", + "status", + ) + + def update(self, instance, validated_data): + return super().update(instance, validated_data) + + +class BugUpdateSerializer(BaseModelSerializer): + class Meta: + model = Feedback + fields = ( + "title", + "description", + "status", + ) + + def update(self, instance, validated_data): + return super().update(instance, validated_data) diff --git a/app/index/serializers/idea.py b/app/index/serializers/idea.py new file mode 100644 index 00000000..344087f6 --- /dev/null +++ b/app/index/serializers/idea.py @@ -0,0 +1,17 @@ +from app.common.serializers import BaseModelSerializer +from app.content.serializers.user import SimpleUserSerializer +from app.index.models.idea import Idea + + +class IdeaListSerializer(BaseModelSerializer): + author = SimpleUserSerializer(read_only=True) + + class Meta: + model = Idea + fields = ( + "id", + "title", + "status", + "created_at", + "author", + ) diff --git a/app/index/tasks/__init__.py b/app/index/tasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/index/tests/__init__.py b/app/index/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/index/urls.py b/app/index/urls.py new file mode 100644 index 00000000..17a8202e --- /dev/null +++ b/app/index/urls.py @@ -0,0 +1,8 @@ +from django.urls import include, re_path +from rest_framework import routers + +from app.index.views.feedback import FeedbackViewSet + +router = routers.DefaultRouter() +router.register("feedbacks", FeedbackViewSet) +urlpatterns = [re_path(r"", include(router.urls))] diff --git a/app/index/util/__init__.py b/app/index/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/index/views/__init__.py b/app/index/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/index/views/feedback.py b/app/index/views/feedback.py new file mode 100644 index 00000000..dcf5a2bd --- /dev/null +++ b/app/index/views/feedback.py @@ -0,0 +1,81 @@ +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.index.models.feedback import Feedback +from app.index.serializers.feedback import ( + BugCreateSerializer, + BugUpdateSerializer, + FeedbackListPolymorphicSerializer, + IdeaCreateSerializer, + IdeaUpdateSerializer, +) + + +class FeedbackViewSet(BaseViewSet): + serializer_class = FeedbackListPolymorphicSerializer + queryset = Feedback.objects.select_related("author") + pagination_class = BasePagination + permission_classes = [BasicViewPermission] + + def create(self, request, *_args, **_kwargs): + data = request.data + + feedback_type = data.get("feedback_type") + + if feedback_type == "Idea": + serializer = IdeaCreateSerializer(data=data, context={"request": request}) + + elif feedback_type == "Bug": + serializer = BugCreateSerializer(data=data, context={"request": request}) + + else: + return Response( + {"detail": "Ugyldig type tilbakemelding"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if serializer.is_valid(): + feedback = self.perform_create(serializer) + data = FeedbackListPolymorphicSerializer(feedback).data + return Response( + data, + status=status.HTTP_201_CREATED, + ) + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + + def update(self, request, *_args, **_kwargs): + instance = self.get_object() + data = request.data + + feedback_type = instance.feedback_type + + if feedback_type == "Idea": + serializer = IdeaUpdateSerializer(instance, data=data) + + elif feedback_type == "Bug": + serializer = BugUpdateSerializer(instance, data=data) + + if serializer.is_valid(): + super().perform_update(serializer) + return Response( + serializer.data, + status=status.HTTP_200_OK, + ) + + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + + def destroy(self, request, *_args, **_kwargs): + super().destroy(request, *_args, **_kwargs) + return Response( + {"detail": "Tilbakemeldingen ble slettet"}, + status=status.HTTP_200_OK, + ) diff --git a/app/settings.py b/app/settings.py index d7a79e4d..90c264b6 100644 --- a/app/settings.py +++ b/app/settings.py @@ -106,6 +106,7 @@ "app.payment", "app.kontres", "app.emoji", + "app.index", "app.codex", ] diff --git a/app/tests/conftest.py b/app/tests/conftest.py index e2efd030..1473ca03 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -36,6 +36,7 @@ from app.group.factories import GroupFactory, MembershipFactory from app.group.factories.fine_factory import FineFactory from app.group.factories.membership_factory import MembershipHistoryFactory +from app.index.factories import BugFactory, IdeaFactory from app.kontres.factories import BookableItemFactory, ReservationFactory from app.payment.factories.order_factory import OrderFactory from app.payment.factories.paid_event_factory import PaidEventFactory @@ -311,3 +312,13 @@ def codex_event(): @pytest.fixture() def codex_event_registration(): return CodexEventRegistrationFactory() + + +@pytest.fixture() +def feedback_bug(): + return BugFactory() + + +@pytest.fixture() +def feedback_idea(): + return IdeaFactory() diff --git a/app/tests/index/__init__.py b/app/tests/index/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/tests/index/test_feedback_integration.py b/app/tests/index/test_feedback_integration.py new file mode 100644 index 00000000..6535abc5 --- /dev/null +++ b/app/tests/index/test_feedback_integration.py @@ -0,0 +1,174 @@ +from rest_framework import status + +import pytest + +from app.util.test_utils import get_api_client + +FEEDBACK_BASE_URL = "/index/feedbacks/" + + +def get_data(type): + return { + "feedback_type": type, + "title": "This is a type title", + "description": f"This is a {type} report", + } + + +@pytest.mark.django_db +def test_list_feedback_with_both_bug_and_idea_as_member( + member, feedback_bug, feedback_idea +): + """All members should be able to list all types of feedbacks.""" + + url = FEEDBACK_BASE_URL + client = get_api_client(member) + response = client.get(url) + + data = response.data + results = data["results"] + bug_type = list(filter(lambda x: "Bug" == x["feedback_type"], results)) + idea_type = list(filter(lambda x: "Idea" == x["feedback_type"], results)) + + assert response.status_code == status.HTTP_200_OK + assert data["count"] == 2 + assert bug_type + assert idea_type + + +@pytest.mark.django_db +def test_list_feedback_as_anonymous_user(default_client): + """Non TIHLDE users should not be able to list feedbacks""" + + url = FEEDBACK_BASE_URL + response = default_client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "type", + ["Bug", "Idea"], +) +def test_create_feedback_with_both_bug_and_idea_as_member(member, type): + """All members should be able to create a bug and a idea feedback""" + + url = FEEDBACK_BASE_URL + client = get_api_client(member) + data = get_data(type) + response = client.post(url, data=data) + data = response.data + + assert response.status_code == status.HTTP_201_CREATED + assert data["feedback_type"] == type + + +@pytest.mark.django_db +def test_create_feedback_with_wrong_type_as_member(member): + """No members should be able to create feedback of another type than bug and idea""" + + url = FEEDBACK_BASE_URL + client = get_api_client(member) + data = get_data("wrong_type") + response = client.post(url, data=data) + data = response.data + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "type", + ["Bug", "Idea"], +) +def test_create_feedback_as_anonymous_user(default_client, type): + """Non TIHLDE users should not be able to create feedbacks""" + + url = FEEDBACK_BASE_URL + data = get_data(type) + response = default_client.post(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "type", + ["Bug", "Idea"], +) +def test_update_feedback_with_both_bug_and_idea_as_member(member, type): + """All members should be able to update their own bug and idea feedback""" + + url = FEEDBACK_BASE_URL + data = get_data(type) + client = get_api_client(member) + response = client.post(url, data=data) + data = response.data + + assert response.status_code == status.HTTP_201_CREATED + assert data["feedback_type"] == type + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "type", + ["Bug", "Idea"], +) +def test_destroy_feedback_as_index_user(index_member, type): + """An Index user should be able to delete other members feedback""" + + url = FEEDBACK_BASE_URL + data = get_data(type) + client = get_api_client(user=index_member) + + initial_response = client.post(url, data=data) + + feedback_id = initial_response.data["id"] + + url = f"{FEEDBACK_BASE_URL}{feedback_id}/" + + response = client.delete(url) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "type", + ["Bug", "Idea"], +) +def test_destroy_your_own_feedback_as_member(member, type): + """A user should be able to delete their own feedback as a member""" + + url = FEEDBACK_BASE_URL + data = get_data(type) + client = get_api_client(user=member) + + initial_response = client.post(url, data=data) + + print(initial_response.data) + print(f"Author: {initial_response.data['author']}") + + feedback_id = initial_response.data["id"] + + url = f"{FEEDBACK_BASE_URL}{feedback_id}/" + + response = client.delete(url) + + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "type", + ["Bug", "Idea"], +) +def test_destroy_feedback_as_anonymous_user(default_client, type): + """Non TIHLDE users should not be able to delete feedbacks""" + + url = FEEDBACK_BASE_URL + data = get_data(type) + response = default_client.delete(url, data=data) + + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/app/urls.py b/app/urls.py index d4fb286b..eabb182f 100644 --- a/app/urls.py +++ b/app/urls.py @@ -55,4 +55,5 @@ path("kontres/", include("app.kontres.urls")), path("emojis/", include("app.emoji.urls")), path("codex/", include("app.codex.urls")), + path("index/", include("app.index.urls")), ] From 13938858c4e3b4c4e632b569e4be8face7145b19 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Fri, 11 Oct 2024 18:31:17 +0200 Subject: [PATCH 56/59] Renaming of index app to feedback (#901) renamed index app to feedback --- app/{index => feedback}/__init__.py | 0 app/{index/admin => feedback}/admin.py | 2 +- app/feedback/app.py | 5 +++++ app/{index => feedback}/enums.py | 0 app/{index => feedback}/exceptions.py | 0 app/feedback/factories/__init__.py | 2 ++ app/{index => feedback}/factories/bug_factory.py | 2 +- app/{index => feedback}/factories/idea_factory.py | 2 +- app/{index/admin => feedback/filters}/__init__.py | 0 app/{index => feedback}/migrations/0001_initial.py | 10 +++++----- app/{index/filters => feedback/migrations}/__init__.py | 0 app/{index => feedback}/mixins.py | 0 app/feedback/models/__init__.py | 3 +++ app/feedback/models/bug.py | 5 +++++ app/{index => feedback}/models/feedback.py | 2 +- app/feedback/models/idea.py | 5 +++++ app/feedback/serializers/__init__.py | 3 +++ app/{index => feedback}/serializers/bug.py | 2 +- app/{index => feedback}/serializers/feedback.py | 4 ++-- app/{index => feedback}/serializers/idea.py | 2 +- app/{index/migrations => feedback/tasks}/__init__.py | 0 app/{index/tasks => feedback/tests}/__init__.py | 0 app/{index => feedback}/urls.py | 2 +- app/{index/tests => feedback/util}/__init__.py | 0 app/{index/util => feedback/views}/__init__.py | 0 app/{index => feedback}/views/feedback.py | 4 ++-- app/index/app.py | 5 ----- app/index/factories/__init__.py | 2 -- app/index/models/__init__.py | 3 --- app/index/models/bug.py | 5 ----- app/index/models/idea.py | 5 ----- app/index/serializers/__init__.py | 3 --- app/index/views/__init__.py | 0 app/settings.py | 2 +- app/tests/conftest.py | 2 +- app/tests/index/test_feedback_integration.py | 2 +- app/urls.py | 2 +- 37 files changed, 43 insertions(+), 43 deletions(-) rename app/{index => feedback}/__init__.py (100%) rename app/{index/admin => feedback}/admin.py (67%) create mode 100644 app/feedback/app.py rename app/{index => feedback}/enums.py (100%) rename app/{index => feedback}/exceptions.py (100%) create mode 100644 app/feedback/factories/__init__.py rename app/{index => feedback}/factories/bug_factory.py (87%) rename app/{index => feedback}/factories/idea_factory.py (87%) rename app/{index/admin => feedback/filters}/__init__.py (100%) rename app/{index => feedback}/migrations/0001_initial.py (93%) rename app/{index/filters => feedback/migrations}/__init__.py (100%) rename app/{index => feedback}/mixins.py (100%) create mode 100644 app/feedback/models/__init__.py create mode 100644 app/feedback/models/bug.py rename app/{index => feedback}/models/feedback.py (98%) create mode 100644 app/feedback/models/idea.py create mode 100644 app/feedback/serializers/__init__.py rename app/{index => feedback}/serializers/bug.py (90%) rename app/{index => feedback}/serializers/feedback.py (93%) rename app/{index => feedback}/serializers/idea.py (90%) rename app/{index/migrations => feedback/tasks}/__init__.py (100%) rename app/{index/tasks => feedback/tests}/__init__.py (100%) rename app/{index => feedback}/urls.py (78%) rename app/{index/tests => feedback/util}/__init__.py (100%) rename app/{index/util => feedback/views}/__init__.py (100%) rename app/{index => feedback}/views/feedback.py (96%) delete mode 100644 app/index/app.py delete mode 100644 app/index/factories/__init__.py delete mode 100644 app/index/models/__init__.py delete mode 100644 app/index/models/bug.py delete mode 100644 app/index/models/idea.py delete mode 100644 app/index/serializers/__init__.py delete mode 100644 app/index/views/__init__.py diff --git a/app/index/__init__.py b/app/feedback/__init__.py similarity index 100% rename from app/index/__init__.py rename to app/feedback/__init__.py diff --git a/app/index/admin/admin.py b/app/feedback/admin.py similarity index 67% rename from app/index/admin/admin.py rename to app/feedback/admin.py index ebcfc4b4..3f450ab7 100644 --- a/app/index/admin/admin.py +++ b/app/feedback/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from app.index import models +from app.feedback import models admin.site.register(models.Bug) diff --git a/app/feedback/app.py b/app/feedback/app.py new file mode 100644 index 00000000..c310f0d3 --- /dev/null +++ b/app/feedback/app.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FeedbackConfig(AppConfig): + name = "app.feedback" diff --git a/app/index/enums.py b/app/feedback/enums.py similarity index 100% rename from app/index/enums.py rename to app/feedback/enums.py diff --git a/app/index/exceptions.py b/app/feedback/exceptions.py similarity index 100% rename from app/index/exceptions.py rename to app/feedback/exceptions.py diff --git a/app/feedback/factories/__init__.py b/app/feedback/factories/__init__.py new file mode 100644 index 00000000..6dba0161 --- /dev/null +++ b/app/feedback/factories/__init__.py @@ -0,0 +1,2 @@ +from app.feedback.factories.bug_factory import BugFactory +from app.feedback.factories.idea_factory import IdeaFactory diff --git a/app/index/factories/bug_factory.py b/app/feedback/factories/bug_factory.py similarity index 87% rename from app/index/factories/bug_factory.py rename to app/feedback/factories/bug_factory.py index cd84aa3f..4ff95a21 100644 --- a/app/index/factories/bug_factory.py +++ b/app/feedback/factories/bug_factory.py @@ -2,7 +2,7 @@ from factory.django import DjangoModelFactory from app.content.factories import UserFactory -from app.index.models.bug import Bug +from app.feedback.models.bug import Bug class BugFactory(DjangoModelFactory): diff --git a/app/index/factories/idea_factory.py b/app/feedback/factories/idea_factory.py similarity index 87% rename from app/index/factories/idea_factory.py rename to app/feedback/factories/idea_factory.py index 64320770..c923c471 100644 --- a/app/index/factories/idea_factory.py +++ b/app/feedback/factories/idea_factory.py @@ -2,7 +2,7 @@ from factory.django import DjangoModelFactory from app.content.factories import UserFactory -from app.index.models.idea import Idea +from app.feedback.models.idea import Idea class IdeaFactory(DjangoModelFactory): diff --git a/app/index/admin/__init__.py b/app/feedback/filters/__init__.py similarity index 100% rename from app/index/admin/__init__.py rename to app/feedback/filters/__init__.py diff --git a/app/index/migrations/0001_initial.py b/app/feedback/migrations/0001_initial.py similarity index 93% rename from app/index/migrations/0001_initial.py rename to app/feedback/migrations/0001_initial.py index fb78c027..ff17edca 100644 --- a/app/index/migrations/0001_initial.py +++ b/app/feedback/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.5 on 2024-09-26 14:54 +# Generated by Django 4.2.16 on 2024-10-11 15:33 from django.conf import settings from django.db import migrations, models @@ -80,14 +80,14 @@ class Migration(migrations.Migration): parent_link=True, primary_key=True, serialize=False, - to="index.feedback", + to="feedback.feedback", ), ), ], options={ "abstract": False, }, - bases=("index.feedback",), + bases=("feedback.feedback",), ), migrations.CreateModel( name="Idea", @@ -100,13 +100,13 @@ class Migration(migrations.Migration): parent_link=True, primary_key=True, serialize=False, - to="index.feedback", + to="feedback.feedback", ), ), ], options={ "abstract": False, }, - bases=("index.feedback",), + bases=("feedback.feedback",), ), ] diff --git a/app/index/filters/__init__.py b/app/feedback/migrations/__init__.py similarity index 100% rename from app/index/filters/__init__.py rename to app/feedback/migrations/__init__.py diff --git a/app/index/mixins.py b/app/feedback/mixins.py similarity index 100% rename from app/index/mixins.py rename to app/feedback/mixins.py diff --git a/app/feedback/models/__init__.py b/app/feedback/models/__init__.py new file mode 100644 index 00000000..604302af --- /dev/null +++ b/app/feedback/models/__init__.py @@ -0,0 +1,3 @@ +from app.feedback.models.bug import Bug +from app.feedback.models.feedback import Feedback +from app.feedback.models.idea import Idea diff --git a/app/feedback/models/bug.py b/app/feedback/models/bug.py new file mode 100644 index 00000000..39962659 --- /dev/null +++ b/app/feedback/models/bug.py @@ -0,0 +1,5 @@ +from app.feedback.models.feedback import Feedback + + +class Bug(Feedback): + pass diff --git a/app/index/models/feedback.py b/app/feedback/models/feedback.py similarity index 98% rename from app/index/models/feedback.py rename to app/feedback/models/feedback.py index f91157c8..13d698db 100644 --- a/app/index/models/feedback.py +++ b/app/feedback/models/feedback.py @@ -5,7 +5,7 @@ from app.common.enums import AdminGroup, Groups from app.common.permissions import BasePermissionModel, check_has_access from app.content.models.user import User -from app.index.enums import Status +from app.feedback.enums import Status from app.util.models import BaseModel diff --git a/app/feedback/models/idea.py b/app/feedback/models/idea.py new file mode 100644 index 00000000..42ecb651 --- /dev/null +++ b/app/feedback/models/idea.py @@ -0,0 +1,5 @@ +from app.feedback.models.feedback import Feedback + + +class Idea(Feedback): + pass diff --git a/app/feedback/serializers/__init__.py b/app/feedback/serializers/__init__.py new file mode 100644 index 00000000..25f74a27 --- /dev/null +++ b/app/feedback/serializers/__init__.py @@ -0,0 +1,3 @@ +from app.feedback.serializers.bug import BugListSerializer +from app.feedback.serializers.idea import IdeaListSerializer +from app.feedback.serializers.feedback import FeedbackListPolymorphicSerializer diff --git a/app/index/serializers/bug.py b/app/feedback/serializers/bug.py similarity index 90% rename from app/index/serializers/bug.py rename to app/feedback/serializers/bug.py index 9d2ab12a..85046256 100644 --- a/app/index/serializers/bug.py +++ b/app/feedback/serializers/bug.py @@ -1,6 +1,6 @@ from app.common.serializers import BaseModelSerializer from app.content.serializers.user import SimpleUserSerializer -from app.index.models.bug import Bug +from app.feedback.models.bug import Bug class BugListSerializer(BaseModelSerializer): diff --git a/app/index/serializers/feedback.py b/app/feedback/serializers/feedback.py similarity index 93% rename from app/index/serializers/feedback.py rename to app/feedback/serializers/feedback.py index df512adb..588fc680 100644 --- a/app/index/serializers/feedback.py +++ b/app/feedback/serializers/feedback.py @@ -1,8 +1,8 @@ from rest_polymorphic.serializers import PolymorphicSerializer from app.common.serializers import BaseModelSerializer -from app.index.models import Bug, Feedback, Idea -from app.index.serializers import BugListSerializer, IdeaListSerializer +from app.feedback.models import Bug, Feedback, Idea +from app.feedback.serializers import BugListSerializer, IdeaListSerializer class FeedbackListPolymorphicSerializer(PolymorphicSerializer, BaseModelSerializer): diff --git a/app/index/serializers/idea.py b/app/feedback/serializers/idea.py similarity index 90% rename from app/index/serializers/idea.py rename to app/feedback/serializers/idea.py index 344087f6..6b415eba 100644 --- a/app/index/serializers/idea.py +++ b/app/feedback/serializers/idea.py @@ -1,6 +1,6 @@ from app.common.serializers import BaseModelSerializer from app.content.serializers.user import SimpleUserSerializer -from app.index.models.idea import Idea +from app.feedback.models.idea import Idea class IdeaListSerializer(BaseModelSerializer): diff --git a/app/index/migrations/__init__.py b/app/feedback/tasks/__init__.py similarity index 100% rename from app/index/migrations/__init__.py rename to app/feedback/tasks/__init__.py diff --git a/app/index/tasks/__init__.py b/app/feedback/tests/__init__.py similarity index 100% rename from app/index/tasks/__init__.py rename to app/feedback/tests/__init__.py diff --git a/app/index/urls.py b/app/feedback/urls.py similarity index 78% rename from app/index/urls.py rename to app/feedback/urls.py index 17a8202e..c8922e8b 100644 --- a/app/index/urls.py +++ b/app/feedback/urls.py @@ -1,7 +1,7 @@ from django.urls import include, re_path from rest_framework import routers -from app.index.views.feedback import FeedbackViewSet +from app.feedback.views.feedback import FeedbackViewSet router = routers.DefaultRouter() router.register("feedbacks", FeedbackViewSet) diff --git a/app/index/tests/__init__.py b/app/feedback/util/__init__.py similarity index 100% rename from app/index/tests/__init__.py rename to app/feedback/util/__init__.py diff --git a/app/index/util/__init__.py b/app/feedback/views/__init__.py similarity index 100% rename from app/index/util/__init__.py rename to app/feedback/views/__init__.py diff --git a/app/index/views/feedback.py b/app/feedback/views/feedback.py similarity index 96% rename from app/index/views/feedback.py rename to app/feedback/views/feedback.py index dcf5a2bd..0ccbc653 100644 --- a/app/index/views/feedback.py +++ b/app/feedback/views/feedback.py @@ -4,8 +4,8 @@ from app.common.pagination import BasePagination from app.common.permissions import BasicViewPermission from app.common.viewsets import BaseViewSet -from app.index.models.feedback import Feedback -from app.index.serializers.feedback import ( +from app.feedback.models.feedback import Feedback +from app.feedback.serializers.feedback import ( BugCreateSerializer, BugUpdateSerializer, FeedbackListPolymorphicSerializer, diff --git a/app/index/app.py b/app/index/app.py deleted file mode 100644 index fa3e063e..00000000 --- a/app/index/app.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class IndexConfig(AppConfig): - name = "app.index" diff --git a/app/index/factories/__init__.py b/app/index/factories/__init__.py deleted file mode 100644 index e4c63edf..00000000 --- a/app/index/factories/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from app.index.factories.bug_factory import BugFactory -from app.index.factories.idea_factory import IdeaFactory diff --git a/app/index/models/__init__.py b/app/index/models/__init__.py deleted file mode 100644 index 8f1a9108..00000000 --- a/app/index/models/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from app.index.models.bug import Bug -from app.index.models.feedback import Feedback -from app.index.models.idea import Idea diff --git a/app/index/models/bug.py b/app/index/models/bug.py deleted file mode 100644 index 2ba1c0b7..00000000 --- a/app/index/models/bug.py +++ /dev/null @@ -1,5 +0,0 @@ -from app.index.models.feedback import Feedback - - -class Bug(Feedback): - pass diff --git a/app/index/models/idea.py b/app/index/models/idea.py deleted file mode 100644 index 1a504a7d..00000000 --- a/app/index/models/idea.py +++ /dev/null @@ -1,5 +0,0 @@ -from app.index.models.feedback import Feedback - - -class Idea(Feedback): - pass diff --git a/app/index/serializers/__init__.py b/app/index/serializers/__init__.py deleted file mode 100644 index 6c05e1f4..00000000 --- a/app/index/serializers/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from app.index.serializers.bug import BugListSerializer -from app.index.serializers.idea import IdeaListSerializer -from app.index.serializers.feedback import FeedbackListPolymorphicSerializer diff --git a/app/index/views/__init__.py b/app/index/views/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/app/settings.py b/app/settings.py index 90c264b6..26e8c1fe 100644 --- a/app/settings.py +++ b/app/settings.py @@ -106,7 +106,7 @@ "app.payment", "app.kontres", "app.emoji", - "app.index", + "app.feedback", "app.codex", ] diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 1473ca03..37961992 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -32,11 +32,11 @@ EventReactionFactory, NewsReactionFactory, ) +from app.feedback.factories import BugFactory, IdeaFactory 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.index.factories import BugFactory, IdeaFactory from app.kontres.factories import BookableItemFactory, ReservationFactory from app.payment.factories.order_factory import OrderFactory from app.payment.factories.paid_event_factory import PaidEventFactory diff --git a/app/tests/index/test_feedback_integration.py b/app/tests/index/test_feedback_integration.py index 6535abc5..890c8ad4 100644 --- a/app/tests/index/test_feedback_integration.py +++ b/app/tests/index/test_feedback_integration.py @@ -4,7 +4,7 @@ from app.util.test_utils import get_api_client -FEEDBACK_BASE_URL = "/index/feedbacks/" +FEEDBACK_BASE_URL = "/feedbacks/" def get_data(type): diff --git a/app/urls.py b/app/urls.py index eabb182f..301f2342 100644 --- a/app/urls.py +++ b/app/urls.py @@ -55,5 +55,5 @@ path("kontres/", include("app.kontres.urls")), path("emojis/", include("app.emoji.urls")), path("codex/", include("app.codex.urls")), - path("index/", include("app.index.urls")), + path("", include("app.feedback.urls")), ] From c44e9107baeb009620348eb4d6968c4f9314d8e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Oct 2024 20:23:53 +0200 Subject: [PATCH 57/59] chore(deps): bump django from 4.2.16 to 5.1.1 (#889) Bumps [django](https://github.com/django/django) from 4.2.16 to 5.1.1. - [Commits](https://github.com/django/django/compare/4.2.16...5.1.1) --- updated-dependencies: - dependency-name: django dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3b5b1c14..4f6210f3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ pyjwt ~= 2.9.0 # Django # ------------------------------------------------------------------------------ -Django == 4.2.16 +Django == 5.1.1 django-enumchoicefield == 3.0.1 django-filter == 24.3 django-ordered-model ~= 3.7.4 From 17b2483407537ed8ba161d0416155faa8f615287 Mon Sep 17 00:00:00 2001 From: Mads Nylund <73914541+MadsNyl@users.noreply.github.com> Date: Fri, 11 Oct 2024 20:27:31 +0200 Subject: [PATCH 58/59] Event registration race condition (#902) * started on fixing race condition * fixed race condition --- app/content/views/registration.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/content/views/registration.py b/app/content/views/registration.py index 2b5d680a..0aa13b3a 100644 --- a/app/content/views/registration.py +++ b/app/content/views/registration.py @@ -1,5 +1,6 @@ import uuid +from django.db.transaction import atomic from django.shortcuts import get_object_or_404 from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters, status @@ -48,6 +49,7 @@ def _is_own_registration(self): def _is_not_own_registration(self): return not self._is_own_registration() + @atomic def create(self, request, *args, **kwargs): """Register the current user for the given event.""" @@ -68,7 +70,7 @@ def create(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) event_id = self.kwargs.get("event_id", None) - event = Event.objects.get(pk=event_id) + event = Event.objects.select_for_update().get(pk=event_id) registration = super().perform_create( serializer, event=event, user=request.user From 119bbdba71323866c848bf926f4b30d2803ded0a Mon Sep 17 00:00:00 2001 From: Mads Nylund Date: Fri, 11 Oct 2024 20:29:30 +0200 Subject: [PATCH 59/59] upadted CHANGELOG.md --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c92abd3..ae1eaec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,12 @@ --- ## Neste versjon -- ✨**Tilbakemelding-funksjon**. Man kan nå opprette tilbakemeldinger for bugs og idé. -## Versjon 2024.09.25 +## Versjon 2024.10.11 +- ✨ **Tilbakemelding-funksjon**. Man kan nå opprette tilbakemeldinger for bugs og idé. +- 🦟 **Påmelding**. Det vil nå ikke være mulig med flere påmeldinger på et arrangement enn maksgrensen. + +## Versjon 2024.09.25 - ✨**Codex arrangementer**. Det kan nå opprettes arrangementer på Codex, som medlemmer av Codex kan melde seg på. - ⚡**Betalingsordre**. Man kan nå se historikk over betalingsordre for en påmelding til et arrangement. - ✨**Gruppe**. HS kan nå opprette en ny gruppe.