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