Skip to content

Commit

Permalink
Merge pull request #6 from snnbotchway/feature/feedback-app
Browse files Browse the repository at this point in the history
Build questionnaire API and configure its admin
  • Loading branch information
snnbotchway authored Mar 12, 2023
2 parents bf8a8d7 + 8a88916 commit 99c2f31
Show file tree
Hide file tree
Showing 10 changed files with 244 additions and 35 deletions.
2 changes: 2 additions & 0 deletions b2b/b2b/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"core.apps.CoreConfig",
"feedback.apps.FeedbackConfig",
# Third-party apps
"nested_admin",
"rest_framework",
"rest_framework.authtoken",
]
Expand Down Expand Up @@ -130,6 +131,7 @@
# https://docs.djangoproject.com/en/4.1/howto/static-files/

STATIC_URL = "static/"
STATIC_ROOT = os.path.join(BASE_DIR, "static")

# Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
Expand Down
1 change: 1 addition & 0 deletions b2b/b2b/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@
path("admin/", admin.site.urls),
path("account/", include("account.urls")),
path("feedback/", include("feedback.urls")),
path(r"_nested_admin/", include("nested_admin.urls")),
]
57 changes: 34 additions & 23 deletions b2b/feedback/admin.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,43 @@
"""Account app admin configurations."""
from django.contrib import admin
from nested_admin.nested import NestedModelAdmin, NestedStackedInline

from .models import Client
from .models import Client, Question, QuestionChoice, Questionnaire


@admin.register(Client)
class ClientAdmin(admin.ModelAdmin):
"""Define admin configuration for the Client model."""

autocomplete_fields = [
"client_rep",
"sales_manager",
]
list_display = [
"email",
"name",
"client_rep",
"sales_manager",
"id",
]
list_editable = [
"name",
]
list_select_related = [
"client_rep",
"sales_manager",
]
search_fields = [
"email__icontains",
"name__icontains",
]
autocomplete_fields = ["client_rep", "sales_manager"]
list_display = ["email", "name", "client_rep", "sales_manager", "id"]
list_editable = ["name"]
list_select_related = ["client_rep", "sales_manager"]
search_fields = ["email__icontains", "name__icontains"]


class QuestionChoiceInline(NestedStackedInline):
"""Inline class for question choices."""

model = QuestionChoice
extra = 0


class QuestionInline(NestedStackedInline):
"""Inline class for questionnaire questions."""

model = Question
inlines = [QuestionChoiceInline]
extra = 0


@admin.register(Questionnaire)
class QuestionnaireAdmin(NestedModelAdmin):
"""Define admin configuration for the Questionnaire model."""

autocomplete_fields = ["client_rep"]
inlines = [QuestionInline]
list_display = ["title", "client_rep", "created_at", "due_at", "id"]
list_editable = ["due_at"]
list_select_related = ["client_rep"]
search_fields = ["title__icontains", "description__icontains"]
81 changes: 80 additions & 1 deletion b2b/feedback/serializers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Serializers for the feedback app."""
from django.db import transaction
from rest_framework import serializers

from .models import Client
from .models import Client, Question, QuestionChoice, Questionnaire


class ClientSerializer(serializers.ModelSerializer):
Expand All @@ -20,3 +21,81 @@ class Meta:
"created_at",
"updated_at",
]


class QuestionChoiceSerializer(serializers.ModelSerializer):
"""The question choice serializer."""

class Meta:
"""Question choice serializer meta class."""

model = QuestionChoice
fields = [
"id",
"value",
"order",
]


class QuestionSerializer(serializers.ModelSerializer):
"""The question serializer."""

choices = QuestionChoiceSerializer(many=True, required=False)

class Meta:
"""Question serializer meta class."""

model = Question
fields = [
"id",
"question_type",
"question_text",
"order",
"required",
"choices",
]


class QuestionnaireSerializer(serializers.ModelSerializer):
"""The questionnaire serializer."""

questions = QuestionSerializer(many=True, required=True)

class Meta:
"""Questionnaire serializer meta class."""

model = Questionnaire
fields = [
"id",
"client_rep",
"title",
"description",
"is_active",
"due_at",
"created_at",
"questions",
]

def create(self, validated_data):
"""Create a questionnaire."""
with transaction.atomic():
questions = validated_data.pop("questions", [])
questionnaire = Questionnaire.objects.create(**validated_data)

for question in questions:
choices = question.pop("choices", [])
question = Question.objects.create(
**question, questionnaire=questionnaire
)
choice_objs = [
QuestionChoice(**choice, question=question) for choice in choices
]
QuestionChoice.objects.bulk_create(choice_objs)

return questionnaire

def validate_questions(self, value):
"""Raise error if there are no questions."""
if not value:
raise serializers.ValidationError("At least one question is required.")
return value
59 changes: 58 additions & 1 deletion b2b/feedback/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import pytest
from django.contrib.auth.models import Group
from django.urls import reverse
from feedback.models import CLIENT_REP_GROUP, SALES_MANAGER_GROUP
from model_bakery import baker


Expand All @@ -17,11 +18,19 @@ def client_payload():
@pytest.fixture
def sales_manager(sample_user):
"""Return a sales manager user."""
sales_managers = baker.make(Group, name="Sales Managers")
sales_managers = baker.make(Group, name=SALES_MANAGER_GROUP)
sample_user.groups.add(sales_managers)
return sample_user


@pytest.fixture
def client_rep(sample_user):
"""Return a client representative user."""
client_reps = baker.make(Group, name=CLIENT_REP_GROUP)
sample_user.groups.add(client_reps)
return sample_user


@pytest.fixture
def client_detail_url():
"""Return a client detail url."""
Expand All @@ -30,3 +39,51 @@ def _client_detail_url(client_id):
return reverse("feedback:client-detail", args=[client_id])

return _client_detail_url


@pytest.fixture
def questionnaire_payload(client_rep):
"""Return a sample questionnaire."""
return {
"client_rep": client_rep.id,
"title": "Sample title",
"description": "Sample description",
"due_at": "2022-07-12T18:30:45Z",
"questions": [
{
"question_type": "OPEN",
"question_text": "What is your name?",
"required": True,
"order": 1,
"choices": [],
},
{
"question_type": "LOGICAL",
"question_text": "True or False?",
"required": True,
"order": 2,
"choices": [],
},
{
"question_type": "MULTIPLE_CHOICE",
"question_text": "Select all which apply.",
"required": False,
"order": 3,
"choices": [
{"value": "Option 1", "order": 1},
{"value": "Option 2", "order": 2},
{"value": "Option 3", "order": 3},
],
},
{
"question_type": "DROPDOWN",
"question_text": "Which of the following?",
"order": 4,
"choices": [
{"value": "Option 1", "order": 1},
{"value": "Option 2", "order": 2},
{"value": "Option 3", "order": 3},
],
},
],
}
48 changes: 46 additions & 2 deletions b2b/feedback/tests/test_feedback_api.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import pytest
from django.urls import reverse
from feedback.models import Client
from feedback.serializers import ClientSerializer
from feedback.models import Client, Questionnaire
from feedback.serializers import ClientSerializer, QuestionnaireSerializer
from model_bakery import baker
from rest_framework import status

CLIENTS_URL = reverse("feedback:client-list")
QUESTIONNAIRES_URL = reverse("feedback:questionnaire-list")


@pytest.mark.django_db
Expand Down Expand Up @@ -93,3 +94,46 @@ def test_non_sales_manager_delete_client_returns_403(

assert response.status_code == status.HTTP_403_FORBIDDEN
assert Client.objects.count() == 1


@pytest.mark.django_db
class TestQuestionnaires:
"""Tests on questionnaire management."""

def test_create_questionnaire_returns_201(
self, api_client, client_rep, questionnaire_payload, sales_manager
):
"""Test creating a questionnaire is successful."""
api_client.force_authenticate(user=sales_manager)

response = api_client.post(
QUESTIONNAIRES_URL, questionnaire_payload, format="json"
)

assert response.status_code == status.HTTP_201_CREATED
questionnaires = Questionnaire.objects.filter(
client_rep_id=response.data.get("client_rep")
)
assert questionnaires.count() == 1
questionnaire = questionnaires.first()
assert questionnaire.questions.count() == 4
serializer = QuestionnaireSerializer(questionnaire)
assert response.data == serializer.data

# Assert the last two questions are false 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

def test_only_sales_manager_can_create_questionnaire(
self, api_client, questionnaire_payload, sample_user
):
"""Test non sales manager cannot create a questionnaire."""
api_client.force_authenticate(user=sample_user)

response = api_client.post(
QUESTIONNAIRES_URL, questionnaire_payload, format="json"
)

assert response.status_code == status.HTTP_403_FORBIDDEN
assert Questionnaire.objects.count() == 0
3 changes: 2 additions & 1 deletion b2b/feedback/urls.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Feedback app url configuration."""
from rest_framework.routers import DefaultRouter

from .views import ClientViewSet
from .views import ClientViewSet, QuestionnaireViewSet

router = DefaultRouter()
router.register("clients", ClientViewSet)
router.register("questionnaires", QuestionnaireViewSet)

app_name = "feedback"

Expand Down
26 changes: 19 additions & 7 deletions b2b/feedback/views.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
"""Views for the feedback app."""
from rest_framework import mixins, viewsets
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin
from rest_framework.viewsets import GenericViewSet

from .models import Client
from .models import Client, Questionnaire
from .permissions import IsSalesManager
from .serializers import ClientSerializer
from .serializers import ClientSerializer, QuestionnaireSerializer


class ClientViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
CreateModelMixin,
DestroyModelMixin,
ListModelMixin,
GenericViewSet,
):
"""The Client view set."""

Expand All @@ -28,3 +29,14 @@ def get_queryset(self):
def perform_create(self, serializer):
"""Assign current user as the manager on client creation."""
return serializer.save(sales_manager=self.request.user)


class QuestionnaireViewSet(
CreateModelMixin,
GenericViewSet,
):
"""The questionnaire viewset."""

queryset = Questionnaire.objects.all()
serializer_class = QuestionnaireSerializer
permission_classes = [IsSalesManager]
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ services:
command: >
sh -c "python manage.py wait_for_db &&
python manage.py migrate &&
python manage.py collectstatic --noinput &&
python manage.py runserver 0.0.0.0:8000"
environment:
- DEBUG=${DEBUG}
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ Django>=4.1.7,<4.2
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

0 comments on commit 99c2f31

Please sign in to comment.