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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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,