diff --git a/.pylintrc b/.pylintrc index a38492d526b184470b00af41da106143c0122e1e..0deca7bc1ce42b9f694aaa973329a5d344013766 100644 --- a/.pylintrc +++ b/.pylintrc @@ -3,4 +3,9 @@ fail-under=10 [MESSAGES CONTROL] disable=line-too-long, - django-not-configured + django-not-configured, + too-few-public-methods, + no-self-use, + abstract-method, + arguments-differ, + invalid-name diff --git a/README.md b/README.md index fa9b5435047925eda043a98975c7f10b4283980b..075146a4cb0ac01f5e58a25ff1f64718db803781 100644 --- a/README.md +++ b/README.md @@ -113,4 +113,25 @@ activate(cur_language) # Resets active language to the one before manual activat ``` -This might be particularly useful for entering data from the Admin Frontend into the database, or choosing the language for the User Frontend if the Browser Locale cannot be used. \ No newline at end of file +This might be particularly useful for entering data from the Admin Frontend into the database, or choosing the language for the User Frontend if the Browser Locale cannot be used. + + +## Pagination in Views + +Every list that needs to be paginated needs a paginator. +Here, the Paginator object is created in the GET call. +The Queryset which is supposed to be paginated needs to be run through the function: + +```python +new_queryset = paginator.paginate_queryset(complete_queryset, request) +``` + +The new_queryset is a List, not a Manager, so it can be directly iterated upon. +After the data has been worked on and run through the Serializer as normal, +instead of returning Result(data), the paginator needs to be used again so it can add its page metadata: + +```python +return paginator.get_paginated_response(serializer.data) +``` + +This function already returns a fully valid Response, so it can be directly returned. \ No newline at end of file diff --git a/unisportomat/quiz/fixtures/criteria.json b/unisportomat/quiz/fixtures/criteria.json index 584ded6433489d92a803179cea13f8313434e63b..597ae13c3cd90f3df8ae5c05215809a2b0013d29 100644 --- a/unisportomat/quiz/fixtures/criteria.json +++ b/unisportomat/quiz/fixtures/criteria.json @@ -2,6 +2,7 @@ { "model": "quiz.criterion", "pk": 1, + "question": 1, "fields": { "name": "Outdoorsport" } diff --git a/unisportomat/quiz/management/commands/seed_db.py b/unisportomat/quiz/management/commands/seed_db.py index ded2a9140dbf7a9ca8de92726f4476b3dcde1434..d6a6d836735a50158d0857d57278b5e41df1dc1c 100644 --- a/unisportomat/quiz/management/commands/seed_db.py +++ b/unisportomat/quiz/management/commands/seed_db.py @@ -74,7 +74,7 @@ class Command(BaseCommand): "Bouldern", ] for name in sports_names: - Sport(name=name).save() + Sport.objects.create_sport(name=name).save() # Create criteria criteria_names = [ @@ -85,7 +85,7 @@ class Command(BaseCommand): "Kampfsport", ] for name in criteria_names: - Criterion(name=name).save() + Criterion.objects.create_criterion(name=name).save() # Create ratings for all sports and criterions for sport in Sport.objects.all(): diff --git a/unisportomat/quiz/models.py b/unisportomat/quiz/models.py index d582994f73b3de31d04f59e1a1e8d16539cf3ed8..9a7b5b18510fdc173a512afbf3959659ca51319b 100644 --- a/unisportomat/quiz/models.py +++ b/unisportomat/quiz/models.py @@ -1,9 +1,19 @@ """ Model definitions for the quiz """ -from django.core.validators import MaxValueValidator, MinValueValidator +from django.core.exceptions import ValidationError from django.db import models +def validate_rating(value): + """ + This function acts as a validator for ratings. + Sadly, it isn't called automatically, so it needs to be used manually. + """ + + if not ((10 >= value >= 1) or value == -1): + raise ValidationError(u"%s is not a valid rating!" % value) + + class CriterionRating(models.Model): """ This is the relation between Sport and Criterion. @@ -11,9 +21,7 @@ class CriterionRating(models.Model): To see it's usage check Sport.rate() and Sport.get_rating() """ - rating = models.IntegerField( - validators=[MaxValueValidator(10), MinValueValidator(1)] - ) + rating = models.IntegerField(validators=[validate_rating]) criterion = models.ForeignKey("Criterion", on_delete=models.CASCADE) sport = models.ForeignKey("Sport", on_delete=models.CASCADE) @@ -21,6 +29,28 @@ class CriterionRating(models.Model): return str(self.sport) + " - " + str(self.criterion) + ": " + str(self.rating) +class SportManager(models.Manager): + """ + Manages Creation of Sport Objects + Since every Criterion Connection needs to be present in the DB at all times, + the connections are made at creation of the Sport object. For this, Sport objects need to be + created through this Manager Class. + + Docs: https://docs.djangoproject.com/en/3.2/ref/models/instances/#creating-objects + """ + + def create_sport(self, **kwargs): + """ + Creates new Sport Object and every CriterionRating for it + """ + sport = self.create(**kwargs) + + for crit in Criterion.objects.iterator(): + sport.rate(crit, -1) + + return sport + + class Sport(models.Model): """ Defines a Sport with name, url that leads to the booking page. @@ -32,6 +62,8 @@ class Sport(models.Model): url = models.URLField() criteria_ratings = models.ManyToManyField("Criterion", through="CriterionRating") + objects = SportManager() + def __str__(self): return self.name @@ -40,6 +72,7 @@ class Sport(models.Model): rating_obj, _ = CriterionRating.objects.get_or_create( sport=self, criterion=criterion, defaults={"rating": rating} ) + validate_rating(rating) rating_obj.rating = rating rating_obj.save() return rating_obj @@ -49,6 +82,42 @@ class Sport(models.Model): criterion_rating = CriterionRating.objects.get(sport=self, criterion=criterion) return criterion_rating.rating + def is_filled(self): + """ + Returns a Boolean whether all Criterions are given a valid rating (unequal to -1) + """ + + for crit in self.criteria_ratings.iterator(): + if self.get_rating(crit) == -1: + return False + + return True + + +class CriterionManager(models.Manager): + """ + Manages Creation of Criterion Objects + Since every Sport Object needs to be rated in every Criterion, + when a new Criterion is created, every Sport object needs to be rated for that Criterion. + As a default value, -1 is entered so that it can be recognized that no true value is given. + + Docs: https://docs.djangoproject.com/en/3.2/ref/models/instances/#creating-objects + """ + + def create_criterion(self, **kwargs): + """ + Creates a Criterion Object and Rates every existing Sport with -1 + """ + crit = Criterion(**kwargs) + + # Criterion needs to be saved before it can be connected to a sport + crit.save() + + for sport in Sport.objects.iterator(): + sport.rate(crit, -1) + + return crit + class Criterion(models.Model): """ @@ -58,9 +127,27 @@ class Criterion(models.Model): name = models.TextField() + objects = CriterionManager() + def __str__(self): return self.name + def get_active_sum(self): + """ + Get Number of Sports with Rating larger than 1 and the cumulated sum of all ratings + TODO: Think about Usefulness of Rating Sums with 1 as Min Value + """ + + num_active = 0 + rating_sum = 0 + + for rating_obj in CriterionRating.objects.filter(criterion=self): + rating_sum += rating_obj.rating + if rating_obj.rating > 1: + num_active += 1 + + return num_active, rating_sum + class CallToMove(models.Model): """Defines text and image that are used to show a call to move between questions""" diff --git a/unisportomat/quiz/serializers.py b/unisportomat/quiz/serializers.py index 584e145715661a9ab7d5ac7a2406e0d6b9463475..9756a8e190171e12071bfbd365b05c6dc05813f3 100644 --- a/unisportomat/quiz/serializers.py +++ b/unisportomat/quiz/serializers.py @@ -2,7 +2,7 @@ Serializers creating JSONs for every Model from .models """ from rest_framework import serializers -from .models import Sport, Criterion, Question +from .models import Sport, Criterion, Question, validate_rating class SportListSerializer(serializers.ModelSerializer): @@ -22,7 +22,6 @@ class QuestionListSerializer(serializers.ModelSerializer): class Meta: model = Question - fields = ("id", "text", "criterion") @@ -34,3 +33,169 @@ class CriterionListSerializer(serializers.ModelSerializer): class Meta: model = Criterion fields = ("id", "name") + + +class SmallSportListSerializer(serializers.BaseSerializer): + """ + Serializes Lists of Sport Objects in a "Simple" Manner, with Criterions being represented in a Bool. + """ + + def to_representation(self, sport_filled_tuples): + """ + Takes a List of (Sport, bool) tuples to Serialize. + The Bool Represents whether the Sport is Filled or not. + """ + + serialized_data = [] + + for sport, boolean in sport_filled_tuples: + serialized_data.append( + { + "id": sport.pk, + "name": sport.name, + "url": sport.url, + "is_filled": boolean, + } + ) + + return serialized_data + + +class SingleSportSerializer(serializers.BaseSerializer): + """ + Serializes and Deserializes a Single Sport Object + """ + + def to_representation(self, sport): + """ + Takes a Single Sport Object and Serializes it and all its Criteria + """ + + serialized_data = {} + + serialized_data["id"] = sport.pk + serialized_data["name"] = sport.name + serialized_data["url"] = sport.url + + criteria = [] + + for criterion in sport.criteria_ratings.iterator(): + criterion_data = {} + + criterion_data["id"] = criterion.pk + criterion_data["name"] = criterion.name + + criterion_data["value"] = sport.get_rating(criterion) + + criteria.append(criterion_data) + + serialized_data["criteria"] = criteria + + return serialized_data + + def to_internal_value(self, request): + """ + The Data in the Request is taken and written to another Dictionary. + During this process, the Data is Validated on whether the Rating Value and Criterion ID are valid. + If the Request is PATCHing or PUTting an existing Sport, not every field must be existant. + So, the existance is explicitly checked. + TODO: Different Functions based on PUT or PATCH? + """ + + sport_dictionary = {} + + # If The Sport is Being Patched, Name or URL may not be changed. + # That means that those Fields might not be sent in the Request, + # leading to needing to check whether they exist. + if "name" in request.data.keys(): + sport_dictionary["name"] = request.data["name"] + + if "url" in request.data.keys(): + sport_dictionary["url"] = request.data["url"] + + # If the Sport is only now created with a POST-Request, no Criteria can be filled out for it + # This is because the Admin Frontend doesn't have a list of Criteria ready + if "criteria" in request.data.keys(): + + # A Number of Criteria may be sent with the Sport + sport_dictionary["criteria"] = [] + + # For every Sent Criterion, the ID of the Criterion and the Rating Value is being tested for Validity + for criterion in request.data["criteria"]: + + value = criterion["value"] + try: + validate_rating(value) + crit = Criterion.objects.get(pk=criterion["id"]) + except: # pylint: disable=bare-except + return None + + sport_dictionary["criteria"].append((crit, value)) + + return sport_dictionary + + +class IncompleteSportSerializer(serializers.BaseSerializer): + """ + Serializes every Sport Object with Incomplete Criteria Ratings. + Includes the Name and ID of both the Sport and the Criteria. + """ + + def to_representation(self, incomplete_sports): + """ + Serializes Every given Sport Object and goes through every Criterium to serialize those that are unrated. + """ + + incomplete_sport_list = [] + + for sport in incomplete_sports: + incomplete_sport = { + "id": sport.pk, + "name": sport.name, + "criteria": [], + } + + for crit in sport.criteria_ratings.iterator(): + # Asking this way to save an indentation and for readability. Would also work when asking for = -1 and handling the append then. + if sport.get_rating(crit) != -1: + continue + + incomplete_sport["criteria"].append( + { + "id": crit.pk, + "name": crit.name, + } + ) + + incomplete_sport_list.append(incomplete_sport) + + return incomplete_sport_list + + +class CriteriaSerializer(serializers.BaseSerializer): + """ + Serializes Every Criterium and Metadata + """ + + def to_representation(self, data): + """ + Takes Tuples of (Criterium, Int, Int), + where the Integers are the Number of Sports in which the Rating is >1 + and the cumulated sum of Ratings >1, respectively + """ + + criteria_list = [] + + for crit, active_sports, sum_of_weights in data: + + criterion_dict = {} + + criterion_dict["id"] = crit.pk + criterion_dict["question_id"] = crit.question.pk + criterion_dict["name"] = crit.name + criterion_dict["number_of_sports_active"] = active_sports + criterion_dict["sum_of_weights"] = sum_of_weights + + criteria_list.append(criterion_dict) + + return criteria_list diff --git a/unisportomat/quiz/tests.py b/unisportomat/quiz/tests.py index 542d2c5e8cfdd224a2307e24509f1a9717e94410..80149025adbcb2b810b96ea4226dc47158511346 100644 --- a/unisportomat/quiz/tests.py +++ b/unisportomat/quiz/tests.py @@ -7,7 +7,9 @@ import tempfile from django.core.files.uploadedfile import SimpleUploadedFile from django.core.management import call_command from django.utils.translation import get_language, activate +from django.urls import reverse from django.test import TestCase, override_settings +from rest_framework.test import APITestCase from django.conf import settings from .models import ( Sport, @@ -379,3 +381,142 @@ class Modeltranslation_Two_Languages_Test(TestCase): self.assertEqual(self.question.text, self.question.text_en) self.assertNotEqual(self.question.text, self.question.text_de) + + +class APITest(APITestCase): + """Tests the Django API""" + + fixtures = [ + "sports.json", + "criteria.json", + "criterion_ratings.json", + "questions.json", + ] + + def test_get_sport_returns_correct_data(self): + """Test the API endpoint /sport/{id}""" + response = self.client.get(reverse("small-sport-list-detail", kwargs={"pk": 1})) + sport_data = { + "id": 1, + "name": "Jiu Jitsu", + "url": "http://www.test.de", + "criteria": [{"id": 1, "name": "Outdoorsport", "value": 1}], + } + self.assertDictEqual(response.data, sport_data) + + def test_put_sport_makes_correct_changes(self): + """ + Test the API endpoint /sport/{id} for put requests + """ + sport_data = { + "name": "Karate", + "url": "http://www.test2.de", + "criteria": [{"id": 1, "name": "Outdoorsport", "value": 1}], + } + + response = self.client.put( + reverse("small-sport-list-detail", kwargs={"pk": 1}), + data=sport_data, + format="json", + ) + self.assertEqual(response.data["name"], sport_data["name"]) + self.assertEqual(response.data["url"], sport_data["url"]) + self.assertEqual(len(response.data["criteria"]), Criterion.objects.count()) + self.assertDictEqual( + response.data["criteria"][0], {"id": 1, "name": "Outdoorsport", "value": 1} + ) + + def test_patch_sport_makes_correct_changes(self): + """ + Test the API endpoint /sport/{id} for patch requests + """ + sport_data = { + "criteria": [{"id": 1, "value": 3}], + } + + response = self.client.patch( + reverse("small-sport-list-detail", kwargs={"pk": 1}), + data=sport_data, + format="json", + ) + self.assertEqual(response.data["name"], "Jiu Jitsu") + self.assertEqual(response.data["url"], "http://www.test.de") + self.assertEqual(len(response.data["criteria"]), Criterion.objects.count()) + self.assertDictEqual( + response.data["criteria"][0], {"id": 1, "name": "Outdoorsport", "value": 3} + ) + + def test_get_sports_returns_correct_data(self): + """Test if API endpoint /sport returns correct sports list""" + + response = self.client.get(reverse("small-sport-list-list")) + + sport_data = [ + { + "id": 1, + "is_filled": True, + "name": "Jiu Jitsu", + "url": "http://www.test.de", + } + ] + self.assertListEqual(response.data["results"], sport_data) + self.assertEqual(response.data["count"], 1) + + def test_post_sports_creates_correct_entry(self): + """Test if post to /sport creates a correct object""" + sport_data_new = { + "name": "Karate", + "url": "http://www.test2.de", + } + + response = self.client.post( + reverse("small-sport-list-list"), data=sport_data_new + ) + self.assertEqual(response.data["name"], sport_data_new["name"]) + self.assertEqual(response.data["url"], sport_data_new["url"]) + self.assertEqual(len(response.data["criteria"]), Criterion.objects.count()) + self.assertEqual(Sport.objects.count(), 2) + + def test_delete_sports_creates_correct_entry(self): + """Test if delete to /sports deletes an object""" + + response = self.client.delete( + reverse("small-sport-list-detail", kwargs={"pk": 1}) + ) + + self.assertEqual(Sport.objects.count(), 0) + + def test_get_incomplete_sport_list(self): + """ + Tests if get to /sport/incomplete/ returns a list of incomplete sports + """ + + # Set Up Incomplete Sport + response = self.client.patch( + reverse("small-sport-list-detail", kwargs={"pk": 1}), + data={"criteria": [{"id": 1, "value": -1}]}, + format="json", + ) + + response = self.client.get(reverse("incomplete")) + + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["name"], "Jiu Jitsu") + + def test_get_criteria(self): + """ + Tests if get /critera/ returns a list of all criteria + """ + + # Set Up Values + response = self.client.patch( + reverse("small-sport-list-detail", kwargs={"pk": 1}), + data={"criteria": [{"id": 1, "value": 7}]}, + format="json", + ) + + response = self.client.get(reverse("criteria")) + + self.assertEqual(len(response.data), Criterion.objects.count()) + self.assertEqual(response.data[0]["number_of_sports_active"], 1) + self.assertEqual(response.data[0]["sum_of_weights"], 7) diff --git a/unisportomat/quiz/views.py b/unisportomat/quiz/views.py index 03cb6f920dc1ad9f4aed1e338c7c4333dd736927..8d7e0cf870f1e7abe163f32f9f1b45b1dde0a76a 100644 --- a/unisportomat/quiz/views.py +++ b/unisportomat/quiz/views.py @@ -4,10 +4,20 @@ Defines the views for the API # from django.shortcuts import render from rest_framework import viewsets +from rest_framework.views import APIView +from rest_framework.response import Response +from django.shortcuts import get_object_or_404 +from django.http import HttpResponse +from .pagination import PageNumberWithPageSizePagination + from .serializers import ( + SmallSportListSerializer, SportListSerializer, CriterionListSerializer, QuestionListSerializer, + SingleSportSerializer, + CriteriaSerializer, + IncompleteSportSerializer, ) from .models import Sport, Criterion, Question @@ -39,3 +49,214 @@ class QuestionListView(viewsets.ModelViewSet): # pylint: disable=too-many-ances serializer_class = QuestionListSerializer queryset = Question.objects.all() + + +# Dev Notes: +# - If we want to include a View in the Router in urls.py, the View needs to be a Viewset +# - Those are mostly meant for Lists of Objects, so instead of get() and post(), list() and create() are used respectively +# https://stackoverflow.com/questions/30389248/how-can-i-register-a-single-view-not-a-viewset-on-my-router + + +class SmallSportListView(viewsets.ViewSet): + """ + View for Sports and List of Sport: + List returns every Sport with the is_filled Field + Detail returns single Sports with every Criterium + """ + + authentication_classes = [] + + # GET for api/admin/sport/ + def list(self, request): + """ + GET for api/admin/sport/ + Returns a List of Every Sport with the is_filled Field, stating whether every Criterion is given a Rating or not + """ + + paginator = PageNumberWithPageSizePagination() + + sports = Sport.objects.all().order_by("name") + sports = paginator.paginate_queryset(sports, request) + is_filled_tuples = [] + + for sport in sports: + + is_filled_tuples.append((sport, sport.is_filled())) + + serializer = SmallSportListSerializer(is_filled_tuples) + + return paginator.get_paginated_response(serializer.data) + + # POST for api/admin/sport/ + def create(self, request): + """ + POST for api/admin/sport/ + View for Creating a New Sport + """ + + request_data = SingleSportSerializer().to_internal_value(request) + + # A Try at Error Catching + if request_data is None: + return HttpResponse(status=400) + + new_sport = Sport.objects.create_sport() + + new_sport.name = request_data["name"] + new_sport.url = request_data["url"] + + new_sport.save() + + response = SingleSportSerializer(new_sport) + + return Response(response.data) + + # GET for api/admin/sport/<id>/ + def retrieve(self, request, pk=None): + """ + GET for api/admin/sport/<pk>/ + View for getting a Single Sport, with the pk to the Sport being the argument in the URL + """ + + sport = get_object_or_404(Sport, pk=pk) + + response = SingleSportSerializer(sport) + + return Response(response.data) + + # PUT for api/admin/sport/<id>/ + def update(self, request, pk=None): + """ + PUT for api/admin/sport/<id>/ + Creates a Sport if it doesn't exist, otherwise overwrites it. + TODO: Maybe Rework PUT if needed of Admin Frontend + """ + + # Get Data from Serializer + request_data = SingleSportSerializer().to_internal_value(request) + + if request_data is None: + # Something is Broke, so Refuse Changing Data + return HttpResponse(status=400) + + # Get Sport from Data + sport = Sport.objects.get(pk=pk) + + # Apply New Data to Sport + sport.name = request_data["name"] + sport.url = request_data["url"] + + # Overwrite Criterion Ratings + for criterion, value in request_data["criteria"]: + sport.rate(criterion, value) + + # Re-Serialize changed Sport and Send it back + response = SingleSportSerializer(sport) + + return Response(response.data) + + # PATCH for api/admin/sport/<id>/ + def partial_update(self, request, pk=None): + """ + PATCH for api/admin/sport/<id>/ + Fills in the given Values into the Sport specified in the URL + """ + + # Get Data from Serializer + request_data = SingleSportSerializer().to_internal_value(request) + + if request_data is None: + # Something is Broke, so Refuse Changing Data + return HttpResponse(status=400) + + # Get Sport from Data + sport = Sport.objects.get(pk=pk) + + # Apply New Data to Sport, if it exists + if "name" in request_data.keys(): + sport.name = request_data["name"] + + if "url" in request_data.keys(): + sport.url = request_data["url"] + + # Overwrite Criterion Ratings + for criterion, value in request_data["criteria"]: + sport.rate(criterion, value) + + # Re-Serialize changed Sport and Send it back + response = SingleSportSerializer(sport) + + return Response(response.data) + + # DELETE for api/admin/sport/<id>/ + def destroy(self, request, pk=None): + """ + DELETE for api/admin/sport/<id>/ + Removes Sport Object specified in the URL + """ + + sport = get_object_or_404(Sport, pk=pk) + + sport.delete() + + return HttpResponse(status=404) + + +class IncompleteSportView(APIView): + """ + Returns all Sport Objects with Incomplete Ratings + """ + + authentication_classes = [] + + # GET for api/admin/sport/incomplete/ + def get(self, request): + """ + GET for api/admin/sport/incomplete/ + Returns every incomplete Sport with its incomplete Ratings in a paginated manner + """ + + paginator = PageNumberWithPageSizePagination() + queryset = Sport.objects.all().order_by("name") + queryset = paginator.paginate_queryset(queryset, request) + + incomplete_sport_list = [] + + for sport in queryset: + + if not sport.is_filled(): + incomplete_sport_list.append(sport) + + response = IncompleteSportSerializer(incomplete_sport_list) + + return paginator.get_paginated_response(response.data) + + +class CriteriaView(APIView): + """ + View for the List of Criteria and their Metadata + """ + + authentication_classes = [] + + # GET for api/admin/criteria/ + def get(self, request): + """ + GET for api/admin/criteria/ + Returns every Criterium and the Metadata of + Number of Sports in which the Rating is >1 + and the cumulated sum of Ratings >1 + TODO: Also Pagination + """ + + data = [] + + for crit in Criterion.objects.iterator(): + + active_sports, sum_of_weights = crit.get_active_sum() + + data.append((crit, active_sports, sum_of_weights)) + + response = CriteriaSerializer(data) + + return Response(response.data) diff --git a/unisportomat/unisportomat/urls.py b/unisportomat/unisportomat/urls.py index 801097cd6baa927bd468c4b170c198230b30dfe9..388d94e57dc1440659c8320eca2485f5bd78fee9 100644 --- a/unisportomat/unisportomat/urls.py +++ b/unisportomat/unisportomat/urls.py @@ -22,8 +22,15 @@ router = routers.DefaultRouter() router.register(r"sport-list", views.SportListView, "sport-list") router.register(r"criterion-list", views.CriterionListView, "criterion-list") router.register(r"question-list", views.QuestionListView, "question-list") +router.register(r"small-sport-list", views.SmallSportListView, "small-sport-list") urlpatterns = [ path("admin/", admin.site.urls), path("api/admin/", include(router.urls)), + path( + "api/admin/sport/incomplete/", + views.IncompleteSportView.as_view(), + name="incomplete", + ), + path("api/admin/criteria/", views.CriteriaView.as_view(), name="criteria"), ]