diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml new file mode 100644 index 0000000..fd2d1c3 --- /dev/null +++ b/.github/workflows/pypi.yml @@ -0,0 +1,26 @@ +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.8' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/serialization_spec/serialization.py b/serialization_spec/serialization.py index 5c9c911..cb092e5 100644 --- a/serialization_spec/serialization.py +++ b/serialization_spec/serialization.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ImproperlyConfigured from django.db.models import Prefetch from rest_framework.utils import model_meta from rest_framework.fields import Field @@ -6,6 +7,7 @@ from typing import List, Dict, Union from collections import OrderedDict +import copy """ Parse a serialization spec such as: @@ -201,18 +203,36 @@ def prefetch_related(request_user, queryset, model, prefixes, serialization_spec return queryset +def get_serialization_spec(view_or_plugin, request_user=None): + if hasattr(view_or_plugin, 'get_serialization_spec'): + view_or_plugin.request_user = request_user + return view_or_plugin.get_serialization_spec() + return getattr(view_or_plugin, 'serialization_spec', None) + + def expand_nested_specs(serialization_spec, request_user): - def get_serialization_spec(serialization_spec_plugin): - if hasattr(serialization_spec_plugin, 'get_serialization_spec'): - serialization_spec_plugin.request_user = request_user - return serialization_spec_plugin.get_serialization_spec() - return getattr(serialization_spec_plugin, 'serialization_spec', []) + expanded_serialization_spec = [] + + for each in serialization_spec: + if not isinstance(each, dict): + expanded_serialization_spec.append(each) + else: + expanded_dict = {} + for key, childspec in each.items(): + if isinstance(childspec, SerializationSpecPlugin): + serialization_spec = get_serialization_spec(childspec, request_user) + if serialization_spec is not None: + plugin_copy = copy.deepcopy(childspec) + plugin_copy.serialization_spec = expand_nested_specs(plugin_copy.serialization_spec, request_user) + expanded_serialization_spec += plugin_copy.serialization_spec + expanded_dict[key] = plugin_copy + else: + expanded_dict[key] = childspec + else: + expanded_dict[key] = expand_nested_specs(childspec, request_user) + expanded_serialization_spec.append(expanded_dict) - return serialization_spec + sum([ - get_serialization_spec(childspec) - for each in serialization_spec if isinstance(each, dict) - for key, childspec in each.items() if isinstance(childspec, SerializationSpecPlugin) - ], []) + return expanded_serialization_spec class NormalisedSpec: @@ -278,8 +298,9 @@ def get_object(self): def get_queryset(self): queryset = self.queryset - if hasattr(self, 'get_serialization_spec'): - self.serialization_spec = self.get_serialization_spec() + self.serialization_spec = get_serialization_spec(self) + if self.serialization_spec is None: + raise ImproperlyConfigured('SerializationSpecMixin requires serialization_spec or get_serialization_spec') expand_many2many_id_fields(queryset.model, self.serialization_spec) serialization_spec = expand_nested_specs(self.serialization_spec, self.request.user) serialization_spec = normalise_spec(serialization_spec) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..4b1d97c --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,369 @@ +import json +from django.core.exceptions import ImproperlyConfigured +from django.test import TestCase +from rest_framework.test import APIClient +from django.core.urlresolvers import reverse +from django.db import connection +from django.test.utils import CaptureQueriesContext + +from .models import LEA, School, Teacher, Subject, Class, Student, Assignment, AssignmentStudent + + +class BaseTestCase(TestCase): + + def setUp(self): + super().setUp() + self.client = APIClient() + + def assert_status(self, response, status_code): + return self.assertEqual( + response.status_code, + status_code, + 'Status code of {} does not match expectation of {}.\nThe response body states {}'.format( + response.status_code, + status_code, + str(response.data) + ) + ) + + def assertJsonEqual(self, expected, actual): + self.assertEqual( + json.dumps(expected, indent=4, sort_keys=True), + json.dumps(actual, indent=4, sort_keys=True) + ) + + +def uuid(tail): + return '00000000-0000-0000-0000-000000000000'[:-len(tail)] + tail + + +class SerializationSpecTestCase(BaseTestCase): + + def setUp(self): + self.maxDiff = None + + self.lea = LEA.objects.create(id=uuid('0'), name='Brighton & Hove') + self.school = School.objects.create(id=uuid('1'), name='Kitteh High', lea=self.lea) + School.objects.create(id=uuid('8'), name='Hove High', lea=self.lea) + self.teacher = Teacher.objects.create(id=uuid('2'), name='Mr Cat', school=self.school) + Teacher.objects.create(id=uuid('7'), name='Ms Dog', school=self.school) + self.french = Subject.objects.create(id=uuid('3'), name='French') + self.math = Subject.objects.create(id=uuid('4'), name='Math') + self.french_class = Class.objects.create(id=uuid('5'), name='French A', subject=self.french, teacher=self.teacher) + self.math_class = Class.objects.create(id=uuid('6'), name='Math B', subject=self.math, teacher=self.teacher) + students = [ + Student.objects.create(id=uuid('1%d' % idx), name='Student %d' % idx, school=self.school) + for idx in range(10) + ] + self.french_class.student_set.set(students[:7]) + self.math_class.student_set.set(students[3:]) + + self.student = students[5] + for clasz in [self.french_class, self.math_class]: + is_math = clasz == self.math_class + assignment = Assignment.objects.create(id=uuid('2%d' % (0 if is_math else 1)), clasz=clasz, name=clasz.name + ' Assignment') + AssignmentStudent.objects.create( + assignment=assignment, student=self.student, is_complete=is_math + ) + self.assignment = assignment + + +class DetailViewTestCase(SerializationSpecTestCase): + + def test_single_fk_and_reverse_fk(self): + with CaptureQueriesContext(connection) as capture: + url = reverse('teacher-detail', kwargs={'id': str(self.teacher.id)}) + response = self.client.get(url) + + self.assertJsonEqual( + sorted(query['sql'] for query in capture.captured_queries), + [ + """SELECT "tests_class"."id", "tests_class"."name", "tests_class"."teacher_id" FROM "tests_class" WHERE "tests_class"."teacher_id" IN ('00000000-0000-0000-0000-000000000002'::uuid)""", + """SELECT "tests_teacher"."id", "tests_teacher"."name", "tests_teacher"."school_id", "tests_school"."id", "tests_school"."created", "tests_school"."modified", "tests_school"."name", "tests_school"."lea_id" FROM "tests_teacher" INNER JOIN "tests_school" ON ("tests_teacher"."school_id" = "tests_school"."id") WHERE "tests_teacher"."id" = '00000000-0000-0000-0000-000000000002'::uuid""", + ] + ) + + self.assertJsonEqual(response.data, { + 'id': uuid('2'), + 'name': 'Mr Cat', + 'school': { # FK + 'id': uuid('1'), + 'name': 'Kitteh High', + }, + "class_set": [ # reverse FK + { + "id": uuid("6"), + "name": "Math B" + }, + { + "id": uuid("5"), + "name": "French A" + } + ], + }) + + def test_single_many_to_many(self): + with CaptureQueriesContext(connection) as capture: + url = reverse('student-detail', kwargs={'id': str(self.student.id)}) + response = self.client.get(url) + + self.assertJsonEqual( + sorted(query['sql'] for query in capture.captured_queries), + [ + """SELECT "tests_student"."id", "tests_student"."name" FROM "tests_student" WHERE "tests_student"."id" = '00000000-0000-0000-0000-000000000015'::uuid""", + """SELECT ("tests_student_classes"."student_id") AS "_prefetch_related_val_student_id", "tests_class"."id", "tests_class"."name" FROM "tests_class" INNER JOIN "tests_student_classes" ON ("tests_class"."id" = "tests_student_classes"."class_id") WHERE "tests_student_classes"."student_id" IN ('00000000-0000-0000-0000-000000000015'::uuid)""" + ] + ) + + self.assertJsonEqual(response.data, { + 'id': uuid('15'), + 'name': 'Student 5', + "classes": [ # M:M + { + "id": uuid("5"), + "name": "French A" + }, + { + "id": uuid("6"), + "name": "Math B" + }, + ], + }) + + def test_single_fk_on_fk_and_reverse_m2m(self): + with CaptureQueriesContext(connection) as capture: + url = reverse('class-detail', kwargs={'id': str(self.math_class.id)}) + response = self.client.get(url) + + self.assertJsonEqual( + sorted(query['sql'] for query in capture.captured_queries), + [ + """SELECT "tests_class"."id", "tests_class"."name", "tests_class"."teacher_id", "tests_teacher"."id", "tests_teacher"."created", "tests_teacher"."modified", "tests_teacher"."name", "tests_teacher"."school_id", "tests_school"."id", "tests_school"."created", "tests_school"."modified", "tests_school"."name", "tests_school"."lea_id" FROM "tests_class" INNER JOIN "tests_teacher" ON ("tests_class"."teacher_id" = "tests_teacher"."id") INNER JOIN "tests_school" ON ("tests_teacher"."school_id" = "tests_school"."id") WHERE "tests_class"."id" = '00000000-0000-0000-0000-000000000006'::uuid""", + """SELECT ("tests_student_classes"."class_id") AS "_prefetch_related_val_class_id", "tests_student"."id", "tests_student"."name" FROM "tests_student" INNER JOIN "tests_student_classes" ON ("tests_student"."id" = "tests_student_classes"."student_id") WHERE "tests_student_classes"."class_id" IN ('00000000-0000-0000-0000-000000000006'::uuid)""" + ] + ) + + self.assertJsonEqual(response.data, { + 'id': uuid('6'), + 'name': 'Math B', + "teacher": { # FK + "id": uuid("2"), + "name": "Mr Cat", + "school": { # FK > FK + "id": uuid("1"), + "name": "Kitteh High" + }, + }, + 'student_set': [ + {'name': 'Student 3'}, {'name': 'Student 4'}, {'name': 'Student 5'}, {'name': 'Student 6'}, {'name': 'Student 7'}, {'name': 'Student 8'}, {'name': 'Student 9'}, + ] + }) + + def test_single_fk_on_many_to_many(self): + with CaptureQueriesContext(connection) as capture: + url = reverse('subject-detail', kwargs={'id': str(self.math.id)}) + response = self.client.get(url) + + self.assertJsonEqual( + sorted(query['sql'] for query in capture.captured_queries), + [ + """SELECT "tests_class"."id", "tests_class"."subject_id", "tests_class"."name", "tests_class"."teacher_id", "tests_teacher"."id", "tests_teacher"."created", "tests_teacher"."modified", "tests_teacher"."name", "tests_teacher"."school_id" FROM "tests_class" INNER JOIN "tests_teacher" ON ("tests_class"."teacher_id" = "tests_teacher"."id") WHERE "tests_class"."subject_id" IN ('00000000-0000-0000-0000-000000000004'::uuid)""", + """SELECT "tests_subject"."id", "tests_subject"."name" FROM "tests_subject" WHERE "tests_subject"."id" = '00000000-0000-0000-0000-000000000004'::uuid""", + ] + ) + + self.assertJsonEqual(response.data, { + "id": uuid("4"), + "name": "Math", + "class_set": [ + { + "id": uuid("6"), + "name": "Math B", + "teacher": { + "id": uuid("2"), + "name": "Mr Cat" + } + } + ], + }) + + def test_single_reverse_fk_on_fk(self): + with CaptureQueriesContext(connection) as capture: + url = reverse('school-detail', kwargs={'id': str(self.school.id)}) + response = self.client.get(url) + + self.assertJsonEqual( + sorted(query['sql'] for query in capture.captured_queries), + [ + """SELECT "tests_school"."id", "tests_school"."name", "tests_school"."lea_id" FROM "tests_school" WHERE "tests_school"."lea_id" IN ('00000000-0000-0000-0000-000000000000'::uuid)""", + """SELECT "tests_school"."id", "tests_school"."name", "tests_school"."lea_id", "tests_lea"."id", "tests_lea"."created", "tests_lea"."modified", "tests_lea"."name" FROM "tests_school" INNER JOIN "tests_lea" ON ("tests_school"."lea_id" = "tests_lea"."id") WHERE "tests_school"."id" = '00000000-0000-0000-0000-000000000001'::uuid""", + ] + ) + + self.assertJsonEqual(response.data, { + "id": uuid("1"), + "name": "Kitteh High", + "lea": { + "id": uuid("0"), + "name": "Brighton & Hove", + "school_set": [ + { + "id": uuid("8"), + "name": "Hove High" + }, + { + "id": uuid("1"), + "name": "Kitteh High" + }, + ] + }, + }) + + def test_single_many_to_many_with_through(self): + with CaptureQueriesContext(connection) as capture: + url = reverse('student-with-assignments-detail', kwargs={'id': str(self.student.id)}) + response = self.client.get(url) + + self.assertJsonEqual( + sorted(query['sql'] for query in capture.captured_queries), + [ + """SELECT "tests_assignmentstudent"."id", "tests_assignmentstudent"."is_complete", "tests_assignmentstudent"."assignment_id", "tests_assignmentstudent"."student_id", "tests_assignment"."id", "tests_assignment"."created", "tests_assignment"."modified", "tests_assignment"."name", "tests_assignment"."clasz_id" FROM "tests_assignmentstudent" INNER JOIN "tests_assignment" ON ("tests_assignmentstudent"."assignment_id" = "tests_assignment"."id") WHERE "tests_assignmentstudent"."student_id" IN ('00000000-0000-0000-0000-000000000015'::uuid)""", + """SELECT "tests_student"."id", "tests_student"."name" FROM "tests_student" WHERE "tests_student"."id" = '00000000-0000-0000-0000-000000000015'::uuid""", + """SELECT ("tests_assignmentstudent"."student_id") AS "_prefetch_related_val_student_id", "tests_assignment"."id", "tests_assignment"."name" FROM "tests_assignment" INNER JOIN "tests_assignmentstudent" ON ("tests_assignment"."id" = "tests_assignmentstudent"."assignment_id") WHERE "tests_assignmentstudent"."student_id" IN ('00000000-0000-0000-0000-000000000015'::uuid)""", + ] + ) + + self.assertJsonEqual(response.data, { + 'id': uuid('15'), + 'name': 'Student 5', + "assignments": [ # M:M + {"name": "French A Assignment"}, + {"name": "Math B Assignment"} + ], + "assignmentstudent_set": [ # M:M through relation + { + "assignment": {"name": "French A Assignment"}, + "is_complete": False + }, + { + "assignment": {"name": "Math B Assignment"}, + "is_complete": True + } + ], + }) + + def test_single_count_plugin(self): + with CaptureQueriesContext(connection) as capture: + url = reverse('assignment-detail', kwargs={'id': str(self.assignment.id)}) + response = self.client.get(url) + + self.assertJsonEqual( + sorted(query['sql'] for query in capture.captured_queries), + [ + """SELECT "tests_assignment"."id", "tests_assignment"."name", "tests_assignment"."clasz_id" FROM "tests_assignment" WHERE "tests_assignment"."id" = '00000000-0000-0000-0000-000000000020'::uuid""", + """SELECT "tests_class"."id", "tests_class"."name", "tests_class"."teacher_id", COUNT(DISTINCT "tests_student_classes"."student_id") AS "student_count", "tests_teacher"."id", "tests_teacher"."created", "tests_teacher"."modified", "tests_teacher"."name", "tests_teacher"."school_id" FROM "tests_class" LEFT OUTER JOIN "tests_student_classes" ON ("tests_class"."id" = "tests_student_classes"."class_id") INNER JOIN "tests_teacher" ON ("tests_class"."teacher_id" = "tests_teacher"."id") WHERE "tests_class"."id" IN ('00000000-0000-0000-0000-000000000006'::uuid) GROUP BY "tests_class"."id", "tests_teacher"."id\"""", + """SELECT ("tests_assignmentstudent"."assignment_id") AS "_prefetch_related_val_assignment_id", "tests_student"."id", "tests_student"."name", COUNT(DISTINCT "tests_student_classes"."class_id") AS "classes_count" FROM "tests_student" LEFT OUTER JOIN "tests_student_classes" ON ("tests_student"."id" = "tests_student_classes"."student_id") INNER JOIN "tests_assignmentstudent" ON ("tests_student"."id" = "tests_assignmentstudent"."student_id") WHERE "tests_assignmentstudent"."assignment_id" IN ('00000000-0000-0000-0000-000000000020'::uuid) GROUP BY ("tests_assignmentstudent"."assignment_id"), "tests_student"."id\"""", + """SELECT ("tests_student_classes"."student_id") AS "_prefetch_related_val_student_id", "tests_class"."id" FROM "tests_class" INNER JOIN "tests_student_classes" ON ("tests_class"."id" = "tests_student_classes"."class_id") WHERE "tests_student_classes"."student_id" IN ('00000000-0000-0000-0000-000000000015'::uuid)""", + ] + ) + + self.assertJsonEqual(response.data, { + "id": uuid("20"), + "name": "Math B Assignment", + "assignees": [ + { + "id": uuid("15"), + "name": "Student 5", + "classes_count": 2, + "classes": [ + uuid("5"), + uuid("6"), + ] + } + ], + "class_name": "Math B - Mr Cat", + "clasz": { + "num_students": 7 + }, + }) + + +class ListViewTestCase(SerializationSpecTestCase): + + def test_single_fk_and_reverse_fk(self): + with CaptureQueriesContext(connection) as capture: + response = self.client.get(reverse('teacher-list')) + + self.assertJsonEqual( + sorted(query['sql'] for query in capture.captured_queries), + [ + """SELECT "tests_class"."id", "tests_class"."name", "tests_class"."teacher_id" FROM "tests_class" WHERE "tests_class"."teacher_id" IN ('00000000-0000-0000-0000-000000000002'::uuid, '00000000-0000-0000-0000-000000000007'::uuid)""", + """SELECT "tests_school"."id", "tests_school"."name" FROM "tests_school" WHERE "tests_school"."id" IN ('00000000-0000-0000-0000-000000000001'::uuid)""", + """SELECT "tests_teacher"."id", "tests_teacher"."name", "tests_teacher"."school_id" FROM "tests_teacher" ORDER BY "tests_teacher"."name" ASC LIMIT 2""", + """SELECT COUNT(*) AS "__count" FROM "tests_teacher\"""", + ] + ) + + self.assertJsonEqual(response.data, { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + 'id': uuid('2'), + 'name': 'Mr Cat', + 'school': { # FK + 'id': uuid('1'), + 'name': 'Kitteh High', + }, + "class_set": [ # reverse FK + { + "id": uuid("5"), + "name": "French A" + }, + { + "id": uuid("6"), + "name": "Math B" + }, + ], + }, + { + "id": uuid('7'), + "name": "Ms Dog", + "school": { + "id": uuid("1"), + "name": "Kitteh High" + }, + "class_set": [], + } + ] + }) + + +class MisconfiguredViewTestCase(SerializationSpecTestCase): + + def test_view_must_have_serialization_spec(self): + with self.assertRaises(ImproperlyConfigured) as cm: + self.client.get(reverse('misconfigured')) + + self.assertEqual(str(cm.exception), 'SerializationSpecMixin requires serialization_spec or get_serialization_spec') + + +class CollidingFieldsRegressionTestCase(SerializationSpecTestCase): + + def test_multiple_many_to_many_fields_do_not_collide(self): + url = reverse('student-with-classes-and-assignments-detail', kwargs={'id': str(self.student.id)}) + response = self.client.get(url) + + self.assertJsonEqual(response.data, { + 'id': uuid('15'), + 'name': 'Student 5', + "assignments": [ + uuid('21'), + uuid('20'), + ], + "classes": [ + uuid('5'), + uuid('6'), + ], + }) diff --git a/tests/test_normalisation.py b/tests/test_normalisation.py new file mode 100644 index 0000000..3f08b0c --- /dev/null +++ b/tests/test_normalisation.py @@ -0,0 +1,88 @@ +from django.test import TestCase + +from serialization_spec.serialization import normalise_spec + + +class NormalisationTestCase(TestCase): + + def test_base_case(self): + spec = [ + 'one', + {'two': [ + 'three', + ]}, + {'four': []}, + ] + + self.assertEqual(normalise_spec(spec), [ + 'one', + { + 'two': [ + 'three', + ], + 'four': [], + }, + ]) + + def test_merge_dupes_one_level(self): + spec = [ + 'one', + {'two': [ + 'three', + ]}, + 'one', + ] + + self.assertEqual(normalise_spec(spec), [ + 'one', + {'two': [ + 'three', + ]}, + ]) + + def test_merge_dupes_two_levels(self): + spec = [ + 'one', + {'two': [ + 'three', + ]}, + {'two': [ + 'four', + ]}, + ] + + self.assertEqual(normalise_spec(spec), [ + 'one', + {'two': [ + 'three', + 'four', + ]}, + ]) + + def test_merge_dupes_three_levels(self): + spec = [ + 'one', + {'two': [ + {'three': [ + 'five' + ]} + ]}, + {'two': [ + 'four', + {'three': [ + 'five', + 'six' + ]} + ]}, + ] + + self.assertEqual(normalise_spec(spec), [ + 'one', + {'two': [ + 'four', + {'three': [ + 'five', + 'six', + ]} + ]} + ]) diff --git a/tests/tests.py b/tests/tests.py index 9b5bd21..a0509ad 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,445 +1,173 @@ -import json -from django.test import TestCase -from rest_framework.test import APIClient -from django.core.urlresolvers import reverse -from django.db import connection -from django.test.utils import CaptureQueriesContext +from .test_api import SerializationSpecTestCase, uuid +from .models import Teacher, Class -from serialization_spec.serialization import normalise_spec -from .models import LEA, School, Teacher, Subject, Class, Student, Assignment, AssignmentStudent +from rest_framework import generics +from unittest.mock import MagicMock +from serialization_spec.serialization import SerializationSpecMixin, SerializationSpecPlugin -class BaseTestCase(TestCase): +class PluginsTestCase(SerializationSpecTestCase): + + class TeacherDetailView(SerializationSpecMixin, generics.RetrieveAPIView): + queryset = Teacher.objects.all() def setUp(self): super().setUp() - self.client = APIClient() - - def assert_status(self, response, status_code): - return self.assertEqual( - response.status_code, - status_code, - 'Status code of {} does not match expectation of {}.\nThe response body states {}'.format( - response.status_code, - status_code, - str(response.data) - ) - ) - def assertJsonEqual(self, expected, actual): - self.assertEqual( - json.dumps(expected, indent=4, sort_keys=True), - json.dumps(actual, indent=4, sort_keys=True) + self.request = MagicMock() + self.request.method = 'GET' + self.request.user = 'USER_1' + self.detail_view = self.TeacherDetailView( + request=self.request, + kwargs={'pk': Teacher.objects.first().id}, + format_kwarg=None ) - -def uuid(tail): - return '00000000-0000-0000-0000-000000000000'[:-len(tail)] + tail - - -class SerializationSpecTestCase(BaseTestCase): - - def setUp(self): - self.maxDiff = None - - self.lea = LEA.objects.create(id=uuid('0'), name='Brighton & Hove') - self.school = School.objects.create(id=uuid('1'), name='Kitteh High', lea=self.lea) - School.objects.create(id=uuid('8'), name='Hove High', lea=self.lea) - self.teacher = Teacher.objects.create(id=uuid('2'), name='Mr Cat', school=self.school) - Teacher.objects.create(id=uuid('7'), name='Ms Dog', school=self.school) - self.french = Subject.objects.create(id=uuid('3'), name='French') - self.math = Subject.objects.create(id=uuid('4'), name='Math') - self.french_class = Class.objects.create(id=uuid('5'), name='French A', subject=self.french, teacher=self.teacher) - self.math_class = Class.objects.create(id=uuid('6'), name='Math B', subject=self.math, teacher=self.teacher) - students = [ - Student.objects.create(id=uuid('1%d' % idx), name='Student %d' % idx, school=self.school) - for idx in range(10) + def test_simple_spec(self): + self.detail_view.serialization_spec = [ + 'id', + 'name' ] - self.french_class.student_set.set(students[:7]) - self.math_class.student_set.set(students[3:]) - - self.student = students[5] - for clasz in [self.french_class, self.math_class]: - is_math = clasz == self.math_class - assignment = Assignment.objects.create(id=uuid('2%d' % (0 if is_math else 1)), clasz=clasz, name=clasz.name + ' Assignment') - AssignmentStudent.objects.create( - assignment=assignment, student=self.student, is_complete=is_math - ) - self.assignment = assignment - - -class DetailViewTestCase(SerializationSpecTestCase): - - def test_single_fk_and_reverse_fk(self): - with CaptureQueriesContext(connection) as capture: - url = reverse('teacher-detail', kwargs={'id': str(self.teacher.id)}) - response = self.client.get(url) - - self.assertJsonEqual( - sorted(query['sql'] for query in capture.captured_queries), - [ - """SELECT "tests_class"."id", "tests_class"."name", "tests_class"."teacher_id" FROM "tests_class" WHERE "tests_class"."teacher_id" IN ('00000000-0000-0000-0000-000000000002'::uuid)""", - """SELECT "tests_teacher"."id", "tests_teacher"."name", "tests_teacher"."school_id", "tests_school"."id", "tests_school"."created", "tests_school"."modified", "tests_school"."name", "tests_school"."lea_id" FROM "tests_teacher" INNER JOIN "tests_school" ON ("tests_teacher"."school_id" = "tests_school"."id") WHERE "tests_teacher"."id" = '00000000-0000-0000-0000-000000000002'::uuid""", - ] - ) + response = self.detail_view.retrieve(self.request) self.assertJsonEqual(response.data, { - 'id': uuid('2'), - 'name': 'Mr Cat', - 'school': { # FK - 'id': uuid('1'), - 'name': 'Kitteh High', - }, - "class_set": [ # reverse FK - { - "id": uuid("6"), - "name": "Math B" - }, - { - "id": uuid("5"), - "name": "French A" - } - ], + "id": "00000000-0000-0000-0000-000000000002", + "name": "Mr Cat" }) - def test_single_many_to_many(self): - with CaptureQueriesContext(connection) as capture: - url = reverse('student-detail', kwargs={'id': str(self.student.id)}) - response = self.client.get(url) - - self.assertJsonEqual( - sorted(query['sql'] for query in capture.captured_queries), - [ - """SELECT "tests_student"."id", "tests_student"."name" FROM "tests_student" WHERE "tests_student"."id" = '00000000-0000-0000-0000-000000000015'::uuid""", - """SELECT ("tests_student_classes"."student_id") AS "_prefetch_related_val_student_id", "tests_class"."id", "tests_class"."name" FROM "tests_class" INNER JOIN "tests_student_classes" ON ("tests_class"."id" = "tests_student_classes"."class_id") WHERE "tests_student_classes"."student_id" IN ('00000000-0000-0000-0000-000000000015'::uuid)""" - ] - ) + def test_get_spec(self): + setattr(self.TeacherDetailView, 'get_serialization_spec', lambda self: [ + 'id', + 'name' + ]) + response = self.detail_view.retrieve(self.request) self.assertJsonEqual(response.data, { - 'id': uuid('15'), - 'name': 'Student 5', - "classes": [ # M:M - { - "id": uuid("5"), - "name": "French A" - }, - { - "id": uuid("6"), - "name": "Math B" - }, - ], + "id": "00000000-0000-0000-0000-000000000002", + "name": "Mr Cat" }) - def test_single_fk_on_fk_and_reverse_m2m(self): - with CaptureQueriesContext(connection) as capture: - url = reverse('class-detail', kwargs={'id': str(self.math_class.id)}) - response = self.client.get(url) + delattr(self.TeacherDetailView, 'get_serialization_spec') - self.assertJsonEqual( - sorted(query['sql'] for query in capture.captured_queries), - [ - """SELECT "tests_class"."id", "tests_class"."name", "tests_class"."teacher_id", "tests_teacher"."id", "tests_teacher"."created", "tests_teacher"."modified", "tests_teacher"."name", "tests_teacher"."school_id", "tests_school"."id", "tests_school"."created", "tests_school"."modified", "tests_school"."name", "tests_school"."lea_id" FROM "tests_class" INNER JOIN "tests_teacher" ON ("tests_class"."teacher_id" = "tests_teacher"."id") INNER JOIN "tests_school" ON ("tests_teacher"."school_id" = "tests_school"."id") WHERE "tests_class"."id" = '00000000-0000-0000-0000-000000000006'::uuid""", - """SELECT ("tests_student_classes"."class_id") AS "_prefetch_related_val_class_id", "tests_student"."id", "tests_student"."name" FROM "tests_student" INNER JOIN "tests_student_classes" ON ("tests_student"."id" = "tests_student_classes"."student_id") WHERE "tests_student_classes"."class_id" IN ('00000000-0000-0000-0000-000000000006'::uuid)""" + def test_spec_with_plugin(self): + class SchoolNameUpper(SerializationSpecPlugin): + serialization_spec = [ + {'school': [ + 'name', + ]} ] - ) - self.assertJsonEqual(response.data, { - 'id': uuid('6'), - 'name': 'Math B', - "teacher": { # FK - "id": uuid("2"), - "name": "Mr Cat", - "school": { # FK > FK - "id": uuid("1"), - "name": "Kitteh High" - }, - }, - 'student_set': [ - {'name': 'Student 3'}, {'name': 'Student 4'}, {'name': 'Student 5'}, {'name': 'Student 6'}, {'name': 'Student 7'}, {'name': 'Student 8'}, {'name': 'Student 9'}, - ] - }) + def get_value(self, instance): + return instance.school.name.upper() - def test_single_fk_on_many_to_many(self): - with CaptureQueriesContext(connection) as capture: - url = reverse('subject-detail', kwargs={'id': str(self.math.id)}) - response = self.client.get(url) + self.detail_view.serialization_spec = [ + {'school_name_upper': SchoolNameUpper()}, + ] - self.assertJsonEqual( - sorted(query['sql'] for query in capture.captured_queries), - [ - """SELECT "tests_class"."id", "tests_class"."subject_id", "tests_class"."name", "tests_class"."teacher_id", "tests_teacher"."id", "tests_teacher"."created", "tests_teacher"."modified", "tests_teacher"."name", "tests_teacher"."school_id" FROM "tests_class" INNER JOIN "tests_teacher" ON ("tests_class"."teacher_id" = "tests_teacher"."id") WHERE "tests_class"."subject_id" IN ('00000000-0000-0000-0000-000000000004'::uuid)""", - """SELECT "tests_subject"."id", "tests_subject"."name" FROM "tests_subject" WHERE "tests_subject"."id" = '00000000-0000-0000-0000-000000000004'::uuid""", - ] - ) + with self.assertNumQueries(1): + response = self.detail_view.retrieve(self.request) self.assertJsonEqual(response.data, { - "id": uuid("4"), - "name": "Math", - "class_set": [ - { - "id": uuid("6"), - "name": "Math B", - "teacher": { - "id": uuid("2"), - "name": "Mr Cat" - } - } - ], + "school_name_upper": "KITTEH HIGH" }) - def test_single_reverse_fk_on_fk(self): - with CaptureQueriesContext(connection) as capture: - url = reverse('school-detail', kwargs={'id': str(self.school.id)}) - response = self.client.get(url) - - self.assertJsonEqual( - sorted(query['sql'] for query in capture.captured_queries), - [ - """SELECT "tests_school"."id", "tests_school"."name", "tests_school"."lea_id" FROM "tests_school" WHERE "tests_school"."lea_id" IN ('00000000-0000-0000-0000-000000000000'::uuid)""", - """SELECT "tests_school"."id", "tests_school"."name", "tests_school"."lea_id", "tests_lea"."id", "tests_lea"."created", "tests_lea"."modified", "tests_lea"."name" FROM "tests_school" INNER JOIN "tests_lea" ON ("tests_school"."lea_id" = "tests_lea"."id") WHERE "tests_school"."id" = '00000000-0000-0000-0000-000000000001'::uuid""", + def test_merge_specs(self): + class ClassNames(SerializationSpecPlugin): + serialization_spec = [ + {'class_set': [ + 'name', + ]} ] - ) - - self.assertJsonEqual(response.data, { - "id": uuid("1"), - "name": "Kitteh High", - "lea": { - "id": uuid("0"), - "name": "Brighton & Hove", - "school_set": [ - { - "id": uuid("8"), - "name": "Hove High" - }, - { - "id": uuid("1"), - "name": "Kitteh High" - }, - ] - }, - }) - def test_single_many_to_many_with_through(self): - with CaptureQueriesContext(connection) as capture: - url = reverse('student-with-assignments-detail', kwargs={'id': str(self.student.id)}) - response = self.client.get(url) - - self.assertJsonEqual( - sorted(query['sql'] for query in capture.captured_queries), - [ - """SELECT "tests_assignmentstudent"."id", "tests_assignmentstudent"."is_complete", "tests_assignmentstudent"."assignment_id", "tests_assignmentstudent"."student_id", "tests_assignment"."id", "tests_assignment"."created", "tests_assignment"."modified", "tests_assignment"."name", "tests_assignment"."clasz_id" FROM "tests_assignmentstudent" INNER JOIN "tests_assignment" ON ("tests_assignmentstudent"."assignment_id" = "tests_assignment"."id") WHERE "tests_assignmentstudent"."student_id" IN ('00000000-0000-0000-0000-000000000015'::uuid)""", - """SELECT "tests_student"."id", "tests_student"."name" FROM "tests_student" WHERE "tests_student"."id" = '00000000-0000-0000-0000-000000000015'::uuid""", - """SELECT ("tests_assignmentstudent"."student_id") AS "_prefetch_related_val_student_id", "tests_assignment"."id", "tests_assignment"."name" FROM "tests_assignment" INNER JOIN "tests_assignmentstudent" ON ("tests_assignment"."id" = "tests_assignmentstudent"."assignment_id") WHERE "tests_assignmentstudent"."student_id" IN ('00000000-0000-0000-0000-000000000015'::uuid)""", - ] - ) + def get_value(self, instance): + return ', '.join(each.name for each in instance.class_set.all()) - self.assertJsonEqual(response.data, { - 'id': uuid('15'), - 'name': 'Student 5', - "assignments": [ # M:M - {"name": "French A Assignment"}, - {"name": "Math B Assignment"} - ], - "assignmentstudent_set": [ # M:M through relation - { - "assignment": {"name": "French A Assignment"}, - "is_complete": False - }, - { - "assignment": {"name": "Math B Assignment"}, - "is_complete": True - } - ], - }) - - def test_single_count_plugin(self): - with CaptureQueriesContext(connection) as capture: - url = reverse('assignment-detail', kwargs={'id': str(self.assignment.id)}) - response = self.client.get(url) - - self.assertJsonEqual( - sorted(query['sql'] for query in capture.captured_queries), - [ - """SELECT "tests_assignment"."id", "tests_assignment"."name", "tests_assignment"."clasz_id" FROM "tests_assignment" WHERE "tests_assignment"."id" = '00000000-0000-0000-0000-000000000020'::uuid""", - """SELECT "tests_class"."id", "tests_class"."name", "tests_class"."teacher_id", COUNT(DISTINCT "tests_student_classes"."student_id") AS "student_count", "tests_teacher"."id", "tests_teacher"."created", "tests_teacher"."modified", "tests_teacher"."name", "tests_teacher"."school_id" FROM "tests_class" LEFT OUTER JOIN "tests_student_classes" ON ("tests_class"."id" = "tests_student_classes"."class_id") INNER JOIN "tests_teacher" ON ("tests_class"."teacher_id" = "tests_teacher"."id") WHERE "tests_class"."id" IN ('00000000-0000-0000-0000-000000000006'::uuid) GROUP BY "tests_class"."id", "tests_teacher"."id\"""", - """SELECT ("tests_assignmentstudent"."assignment_id") AS "_prefetch_related_val_assignment_id", "tests_student"."id", "tests_student"."name", COUNT(DISTINCT "tests_student_classes"."class_id") AS "classes_count" FROM "tests_student" LEFT OUTER JOIN "tests_student_classes" ON ("tests_student"."id" = "tests_student_classes"."student_id") INNER JOIN "tests_assignmentstudent" ON ("tests_student"."id" = "tests_assignmentstudent"."student_id") WHERE "tests_assignmentstudent"."assignment_id" IN ('00000000-0000-0000-0000-000000000020'::uuid) GROUP BY ("tests_assignmentstudent"."assignment_id"), "tests_student"."id\"""", - """SELECT ("tests_student_classes"."student_id") AS "_prefetch_related_val_student_id", "tests_class"."id" FROM "tests_class" INNER JOIN "tests_student_classes" ON ("tests_class"."id" = "tests_student_classes"."class_id") WHERE "tests_student_classes"."student_id" IN ('00000000-0000-0000-0000-000000000015'::uuid)""", + class SubjectNames(SerializationSpecPlugin): + serialization_spec = [ + {'class_set': [ + {'subject': [ + 'name', + ]} + ]} ] - ) - - self.assertJsonEqual(response.data, { - "id": uuid("20"), - "name": "Math B Assignment", - "assignees": [ - { - "id": uuid("15"), - "name": "Student 5", - "classes_count": 2, - "classes": [ - uuid("5"), - uuid("6"), - ] - } - ], - "class_name": "Math B - Mr Cat", - "clasz": { - "num_students": 7 - }, - }) + def get_value(self, instance): + return ', '.join(each.subject.name for each in instance.class_set.all()) -class ListViewTestCase(SerializationSpecTestCase): - - def test_single_fk_and_reverse_fk(self): - with CaptureQueriesContext(connection) as capture: - response = self.client.get(reverse('teacher-list')) + self.detail_view.serialization_spec = [ + 'name', + {'subject_names': SubjectNames()}, + {'classes_names': ClassNames()}, + ] - self.assertJsonEqual( - sorted(query['sql'] for query in capture.captured_queries), - [ - """SELECT "tests_class"."id", "tests_class"."name", "tests_class"."teacher_id" FROM "tests_class" WHERE "tests_class"."teacher_id" IN ('00000000-0000-0000-0000-000000000002'::uuid, '00000000-0000-0000-0000-000000000007'::uuid)""", - """SELECT "tests_school"."id", "tests_school"."name" FROM "tests_school" WHERE "tests_school"."id" IN ('00000000-0000-0000-0000-000000000001'::uuid)""", - """SELECT "tests_teacher"."id", "tests_teacher"."name", "tests_teacher"."school_id" FROM "tests_teacher" ORDER BY "tests_teacher"."name" ASC LIMIT 2""", - """SELECT COUNT(*) AS "__count" FROM "tests_teacher\"""", - ] - ) + response = self.detail_view.retrieve(self.request) self.assertJsonEqual(response.data, { - "count": 2, - "next": None, - "previous": None, - "results": [ - { - 'id': uuid('2'), - 'name': 'Mr Cat', - 'school': { # FK - 'id': uuid('1'), - 'name': 'Kitteh High', - }, - "class_set": [ # reverse FK - { - "id": uuid("5"), - "name": "French A" - }, - { - "id": uuid("6"), - "name": "Math B" - }, - ], - }, - { - "id": uuid('7'), - "name": "Ms Dog", - "school": { - "id": uuid("1"), - "name": "Kitteh High" - }, - "class_set": [], - } - ] + "name": "Mr Cat", + "classes_names": "Math B, French A", + "subject_names": "Math, French" }) - -class NormalisationTestCase(TestCase): - - def test_base_case(self): - spec = [ - 'one', - {'two': [ - 'three', - ]}, - {'four': []}, + def test_reverse_fk_list_ids(self): + self.detail_view.serialization_spec = [ + 'class_set' ] - self.assertEqual(normalise_spec(spec), [ - 'one', - { - 'two': [ - 'three', - ], - 'four': [], - }, - ]) + response = self.detail_view.retrieve(self.request) + self.assertEqual( + [str(id) for id in response.data['class_set']], + [uuid('6'), uuid('5')] + ) - def test_merge_dupes_one_level(self): - spec = [ - 'one', - {'two': [ - 'three', - ]}, - 'one', - ] + def test_many_to_many_list_ids(self): + class ClassDetailView(SerializationSpecMixin, generics.RetrieveAPIView): + queryset = Class.objects.all() - self.assertEqual(normalise_spec(spec), [ - 'one', - {'two': [ - 'three', - ]}, - ]) + serialization_spec = [ + 'student_set' + ] - def test_merge_dupes_two_levels(self): - spec = [ - 'one', - {'two': [ - 'three', - ]}, - {'two': [ - 'four', - ]}, - ] + detail_view = ClassDetailView( + request=self.request, + kwargs={'pk': Class.objects.first().id}, + format_kwarg=None + ) - self.assertEqual(normalise_spec(spec), [ - 'one', - {'two': [ - 'three', - 'four', - ]}, - ]) + response = detail_view.retrieve(self.request) + self.assertEqual( + [str(id) for id in response.data['student_set']], + [uuid('10'), uuid('11'), uuid('12'), uuid('13'), uuid('14'), uuid('15'), uuid('16')] + ) - def test_merge_dupes_three_levels(self): - spec = [ - 'one', - {'two': [ - {'three': [ - 'five' + def test_spec_with_nested_plugin(self): + class LeaName(SerializationSpecPlugin): + serialization_spec = [ + {'lea': [ + 'name', ]} - ]}, - {'two': [ - 'four', - {'three': [ - 'five', - 'six' - ]} - ]}, - ] + ] + + def get_value(self, instance): + return instance.lea.name - self.assertEqual(normalise_spec(spec), [ - 'one', - {'two': [ - 'four', - {'three': [ - 'five', - 'six', + class SchoolNameUpper(SerializationSpecPlugin): + serialization_spec = [ + {'school': [ + 'name', + {'lea_name': LeaName()} ]} - ]} - ]) + ] + def get_value(self, instance): + return (instance.school.lea.name + ': ' + instance.school.name).upper() -class CollidingFieldsRegressionTestCase(SerializationSpecTestCase): + self.detail_view.serialization_spec = [ + {'school_name_upper': SchoolNameUpper()}, + ] - def test_multiple_many_to_many_fields_do_not_collide(self): - url = reverse('student-with-classes-and-assignments-detail', kwargs={'id': str(self.student.id)}) - response = self.client.get(url) + with self.assertNumQueries(2): + response = self.detail_view.retrieve(self.request) self.assertJsonEqual(response.data, { - 'id': uuid('15'), - 'name': 'Student 5', - "assignments": [ - uuid('21'), - uuid('20'), - ], - "classes": [ - uuid('5'), - uuid('6'), - ], + "school_name_upper": "BRIGHTON & HOVE: KITTEH HIGH" }) diff --git a/tests/urls.py b/tests/urls.py index 8aa9c7a..c58a598 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -11,4 +11,5 @@ url(r'^students-detail/(?P[0-9a-f-]+)/$', view=views.StudentWithAssignmentsDetailView.as_view(), name='student-with-assignments-detail'), url(r'^assignments/(?P[0-9a-f-]+)/$', view=views.AssignmentDetailView.as_view(), name='assignment-detail'), url(r'^students-with-classes-and-assignments/(?P[0-9a-f-]+)/$', view=views.StudentWithClassesAndAssignmentsDetailView.as_view(), name='student-with-classes-and-assignments-detail'), + url(r'^misconfigured/$', view=views.MisconfiguredView.as_view(), name='misconfigured'), ] diff --git a/tests/views.py b/tests/views.py index 24c3f6b..6fc314c 100644 --- a/tests/views.py +++ b/tests/views.py @@ -26,7 +26,6 @@ class TeacherDetailView(SerializationSpecMixin, generics.RetrieveAPIView): class TeacherListView(SerializationSpecMixin, generics.ListAPIView): queryset = Teacher.objects.order_by('name') - lookup_field = 'id' serialization_spec = [ 'id', @@ -183,3 +182,10 @@ class StudentWithClassesAndAssignmentsDetailView(SerializationSpecMixin, generic 'assignments', 'classes', ] + + +class MisconfiguredView(SerializationSpecMixin, generics.ListAPIView): + + queryset = Assignment.objects.all() + + # Missing serialization_spec