Skip to content
Snippets Groups Projects
Commit ea8198f1 authored by josiepark's avatar josiepark
Browse files

Merge branch '46-implement-the-adminfrontend-api' into 'master'

Resolve "Implement the AdminFrontend API"

See merge request swp-unisport/team-warumkeinrust/unisport-o-mat!45
parents 80033041 ac38183c
No related branches found
No related tags found
No related merge requests found
......@@ -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
......@@ -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
......@@ -2,6 +2,7 @@
{
"model": "quiz.criterion",
"pk": 1,
"question": 1,
"fields": {
"name": "Outdoorsport"
}
......
......@@ -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():
......
""" 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"""
......
......@@ -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
......@@ -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)
......@@ -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)
......@@ -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"),
]
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment