From 8777743268868a6c446753f4ea1325cb612a63c1 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Mon, 13 Mar 2023 12:37:54 +0000 Subject: [PATCH 1/9] Write create response endpoint tests --- b2b/feedback/tests/conftest.py | 60 +++++++++++++++++++++- b2b/feedback/tests/test_feedback_api.py | 68 +++++++++++++++++++++++-- b2b/feedback/tests/test_models.py | 2 +- 3 files changed, 125 insertions(+), 5 deletions(-) diff --git a/b2b/feedback/tests/conftest.py b/b2b/feedback/tests/conftest.py index 5ca8ee1..8f24378 100644 --- a/b2b/feedback/tests/conftest.py +++ b/b2b/feedback/tests/conftest.py @@ -5,6 +5,8 @@ from feedback.models import CLIENT_REP_GROUP, SALES_MANAGER_GROUP from model_bakery import baker +from .test_feedback_api import QUESTIONNAIRES_URL + @pytest.fixture def client_payload(): @@ -62,7 +64,10 @@ def questionnaire_payload(client_rep): "question_text": "True or False?", "required": True, "order": 2, - "choices": [], + "choices": [ + {"value": "True", "order": 1}, + {"value": "False", "order": 2}, + ], }, { "question_type": "MULTIPLE_CHOICE", @@ -87,3 +92,56 @@ def questionnaire_payload(client_rep): }, ], } + + +@pytest.fixture +def response_payload(api_client, sales_manager, questionnaire_payload): + """Return a sample response payload.""" + # Create a questionnaire + api_client.force_authenticate(user=sales_manager) + response = api_client.post(QUESTIONNAIRES_URL, questionnaire_payload, format="json") + + data = response.data + questions = data["questions"] + question1 = questions[0] + question2 = questions[1] + question3 = questions[2] + question4 = questions[3] + + choice21 = question2["choices"][0]["id"] # id of question2 choice 1 + choice31 = question3["choices"][0]["id"] # id of question3 choice 1 + choice32 = question3["choices"][1]["id"] # id of question3 choice 2 + choice43 = question4["choices"][2]["id"] # id of question4 choice 3 + + return { + "questionnaire": data["id"], + "answers": [ + { + "question_id": question1["id"], + "answer_text": "My name is Solomon Botchway.", + "choices": [], + }, + { + "question_id": question2["id"], + "answer_text": "", + "choices": [ + {"question_choice_id": choice21}, + ], + }, + { + "question_id": question3["id"], + "answer_text": "", + "choices": [ + {"question_choice_id": choice31}, + {"question_choice_id": choice32}, + ], + }, + { + "question_id": question4["id"], + "answer_text": "", + "choices": [ + {"question_choice_id": choice43}, + ], + }, + ], + } diff --git a/b2b/feedback/tests/test_feedback_api.py b/b2b/feedback/tests/test_feedback_api.py index a2e556d..b3e558c 100644 --- a/b2b/feedback/tests/test_feedback_api.py +++ b/b2b/feedback/tests/test_feedback_api.py @@ -1,12 +1,27 @@ import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.urls import reverse -from feedback.models import Client, Questionnaire -from feedback.serializers import ClientSerializer, QuestionnaireSerializer +from feedback.models import ( + CLIENT_REP_GROUP, + AnswerChoice, + Client, + Questionnaire, + Response, +) +from feedback.serializers import ( + ClientSerializer, + QuestionnaireSerializer, + ResponseSerializer, +) from model_bakery import baker from rest_framework import status +User = get_user_model() + CLIENTS_URL = reverse("feedback:client-list") QUESTIONNAIRES_URL = reverse("feedback:questionnaire-list") +RESPONSES_URL = reverse("feedback:response-list") @pytest.mark.django_db @@ -120,7 +135,7 @@ def test_create_questionnaire_returns_201( serializer = QuestionnaireSerializer(questionnaire) assert response.data == serializer.data - # Assert the last two questions are false just like in the payload + # Assert that the last two questions are not required just like in the payload questions = questionnaire.questions.all() assert questions[0].required == questions[1].required is True assert questions[2].required == questions[3].required is False @@ -164,3 +179,50 @@ def test_non_client_rep_cannot_list_questionnaires(self, api_client, sample_user questionnaires = Questionnaire.objects.filter(client_rep=sample_user) serializer = QuestionnaireSerializer(questionnaires, many=True) assert serializer.data != response.data + + +@pytest.mark.django_db +class TestResponses: + """Tests on questionnaire management.""" + + def test_client_rep_create_response_returns_201( + self, api_client, client_rep, response_payload + ): + """Test creating a questionnaire is successful.""" + api_client.force_authenticate(user=client_rep) + + response = api_client.post(RESPONSES_URL, response_payload, format="json") + + assert response.status_code == status.HTTP_201_CREATED + responses = Response.objects.filter(respondent=response.data["respondent"]) + assert responses.count() == 1 + feedback_response = responses.first() + assert feedback_response.answers.count() == 4 + assert AnswerChoice.objects.count() == 4 + serializer = ResponseSerializer(feedback_response) + assert response.data == serializer.data + + def test_client_reps_cannot_respond_unassigned_questionnaires( + self, api_client, response_payload + ): + """Test client reps can't respond if they haven't been assigned.""" + user = baker.make(User) + client_reps = Group.objects.get(name=CLIENT_REP_GROUP) + user.groups.add(client_reps) + api_client.force_authenticate(user=user) + + response = api_client.post(RESPONSES_URL, response_payload, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert Response.objects.count() == 0 + + def test_anonymous_user_cannot_respond_to_questionnaires( + self, api_client, response_payload + ): + """Test anonymous users cannot respond to questionnaires.""" + api_client.logout() + + response = api_client.post(RESPONSES_URL, response_payload, format="json") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert Response.objects.count() == 0 diff --git a/b2b/feedback/tests/test_models.py b/b2b/feedback/tests/test_models.py index bbc785f..0f86f26 100644 --- a/b2b/feedback/tests/test_models.py +++ b/b2b/feedback/tests/test_models.py @@ -52,7 +52,7 @@ def test_answer_creation(self): response = baker.make(Response) answer = baker.make(Answer, question=question, response=response) assert isinstance(answer, Answer) - assert str(answer) == answer.answer_text + assert str(answer) == f"Answer to {question}" def test_answer_choice_creation(self): choice = baker.make(QuestionChoice) From cfd345de32666f1d56e8ef9e15da7d13e4e32895 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Mon, 13 Mar 2023 12:39:16 +0000 Subject: [PATCH 2/9] Implement create response endpoint --- .../0004_alter_answer_answer_text.py | 17 ++++ b2b/feedback/models.py | 4 +- b2b/feedback/serializers.py | 91 ++++++++++++++++++- b2b/feedback/urls.py | 3 +- b2b/feedback/views.py | 19 +++- 5 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 b2b/feedback/migrations/0004_alter_answer_answer_text.py diff --git a/b2b/feedback/migrations/0004_alter_answer_answer_text.py b/b2b/feedback/migrations/0004_alter_answer_answer_text.py new file mode 100644 index 0000000..969a8e3 --- /dev/null +++ b/b2b/feedback/migrations/0004_alter_answer_answer_text.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.7 on 2023-03-12 23:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("feedback", "0003_alter_client_email"), + ] + + operations = [ + migrations.AlterField( + model_name="answer", + name="answer_text", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/b2b/feedback/models.py b/b2b/feedback/models.py index 4001f4c..9a866d2 100644 --- a/b2b/feedback/models.py +++ b/b2b/feedback/models.py @@ -120,11 +120,11 @@ class Answer(models.Model): Response, on_delete=models.CASCADE, related_name="answers" ) question = models.ForeignKey(Question, on_delete=models.CASCADE) - answer_text = models.TextField() + answer_text = models.TextField(null=True, blank=True) def __str__(self): """Return the answer_text.""" - return self.answer_text + return f"Answer to {self.question}" class AnswerChoice(models.Model): diff --git a/b2b/feedback/serializers.py b/b2b/feedback/serializers.py index 1cab19d..cd98cca 100644 --- a/b2b/feedback/serializers.py +++ b/b2b/feedback/serializers.py @@ -1,8 +1,17 @@ """Serializers for the feedback app.""" from django.db import transaction +from django.shortcuts import get_object_or_404 from rest_framework import serializers -from .models import Client, Question, QuestionChoice, Questionnaire +from .models import ( + Answer, + AnswerChoice, + Client, + Question, + QuestionChoice, + Questionnaire, + Response, +) class ClientSerializer(serializers.ModelSerializer): @@ -99,3 +108,83 @@ def validate_questions(self, value): if not value: raise serializers.ValidationError("At least one question is required.") return value + + +class AnswerChoiceSerializer(serializers.ModelSerializer): + """The answer choice serializer.""" + + question_choice_id = serializers.IntegerField() + + class Meta: + """Answer choice serializer meta class.""" + + model = AnswerChoice + fields = [ + "id", + "question_choice_id", + ] + + +class AnswerSerializer(serializers.ModelSerializer): + """The answer serializer.""" + + choices = AnswerChoiceSerializer(many=True, required=False) + question_id = serializers.IntegerField() + + class Meta: + """Answer serializer meta class.""" + + model = Answer + fields = [ + "id", + "answer_text", + "question_id", + "choices", + ] + + +class ResponseSerializer(serializers.ModelSerializer): + """The response serializer.""" + + answers = AnswerSerializer(many=True, required=True) + + class Meta: + """Response serializer meta class.""" + + model = Response + fields = [ + "id", + "respondent", + "questionnaire", + "submitted_at", + "answers", + ] + read_only_fields = [ + "respondent", + ] + + def create(self, validated_data): + """Create a response.""" + with transaction.atomic(): + answers = validated_data.pop("answers", []) + response = Response.objects.create(**validated_data) + + for answer in answers: + choices = answer.pop("choices", []) + answer = Answer.objects.create(**answer, response=response) + choice_objs = [ + AnswerChoice(**choice, answer=answer) for choice in choices + ] + AnswerChoice.objects.bulk_create(choice_objs) + + return response + + def validate_questionnaire(self, value): + """Validate that current user is assigned to the questionnaire.""" + user = self.context["request"].user + questionnaire = get_object_or_404(Questionnaire, pk=value.id) + if questionnaire.client_rep != user: + raise serializers.ValidationError( + "This questionnaire is not assigned to you." + ) + return value diff --git a/b2b/feedback/urls.py b/b2b/feedback/urls.py index d41142b..d473d43 100644 --- a/b2b/feedback/urls.py +++ b/b2b/feedback/urls.py @@ -1,11 +1,12 @@ """Feedback app url configuration.""" from rest_framework.routers import DefaultRouter -from .views import ClientViewSet, QuestionnaireViewSet +from .views import ClientViewSet, QuestionnaireViewSet, ResponseViewSet router = DefaultRouter() router.register("clients", ClientViewSet) router.register("questionnaires", QuestionnaireViewSet) +router.register("responses", ResponseViewSet) app_name = "feedback" diff --git a/b2b/feedback/views.py b/b2b/feedback/views.py index 82235af..3de4e50 100644 --- a/b2b/feedback/views.py +++ b/b2b/feedback/views.py @@ -2,9 +2,9 @@ from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin from rest_framework.viewsets import GenericViewSet -from .models import Client, Questionnaire +from .models import Client, Questionnaire, Response from .permissions import IsClientRepresentative, IsSalesManager -from .serializers import ClientSerializer, QuestionnaireSerializer +from .serializers import ClientSerializer, QuestionnaireSerializer, ResponseSerializer class ClientViewSet( @@ -55,3 +55,18 @@ def get_permissions(self): if self._is_list_action(): return [IsClientRepresentative()] return [IsSalesManager()] + + +class ResponseViewSet( + CreateModelMixin, + GenericViewSet, +): + """The Response viewset.""" + + queryset = Response.objects.all() + serializer_class = ResponseSerializer + permission_classes = [IsClientRepresentative] + + def perform_create(self, serializer): + """Attach current user as the respondent.""" + return serializer.save(respondent=self.request.user) From bdd60ecb86163ec00255fb4d9714cd79f1142602 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Mon, 13 Mar 2023 12:40:11 +0000 Subject: [PATCH 3/9] Configure ResponseAdmin panel --- b2b/feedback/admin.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/b2b/feedback/admin.py b/b2b/feedback/admin.py index c6dd895..7ffcf84 100644 --- a/b2b/feedback/admin.py +++ b/b2b/feedback/admin.py @@ -2,7 +2,15 @@ from django.contrib import admin from nested_admin.nested import NestedModelAdmin, NestedStackedInline -from .models import Client, Question, QuestionChoice, Questionnaire +from .models import ( + Answer, + AnswerChoice, + Client, + Question, + QuestionChoice, + Questionnaire, + Response, +) @admin.register(Client) @@ -41,3 +49,28 @@ class QuestionnaireAdmin(NestedModelAdmin): list_editable = ["due_at"] list_select_related = ["client_rep"] search_fields = ["title__icontains", "description__icontains"] + + +class AnswerChoiceInline(NestedStackedInline): + """Inline class for answer choices.""" + + model = AnswerChoice + extra = 0 + + +class AnswerInline(NestedStackedInline): + """Inline class for response answers.""" + + model = Answer + inlines = [AnswerChoiceInline] + extra = 0 + + +@admin.register(Response) +class ResponseAdmin(NestedModelAdmin): + """Define admin configuration for the Questionnaire model.""" + + autocomplete_fields = ["questionnaire", "respondent"] + inlines = [AnswerInline] + list_select_related = ["questionnaire", "respondent"] + search_fields = ["questionnaire__title", "respondent__email"] From 775eb747dcafbb00727a49ad93615b89d0e0bdc2 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Mon, 13 Mar 2023 15:04:41 +0000 Subject: [PATCH 4/9] Update create response tests for incoming change --- b2b/feedback/tests/conftest.py | 10 ++++++++++ b2b/feedback/tests/test_feedback_api.py | 21 +++++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/b2b/feedback/tests/conftest.py b/b2b/feedback/tests/conftest.py index 8f24378..6ccc9c2 100644 --- a/b2b/feedback/tests/conftest.py +++ b/b2b/feedback/tests/conftest.py @@ -43,6 +43,16 @@ def _client_detail_url(client_id): return _client_detail_url +@pytest.fixture +def response_list_url(): + """Return a questionnaire's response list url.""" + + def _get_url(questionnaire_id): + return reverse("feedback:questionnaire-responses-list", args=[questionnaire_id]) + + return _get_url + + @pytest.fixture def questionnaire_payload(client_rep): """Return a sample questionnaire.""" diff --git a/b2b/feedback/tests/test_feedback_api.py b/b2b/feedback/tests/test_feedback_api.py index b3e558c..5e01c39 100644 --- a/b2b/feedback/tests/test_feedback_api.py +++ b/b2b/feedback/tests/test_feedback_api.py @@ -21,7 +21,6 @@ CLIENTS_URL = reverse("feedback:client-list") QUESTIONNAIRES_URL = reverse("feedback:questionnaire-list") -RESPONSES_URL = reverse("feedback:response-list") @pytest.mark.django_db @@ -186,12 +185,14 @@ class TestResponses: """Tests on questionnaire management.""" def test_client_rep_create_response_returns_201( - self, api_client, client_rep, response_payload + self, api_client, client_rep, response_list_url, response_payload ): """Test creating a questionnaire is successful.""" api_client.force_authenticate(user=client_rep) + questionnaire = Questionnaire.objects.get(client_rep=client_rep) + url = response_list_url(questionnaire.id) - response = api_client.post(RESPONSES_URL, response_payload, format="json") + response = api_client.post(url, response_payload, format="json") assert response.status_code == status.HTTP_201_CREATED responses = Response.objects.filter(respondent=response.data["respondent"]) @@ -203,26 +204,30 @@ def test_client_rep_create_response_returns_201( assert response.data == serializer.data def test_client_reps_cannot_respond_unassigned_questionnaires( - self, api_client, response_payload + self, api_client, client_rep, response_list_url, response_payload ): """Test client reps can't respond if they haven't been assigned.""" user = baker.make(User) client_reps = Group.objects.get(name=CLIENT_REP_GROUP) user.groups.add(client_reps) api_client.force_authenticate(user=user) + questionnaire = Questionnaire.objects.get(client_rep=client_rep) + url = response_list_url(questionnaire.id) - response = api_client.post(RESPONSES_URL, response_payload, format="json") + response = api_client.post(url, response_payload, format="json") - assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.status_code == status.HTTP_404_NOT_FOUND assert Response.objects.count() == 0 def test_anonymous_user_cannot_respond_to_questionnaires( - self, api_client, response_payload + self, api_client, response_list_url, response_payload ): """Test anonymous users cannot respond to questionnaires.""" api_client.logout() + questionnaire = baker.make(Questionnaire) + url = response_list_url(questionnaire.id) - response = api_client.post(RESPONSES_URL, response_payload, format="json") + response = api_client.post(url, response_payload, format="json") assert response.status_code == status.HTTP_401_UNAUTHORIZED assert Response.objects.count() == 0 From 71c3b1cc3e52c8d883473289a31a1db8b2cea505 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Mon, 13 Mar 2023 15:06:01 +0000 Subject: [PATCH 5/9] Switch to nested routers for responses --- b2b/feedback/serializers.py | 17 ++++++++--------- b2b/feedback/urls.py | 12 +++++++++--- b2b/feedback/views.py | 14 ++++++++++++-- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/b2b/feedback/serializers.py b/b2b/feedback/serializers.py index cd98cca..0d1c022 100644 --- a/b2b/feedback/serializers.py +++ b/b2b/feedback/serializers.py @@ -1,5 +1,6 @@ """Serializers for the feedback app.""" from django.db import transaction +from django.http import Http404 from django.shortcuts import get_object_or_404 from rest_framework import serializers @@ -155,7 +156,6 @@ class Meta: fields = [ "id", "respondent", - "questionnaire", "submitted_at", "answers", ] @@ -179,12 +179,11 @@ def create(self, validated_data): return response - def validate_questionnaire(self, value): + def validate(self, attrs): """Validate that current user is assigned to the questionnaire.""" - user = self.context["request"].user - questionnaire = get_object_or_404(Questionnaire, pk=value.id) - if questionnaire.client_rep != user: - raise serializers.ValidationError( - "This questionnaire is not assigned to you." - ) - return value + questionnaire = get_object_or_404( + Questionnaire, pk=self.context["questionnaire_id"] + ) + if questionnaire.client_rep != self.context["user"]: + raise Http404() + return super().validate(attrs) diff --git a/b2b/feedback/urls.py b/b2b/feedback/urls.py index d473d43..c74afe0 100644 --- a/b2b/feedback/urls.py +++ b/b2b/feedback/urls.py @@ -1,13 +1,19 @@ """Feedback app url configuration.""" -from rest_framework.routers import DefaultRouter +from rest_framework_nested.routers import DefaultRouter, NestedDefaultRouter from .views import ClientViewSet, QuestionnaireViewSet, ResponseViewSet router = DefaultRouter() router.register("clients", ClientViewSet) router.register("questionnaires", QuestionnaireViewSet) -router.register("responses", ResponseViewSet) + +questionnaires_router = NestedDefaultRouter( + router, "questionnaires", lookup="questionnaire" +) +questionnaires_router.register( + "responses", ResponseViewSet, basename="questionnaire-responses" +) app_name = "feedback" -urlpatterns = router.urls +urlpatterns = router.urls + questionnaires_router.urls diff --git a/b2b/feedback/views.py b/b2b/feedback/views.py index 3de4e50..98de114 100644 --- a/b2b/feedback/views.py +++ b/b2b/feedback/views.py @@ -67,6 +67,16 @@ class ResponseViewSet( serializer_class = ResponseSerializer permission_classes = [IsClientRepresentative] + def get_serializer_context(self): + """Pass user and questionnaire id to serializer for validation.""" + return { + "user": self.request.user, + "questionnaire_id": self.kwargs["questionnaire_pk"], + } + def perform_create(self, serializer): - """Attach current user as the respondent.""" - return serializer.save(respondent=self.request.user) + """Add response relationships.""" + questionnaire_id = self.kwargs["questionnaire_pk"] + return serializer.save( + questionnaire_id=questionnaire_id, respondent=self.request.user + ) From 15c6d83acc0a4cabe3546da8ae2f0af54a99f153 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Mon, 13 Mar 2023 15:06:29 +0000 Subject: [PATCH 6/9] Update project requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index bf08bb1..47ff8d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ django-environ>=0.10.0,<0.11 psycopg2>=2.9.5,<2.10 djangorestframework>=3.14.0,<3.15.0 django-nested-admin>=4.0.2,<4.1 +drf-nested-routers>=0.93.4,<0.94 From 0bbc7d8cbe27468338b2310977eba70fac5c94e7 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Mon, 13 Mar 2023 15:18:05 +0000 Subject: [PATCH 7/9] Improve response creation tests --- b2b/feedback/tests/test_feedback_api.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/b2b/feedback/tests/test_feedback_api.py b/b2b/feedback/tests/test_feedback_api.py index 5e01c39..4fd1a6b 100644 --- a/b2b/feedback/tests/test_feedback_api.py +++ b/b2b/feedback/tests/test_feedback_api.py @@ -111,11 +111,11 @@ def test_non_sales_manager_delete_client_returns_403( @pytest.mark.django_db -class TestQuestionnaires: +class TestManageQuestionnaires: """Tests on questionnaire management.""" def test_create_questionnaire_returns_201( - self, api_client, client_rep, questionnaire_payload, sales_manager + self, api_client, questionnaire_payload, sales_manager ): """Test creating a questionnaire is successful.""" api_client.force_authenticate(user=sales_manager) @@ -181,7 +181,7 @@ def test_non_client_rep_cannot_list_questionnaires(self, api_client, sample_user @pytest.mark.django_db -class TestResponses: +class TestManageResponses: """Tests on questionnaire management.""" def test_client_rep_create_response_returns_201( @@ -198,6 +198,8 @@ def test_client_rep_create_response_returns_201( responses = Response.objects.filter(respondent=response.data["respondent"]) assert responses.count() == 1 feedback_response = responses.first() + assert feedback_response.questionnaire == questionnaire + assert feedback_response.respondent == client_rep assert feedback_response.answers.count() == 4 assert AnswerChoice.objects.count() == 4 serializer = ResponseSerializer(feedback_response) From adce010968d6c2feffc36a4aab77d1ebde10f34f Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Mon, 13 Mar 2023 19:07:46 +0000 Subject: [PATCH 8/9] Write tests for incoming change --- b2b/feedback/tests/conftest.py | 10 +++++++++ b2b/feedback/tests/test_feedback_api.py | 27 +++++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/b2b/feedback/tests/conftest.py b/b2b/feedback/tests/conftest.py index 6ccc9c2..2d209c7 100644 --- a/b2b/feedback/tests/conftest.py +++ b/b2b/feedback/tests/conftest.py @@ -43,6 +43,16 @@ def _client_detail_url(client_id): return _client_detail_url +@pytest.fixture +def questionnaire_detail_url(): + """Return a questionnaire detail url.""" + + def _get_url(questionnaire_id): + return reverse("feedback:questionnaire-detail", args=[questionnaire_id]) + + return _get_url + + @pytest.fixture def response_list_url(): """Return a questionnaire's response list url.""" diff --git a/b2b/feedback/tests/test_feedback_api.py b/b2b/feedback/tests/test_feedback_api.py index 4fd1a6b..5a774b4 100644 --- a/b2b/feedback/tests/test_feedback_api.py +++ b/b2b/feedback/tests/test_feedback_api.py @@ -11,6 +11,7 @@ ) from feedback.serializers import ( ClientSerializer, + QuestionnaireListSerializer, QuestionnaireSerializer, ResponseSerializer, ) @@ -115,7 +116,7 @@ class TestManageQuestionnaires: """Tests on questionnaire management.""" def test_create_questionnaire_returns_201( - self, api_client, questionnaire_payload, sales_manager + self, api_client, client_rep, questionnaire_payload, sales_manager ): """Test creating a questionnaire is successful.""" api_client.force_authenticate(user=sales_manager) @@ -130,6 +131,8 @@ def test_create_questionnaire_returns_201( ) assert questionnaires.count() == 1 questionnaire = questionnaires.first() + assert questionnaire.author == sales_manager + assert questionnaire.client_rep == client_rep assert questionnaire.questions.count() == 4 serializer = QuestionnaireSerializer(questionnaire) assert response.data == serializer.data @@ -158,11 +161,27 @@ def test_client_rep_can_list_assigned_questionnaires(self, api_client, client_re baker.make(Questionnaire, client_rep=client_rep) baker.make(Questionnaire) - response = api_client.get(QUESTIONNAIRES_URL) + response = api_client.get(f"{QUESTIONNAIRES_URL}?client_rep=1") assert response.status_code == status.HTTP_200_OK questionnaires = Questionnaire.objects.filter(client_rep=client_rep) - serializer = QuestionnaireSerializer(questionnaires, many=True) + serializer = QuestionnaireListSerializer(questionnaires, many=True) + assert serializer.data == response.data + assert len(response.data) == 1 + + def test_sales_manager_can_list_authored_questionnaires( + self, api_client, sales_manager + ): + """Test sales managers can list authored questionnaires.""" + api_client.force_authenticate(user=sales_manager) + baker.make(Questionnaire, author=sales_manager) + baker.make(Questionnaire) + + response = api_client.get(f"{QUESTIONNAIRES_URL}?sales_manager=1") + + assert response.status_code == status.HTTP_200_OK + questionnaires = Questionnaire.objects.filter(author=sales_manager) + serializer = QuestionnaireListSerializer(questionnaires, many=True) assert serializer.data == response.data assert len(response.data) == 1 @@ -176,7 +195,7 @@ def test_non_client_rep_cannot_list_questionnaires(self, api_client, sample_user assert response.status_code == status.HTTP_403_FORBIDDEN questionnaires = Questionnaire.objects.filter(client_rep=sample_user) - serializer = QuestionnaireSerializer(questionnaires, many=True) + serializer = QuestionnaireListSerializer(questionnaires, many=True) assert serializer.data != response.data From 16213e1baa01e3cebaca0d422052f50bf9db2de9 Mon Sep 17 00:00:00 2001 From: snnbotchway Date: Mon, 13 Mar 2023 19:08:35 +0000 Subject: [PATCH 9/9] Add retrieve and list questionnaires --- ...alter_questionnaire_client_rep_and_more.py | 59 +++++++++++++++++++ b2b/feedback/models.py | 16 ++++- b2b/feedback/permissions.py | 12 ++++ b2b/feedback/serializers.py | 23 ++++++-- b2b/feedback/views.py | 55 +++++++++++++---- 5 files changed, 145 insertions(+), 20 deletions(-) create mode 100644 b2b/feedback/migrations/0005_questionnaire_author_alter_questionnaire_client_rep_and_more.py diff --git a/b2b/feedback/migrations/0005_questionnaire_author_alter_questionnaire_client_rep_and_more.py b/b2b/feedback/migrations/0005_questionnaire_author_alter_questionnaire_client_rep_and_more.py new file mode 100644 index 0000000..0dd6f79 --- /dev/null +++ b/b2b/feedback/migrations/0005_questionnaire_author_alter_questionnaire_client_rep_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 4.1.7 on 2023-03-13 15:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("feedback", "0004_alter_answer_answer_text"), + ] + + operations = [ + migrations.AddField( + model_name="questionnaire", + name="author", + field=models.ForeignKey( + blank=True, + limit_choices_to={"groups__name": "Sales Managers"}, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sales_manager_questionnaires", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="questionnaire", + name="client_rep", + field=models.ForeignKey( + blank=True, + limit_choices_to={"groups__name": "Corporate Client Representatives"}, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="client_rep_questionnaires", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="response", + name="questionnaire", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="questionnaire_responses", + to="feedback.questionnaire", + ), + ), + migrations.AlterField( + model_name="response", + name="respondent", + field=models.ForeignKey( + limit_choices_to={"groups__name": "Corporate Client Representatives"}, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="client_rep_responses", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/b2b/feedback/models.py b/b2b/feedback/models.py index 9a866d2..ce0a89a 100644 --- a/b2b/feedback/models.py +++ b/b2b/feedback/models.py @@ -40,10 +40,18 @@ def __str__(self): class Questionnaire(models.Model): """The Questionnaire model.""" + author = models.ForeignKey( + User, + blank=True, + null=True, + limit_choices_to={"groups__name": SALES_MANAGER_GROUP}, + related_name="sales_manager_questionnaires", + on_delete=models.SET_NULL, + ) client_rep = models.ForeignKey( User, on_delete=models.SET_NULL, - related_name="questionnaires", + related_name="client_rep_questionnaires", blank=True, null=True, limit_choices_to={"groups__name": CLIENT_REP_GROUP}, @@ -101,11 +109,13 @@ class Response(models.Model): respondent = models.ForeignKey( User, on_delete=models.SET_NULL, - related_name="responses", + related_name="client_rep_responses", limit_choices_to={"groups__name": CLIENT_REP_GROUP}, null=True, ) - questionnaire = models.ForeignKey(Questionnaire, on_delete=models.CASCADE) + questionnaire = models.ForeignKey( + Questionnaire, on_delete=models.CASCADE, related_name="questionnaire_responses" + ) submitted_at = models.DateTimeField(auto_now_add=True) def __str__(self): diff --git a/b2b/feedback/permissions.py b/b2b/feedback/permissions.py index e148108..f8ddea7 100644 --- a/b2b/feedback/permissions.py +++ b/b2b/feedback/permissions.py @@ -21,3 +21,15 @@ def has_permission(self, request, view): """Return true if current user is in the Client Representatives group.""" client_rep_group, _ = Group.objects.get_or_create(name=CLIENT_REP_GROUP) return client_rep_group in request.user.groups.all() + + +class IsSalesManagerOrClientRep(BasePermission): + """Sales Managers or Client Representatives permission class.""" + + def has_permission(self, request, view): + """Return true if current user is in either group.""" + sales_manager_group, _ = Group.objects.get_or_create(name=SALES_MANAGER_GROUP) + client_rep_group, _ = Group.objects.get_or_create(name=CLIENT_REP_GROUP) + + user_groups = request.user.groups.all() + return sales_manager_group in user_groups or client_rep_group in user_groups diff --git a/b2b/feedback/serializers.py b/b2b/feedback/serializers.py index 0d1c022..65b5513 100644 --- a/b2b/feedback/serializers.py +++ b/b2b/feedback/serializers.py @@ -66,22 +66,33 @@ class Meta: ] -class QuestionnaireSerializer(serializers.ModelSerializer): +class QuestionnaireListSerializer(serializers.ModelSerializer): + """The questionnaire list serializer.""" + + class Meta: + """Questionnaire list serializer meta class.""" + + model = Questionnaire + fields = [ + "id", + "title", + "due_at", + ] + + +class QuestionnaireSerializer(QuestionnaireListSerializer): """The questionnaire serializer.""" questions = QuestionSerializer(many=True, required=True) - class Meta: + class Meta(QuestionnaireListSerializer.Meta): """Questionnaire serializer meta class.""" model = Questionnaire - fields = [ - "id", + fields = QuestionnaireListSerializer.Meta.fields + [ "client_rep", - "title", "description", "is_active", - "due_at", "created_at", "questions", ] diff --git a/b2b/feedback/views.py b/b2b/feedback/views.py index 98de114..b8c2d0b 100644 --- a/b2b/feedback/views.py +++ b/b2b/feedback/views.py @@ -1,10 +1,25 @@ """Views for the feedback app.""" -from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin +from rest_framework.mixins import ( + CreateModelMixin, + DestroyModelMixin, + ListModelMixin, + RetrieveModelMixin, +) +from rest_framework.permissions import SAFE_METHODS from rest_framework.viewsets import GenericViewSet from .models import Client, Questionnaire, Response -from .permissions import IsClientRepresentative, IsSalesManager -from .serializers import ClientSerializer, QuestionnaireSerializer, ResponseSerializer +from .permissions import ( + IsClientRepresentative, + IsSalesManager, + IsSalesManagerOrClientRep, +) +from .serializers import ( + ClientSerializer, + QuestionnaireListSerializer, + QuestionnaireSerializer, + ResponseSerializer, +) class ClientViewSet( @@ -33,6 +48,7 @@ def perform_create(self, serializer): class QuestionnaireViewSet( CreateModelMixin, + RetrieveModelMixin, ListModelMixin, GenericViewSet, ): @@ -41,21 +57,38 @@ class QuestionnaireViewSet( queryset = Questionnaire.objects.prefetch_related("questions__choices").all() serializer_class = QuestionnaireSerializer - def _is_list_action(self): - return self.action == "list" + def _fetch_params(self): + query_params = self.request.query_params + sales_manager = int(query_params.get("sales_manager", 0)) + client_rep = int(query_params.get("client_rep", 0)) + return client_rep, sales_manager def get_queryset(self): - """Filter list queryset to questionnaires the current user is assigned.""" - if self._is_list_action(): - return self.queryset.filter(client_rep=self.request.user) + """Filter queryset with params.""" + user = self.request.user + client_rep, sales_manager = self._fetch_params() + if client_rep: + return self.queryset.filter(client_rep=user) + elif sales_manager: + return self.queryset.filter(author=user) return self.queryset def get_permissions(self): - """Client reps can list and Sales managers can create questionnaires.""" - if self._is_list_action(): - return [IsClientRepresentative()] + """Return appropriate permissions.""" + if self.request.method in SAFE_METHODS: + return [IsSalesManagerOrClientRep()] return [IsSalesManager()] + def get_serializer_class(self): + """Return appropriate serializer.""" + if self.action == "list": + return QuestionnaireListSerializer + return QuestionnaireSerializer + + def perform_create(self, serializer): + """Set current user as questionnaire author.""" + return serializer.save(author=self.request.user) + class ResponseViewSet( CreateModelMixin,