From da03b407bba828db80ebbd768e5f8dc2cedeef30 Mon Sep 17 00:00:00 2001 From: Daniel Egger Date: Mon, 17 Jul 2023 17:51:49 +0200 Subject: [PATCH] Add GraphQL server code for learning path --- scripts/count_queries.py | 9 +- scripts/graphene_count_queries.py | 75 +++++ .../vbv_lernwelt/assignment/graphql/types.py | 2 +- server/vbv_lernwelt/core/schema.py | 7 +- .../vbv_lernwelt/core/serializer_helpers.py | 4 +- server/vbv_lernwelt/core/utils.py | 20 +- .../vbv_lernwelt/course/graphql/interfaces.py | 19 ++ server/vbv_lernwelt/course/graphql/queries.py | 9 +- server/vbv_lernwelt/course/graphql/types.py | 8 +- server/vbv_lernwelt/course/schema.py | 46 --- server/vbv_lernwelt/course/services.py | 6 +- server/vbv_lernwelt/course/views.py | 5 +- .../vbv_lernwelt/learnpath/graphql/queries.py | 81 +++++ .../vbv_lernwelt/learnpath/graphql/types.py | 282 ++++++++++++++++++ server/vbv_lernwelt/learnpath/utils.py | 2 - 15 files changed, 496 insertions(+), 79 deletions(-) create mode 100644 scripts/graphene_count_queries.py create mode 100644 server/vbv_lernwelt/course/graphql/interfaces.py delete mode 100644 server/vbv_lernwelt/course/schema.py create mode 100644 server/vbv_lernwelt/learnpath/graphql/queries.py create mode 100644 server/vbv_lernwelt/learnpath/graphql/types.py delete mode 100644 server/vbv_lernwelt/learnpath/utils.py diff --git a/scripts/count_queries.py b/scripts/count_queries.py index 905f001e..484787f1 100644 --- a/scripts/count_queries.py +++ b/scripts/count_queries.py @@ -24,7 +24,7 @@ def main(): reset_queries() page = Page.objects.get( - slug="versicherungsvermittlerin", locale__language_code="de-CH" + slug="überbetriebliche-kurse-lp", locale__language_code="de-CH" ) serializer = page.specific.get_serializer_class()(page.specific) @@ -32,6 +32,13 @@ def main(): print(len(json.dumps(serializer.data))) print(len(connection.queries)) + # reference + page = Page.objects.get( + slug="überbetriebliche-kurse-lp", locale__language_code="de-CH" + ) + list(page.get_descendants().specific()) + print(len(connection.queries)) + if __name__ == "__main__": main() diff --git a/scripts/graphene_count_queries.py b/scripts/graphene_count_queries.py new file mode 100644 index 00000000..94b85982 --- /dev/null +++ b/scripts/graphene_count_queries.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +import os +import sys + +import django +import graphene +from graphene import Context + +sys.path.append("../server") + +os.environ.setdefault("IT_APP_ENVIRONMENT", "local") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.base") +django.setup() + +from vbv_lernwelt.core.schema import Query +from vbv_lernwelt.core.models import User + + +def main(): + from django.conf import settings + + settings.DEBUG = True + from django.db import connection + from django.db import reset_queries + + reset_queries() + + schema = graphene.Schema(query=Query, auto_camelcase=False) + context = Context() + context.user = User.objects.get(username="admin") + result = schema.execute( + """ +{ + learning_path(slug: "überbetriebliche-kurse-lp") { + id + title + content_type + topics { + id + title + content_type + circles { + id + title + content_type + learning_sequences { + id + title + icon + learning_units { + id + title + learning_contents { + id + title + } + } + } + } + } + } +} + """, + context=context, + ) + + print(result) + print(len(connection.queries)) + + # reference + reset_queries() + + +if __name__ == "__main__": + main() diff --git a/server/vbv_lernwelt/assignment/graphql/types.py b/server/vbv_lernwelt/assignment/graphql/types.py index d0f4aa6f..3ba46191 100644 --- a/server/vbv_lernwelt/assignment/graphql/types.py +++ b/server/vbv_lernwelt/assignment/graphql/types.py @@ -4,7 +4,7 @@ from graphene_django import DjangoObjectType from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion from vbv_lernwelt.core.graphql.types import JSONStreamField -from vbv_lernwelt.course.schema import CoursePageInterface +from vbv_lernwelt.course.graphql.interfaces import CoursePageInterface class AssignmentObjectType(DjangoObjectType): diff --git a/server/vbv_lernwelt/core/schema.py b/server/vbv_lernwelt/core/schema.py index ed89136d..d389be78 100644 --- a/server/vbv_lernwelt/core/schema.py +++ b/server/vbv_lernwelt/core/schema.py @@ -2,13 +2,16 @@ import graphene from vbv_lernwelt.assignment.graphql.mutations import AssignmentMutation from vbv_lernwelt.assignment.graphql.queries import AssignmentQuery -from vbv_lernwelt.course.schema import CourseQuery +from vbv_lernwelt.course.graphql.queries import CourseQuery from vbv_lernwelt.course_session.graphql.mutations import CourseSessionMutation from vbv_lernwelt.course_session.graphql.queries import CourseSessionQuery from vbv_lernwelt.feedback.graphql.mutations import FeedbackMutation +from vbv_lernwelt.learnpath.graphql.queries import CircleQuery -class Query(AssignmentQuery, CourseQuery, CourseSessionQuery, graphene.ObjectType): +class Query( + AssignmentQuery, CourseQuery, CourseSessionQuery, CircleQuery, graphene.ObjectType +): pass diff --git a/server/vbv_lernwelt/core/serializer_helpers.py b/server/vbv_lernwelt/core/serializer_helpers.py index cc82eef4..781a1dda 100644 --- a/server/vbv_lernwelt/core/serializer_helpers.py +++ b/server/vbv_lernwelt/core/serializer_helpers.py @@ -1,7 +1,7 @@ import wagtail.api.v2.serializers as wagtail_serializers from rest_framework.fields import SerializerMethodField -from vbv_lernwelt.learnpath.utils import get_wagtail_type +from vbv_lernwelt.core.utils import get_django_content_type def get_it_serializer_class( @@ -33,7 +33,7 @@ def get_it_serializer_class( class ItWagtailTypeField(wagtail_serializers.TypeField): def to_representation(self, obj): - name = get_wagtail_type(obj) + name = get_django_content_type(obj) return name diff --git a/server/vbv_lernwelt/core/utils.py b/server/vbv_lernwelt/core/utils.py index 67b85f3e..8291ac53 100644 --- a/server/vbv_lernwelt/core/utils.py +++ b/server/vbv_lernwelt/core/utils.py @@ -39,19 +39,17 @@ class DayUserRateThrottle(UserRateThrottle): scope = "day-throttle" -def find_first(iterable, default=False, pred=None): - """Returns the first true value in the iterable. - - If no true value is found, returns *default* - - If *pred* is not None, returns the first item - for which pred(item) is true. - - """ - # first_true([a,b,c], x) --> a or b or c or x - # first_true([a,b], x, f) --> a if f(a) else b if f(b) else x +def find_first(iterable, pred=None, default=None): return next(filter(pred, iterable), default) +def find_first_index(iterable, pred, default=None): + return next((i for i, x in enumerate(iterable) if pred(x)), default) + + def replace_whitespace(text, replacement=" "): return re.sub(r"\s+", replacement, text).strip() + + +def get_django_content_type(obj): + return obj._meta.app_label + "." + type(obj).__name__ diff --git a/server/vbv_lernwelt/course/graphql/interfaces.py b/server/vbv_lernwelt/course/graphql/interfaces.py new file mode 100644 index 00000000..10dfc250 --- /dev/null +++ b/server/vbv_lernwelt/course/graphql/interfaces.py @@ -0,0 +1,19 @@ +import graphene + +from vbv_lernwelt.core.utils import get_django_content_type + + +class CoursePageInterface(graphene.Interface): + id = graphene.ID() + title = graphene.String() + slug = graphene.String() + content_type = graphene.String() + live = graphene.Boolean() + translation_key = graphene.String() + frontend_url = graphene.String() + + def resolve_frontend_url(self, info): + return self.get_frontend_url() + + def resolve_content_type(self, info): + return get_django_content_type(self) diff --git a/server/vbv_lernwelt/course/graphql/queries.py b/server/vbv_lernwelt/course/graphql/queries.py index be7b075b..c0473cf1 100644 --- a/server/vbv_lernwelt/course/graphql/queries.py +++ b/server/vbv_lernwelt/course/graphql/queries.py @@ -1,16 +1,15 @@ import graphene -from graphql import GraphQLError -from vbv_lernwelt.course.graphql.types import CourseType +from vbv_lernwelt.course.graphql.types import CourseObjectType from vbv_lernwelt.course.models import Course from vbv_lernwelt.course.permissions import has_course_access -class CourseQuery: - course = graphene.Field(CourseType, id=graphene.Int()) +class CourseQuery(graphene.ObjectType): + course = graphene.Field(CourseObjectType, id=graphene.Int()) def resolve_course(root, info, id): course = Course.objects.get(pk=id) if has_course_access(info.context.user, course): return course - return GraphQLError("You do not have access to this course") + raise PermissionError("You do not have access to this course") diff --git a/server/vbv_lernwelt/course/graphql/types.py b/server/vbv_lernwelt/course/graphql/types.py index d9a94ad3..33f268d9 100644 --- a/server/vbv_lernwelt/course/graphql/types.py +++ b/server/vbv_lernwelt/course/graphql/types.py @@ -8,7 +8,7 @@ from rest_framework.exceptions import PermissionDenied from vbv_lernwelt.course.models import Course, CourseBasePage from vbv_lernwelt.course.permissions import has_course_access -from vbv_lernwelt.course.schema import CoursePageInterface +from vbv_lernwelt.learnpath.graphql.types import LearningPathObjectType logger = structlog.get_logger(__name__) @@ -39,12 +39,12 @@ def resolve_course_page( raise e -class CourseType(DjangoObjectType): - learning_path = graphene.Field(CoursePageInterface) +class CourseObjectType(DjangoObjectType): + learning_path = graphene.Field(LearningPathObjectType) class Meta: model = Course - fields = ("id", "title", "category_name", "slug") + fields = ("id", "title", "category_name", "slug", "learning_path") def resolve_learning_path(self, info): return self.get_learning_path() diff --git a/server/vbv_lernwelt/course/schema.py b/server/vbv_lernwelt/course/schema.py deleted file mode 100644 index 5393f005..00000000 --- a/server/vbv_lernwelt/course/schema.py +++ /dev/null @@ -1,46 +0,0 @@ -import graphene -from graphene_django import DjangoObjectType - -from vbv_lernwelt.course.models import Course -from vbv_lernwelt.course.permissions import has_course_access -from vbv_lernwelt.learnpath.models import LearningPath - - -class CoursePageInterface(graphene.Interface): - id = graphene.ID() - title = graphene.String() - slug = graphene.String() - content_type = graphene.String() - live = graphene.Boolean() - translation_key = graphene.String() - frontend_url = graphene.String() - - def resolve_frontend_url(self, info): - return self.get_frontend_url() - - -class LearningPathType(DjangoObjectType): - class Meta: - model = LearningPath - interfaces = (CoursePageInterface,) - - -class CourseType(DjangoObjectType): - learning_path = graphene.Field(LearningPathType) - - class Meta: - model = Course - fields = ("id", "title", "category_name", "slug", "learning_path") - - def resolve_learning_path(self, info): - return self.get_learning_path() - - -class CourseQuery(graphene.ObjectType): - course = graphene.Field(CourseType, id=graphene.Int()) - - def resolve_course(root, info, id): - course = Course.objects.get(pk=id) - if has_course_access(info.context.user, course): - return course - raise PermissionError("You do not have access to this course") diff --git a/server/vbv_lernwelt/course/services.py b/server/vbv_lernwelt/course/services.py index b26dd882..34c71574 100644 --- a/server/vbv_lernwelt/course/services.py +++ b/server/vbv_lernwelt/course/services.py @@ -1,5 +1,5 @@ +from vbv_lernwelt.core.utils import get_django_content_type from vbv_lernwelt.course.models import CourseCompletion, CourseCompletionStatus -from vbv_lernwelt.learnpath.utils import get_wagtail_type def mark_course_completion( @@ -15,7 +15,7 @@ def mark_course_completion( and page.specific.has_course_completion_status ): return ValueError( - f"Page {page.id} of type {get_wagtail_type(page)}" + f"Page {page.id} of type {get_django_content_type(page)}" f" cannot be marked as completed" ) @@ -25,7 +25,7 @@ def mark_course_completion( course_session_id=course_session.id, ) cc.completion_status = completion_status - cc.page_type = get_wagtail_type(page.specific) + cc.page_type = get_django_content_type(page.specific) cc.save() return cc diff --git a/server/vbv_lernwelt/course/views.py b/server/vbv_lernwelt/course/views.py index 1310b731..fb4dcadc 100644 --- a/server/vbv_lernwelt/course/views.py +++ b/server/vbv_lernwelt/course/views.py @@ -5,6 +5,8 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from wagtail.models import Page +from vbv_lernwelt.core.utils import get_django_content_type + from vbv_lernwelt.course.models import ( CircleDocument, CourseCompletion, @@ -27,7 +29,6 @@ from vbv_lernwelt.course.serializers import ( from vbv_lernwelt.course.services import mark_course_completion from vbv_lernwelt.files.models import UploadFile from vbv_lernwelt.files.services import FileDirectUploadService -from vbv_lernwelt.learnpath.utils import get_wagtail_type logger = structlog.get_logger(__name__) @@ -115,7 +116,7 @@ def mark_course_completion_view(request): "mark_course_completion successful", label="completion_api", page_id=page_id, - page_type=get_wagtail_type(page.specific), + page_type=get_django_content_type(page.specific), page_slug=page.slug, page_title=page.title, user_id=request.user.id, diff --git a/server/vbv_lernwelt/learnpath/graphql/queries.py b/server/vbv_lernwelt/learnpath/graphql/queries.py new file mode 100644 index 00000000..188300de --- /dev/null +++ b/server/vbv_lernwelt/learnpath/graphql/queries.py @@ -0,0 +1,81 @@ +import graphene + +from vbv_lernwelt.course.graphql.types import resolve_course_page +from vbv_lernwelt.learnpath.graphql.types import ( + CircleObjectType, + LearningContentAssignmentObjectType, + LearningContentAttendanceCourseObjectType, + LearningContentDocumentListObjectType, + LearningContentFeedbackObjectType, + LearningContentLearningModuleObjectType, + LearningContentMediaLibraryObjectType, + LearningContentPlaceholderObjectType, + LearningContentRichTextObjectType, + LearningContentTestObjectType, + LearningContentVideoObjectType, + LearningPathObjectType, +) +from vbv_lernwelt.learnpath.models import Circle, LearningPath + + +class CircleQuery: + circle = graphene.Field(CircleObjectType, id=graphene.Int(), slug=graphene.String()) + learning_path = graphene.Field( + LearningPathObjectType, id=graphene.Int(), slug=graphene.String() + ) + + def resolve_circle(root, info, id=None, slug=None): + return resolve_course_page(Circle, root, info, id=id, slug=slug) + + def resolve_learning_path(root, info, id=None, slug=None): + return resolve_course_page(LearningPath, root, info, id=id, slug=slug) + + # dummy import, so that graphene recognizes the types + learning_content_media_library = graphene.Field( + LearningContentMediaLibraryObjectType + ) + learning_content_assignment = graphene.Field(LearningContentAssignmentObjectType) + learning_content_attendance_course = graphene.Field( + LearningContentAttendanceCourseObjectType + ) + learning_content_feedback = graphene.Field(LearningContentFeedbackObjectType) + learning_content_learning_module = graphene.Field( + LearningContentLearningModuleObjectType + ) + learning_content_placeholder = graphene.Field(LearningContentPlaceholderObjectType) + learning_content_rich_text = graphene.Field(LearningContentRichTextObjectType) + learning_content_test = graphene.Field(LearningContentTestObjectType) + learning_content_video = graphene.Field(LearningContentVideoObjectType) + learning_content_document_list = graphene.Field( + LearningContentDocumentListObjectType + ) + + def resolve_learning_content_media_library(self, info): + return None + + def resolve_learning_content_assignment(self, info): + return None + + def resolve_learning_content_attendance_course(self, info): + return None + + def resolve_learning_content_feedback(self, info): + return None + + def resolve_learning_content_learning_module(self, info): + return None + + def resolve_learning_content_placeholder(self, info): + return None + + def resolve_learning_content_rich_text(self, info): + return None + + def resolve_learning_content_test(self, info): + return None + + def resolve_learning_content_video(self, info): + return None + + def resolve_learning_content_document_list(self, info): + return None diff --git a/server/vbv_lernwelt/learnpath/graphql/types.py b/server/vbv_lernwelt/learnpath/graphql/types.py new file mode 100644 index 00000000..4ce4b41e --- /dev/null +++ b/server/vbv_lernwelt/learnpath/graphql/types.py @@ -0,0 +1,282 @@ +import graphene +import structlog +from graphene_django import DjangoObjectType + +from vbv_lernwelt.core.utils import find_first_index +from vbv_lernwelt.course.graphql.interfaces import CoursePageInterface +from vbv_lernwelt.learnpath.models import ( + Circle, + LearningContentAssignment, + LearningContentAttendanceCourse, + LearningContentDocumentList, + LearningContentFeedback, + LearningContentLearningModule, + LearningContentMediaLibrary, + LearningContentPlaceholder, + LearningContentRichText, + LearningContentTest, + LearningContentVideo, + LearningPath, + LearningSequence, + LearningUnit, + Topic, +) + +logger = structlog.get_logger(__name__) + + +class LearningContentInterface(CoursePageInterface): + minutes = graphene.Int() + description = graphene.String() + content = graphene.String() + + @classmethod + def resolve_type(cls, instance, info): + if isinstance(instance, LearningContentAssignment): + return LearningContentAssignmentObjectType + elif isinstance(instance, LearningContentAttendanceCourse): + return LearningContentAttendanceCourseObjectType + elif isinstance(instance, LearningContentFeedback): + return LearningContentFeedbackObjectType + elif isinstance(instance, LearningContentLearningModule): + return LearningContentLearningModuleObjectType + elif isinstance(instance, LearningContentMediaLibrary): + return LearningContentMediaLibraryObjectType + elif isinstance(instance, LearningContentPlaceholder): + return LearningContentPlaceholderObjectType + elif isinstance(instance, LearningContentRichText): + return LearningContentRichTextObjectType + elif isinstance(instance, LearningContentTest): + return LearningContentTestObjectType + elif isinstance(instance, LearningContentVideo): + return LearningContentVideoObjectType + elif isinstance(instance, LearningContentDocumentList): + return LearningContentDocumentListObjectType + else: + logger.error( + "Could not resolve type for LearningContentInterface", + django_instance=instance, + ) + return None + + +class LearningContentAttendanceCourseObjectType(DjangoObjectType): + class Meta: + model = LearningContentAttendanceCourse + interfaces = (LearningContentInterface,) + fields = [] + + +class LearningContentVideoObjectType(DjangoObjectType): + class Meta: + model = LearningContentVideo + interfaces = (LearningContentInterface,) + fields = [] + + +class LearningContentPlaceholderObjectType(DjangoObjectType): + class Meta: + model = LearningContentPlaceholder + interfaces = (LearningContentInterface,) + fields = [] + + +class LearningContentFeedbackObjectType(DjangoObjectType): + class Meta: + model = LearningContentFeedback + interfaces = (LearningContentInterface,) + fields = [] + + +class LearningContentLearningModuleObjectType(DjangoObjectType): + class Meta: + model = LearningContentLearningModule + interfaces = (LearningContentInterface,) + fields = [] + + +class LearningContentMediaLibraryObjectType(DjangoObjectType): + class Meta: + model = LearningContentMediaLibrary + interfaces = (LearningContentInterface,) + fields = [] + + +class LearningContentTestObjectType(DjangoObjectType): + class Meta: + model = LearningContentTest + interfaces = (LearningContentInterface,) + fields = [] + + +class LearningContentRichTextObjectType(DjangoObjectType): + class Meta: + model = LearningContentRichText + interfaces = (LearningContentInterface,) + fields = [] + + +class LearningContentAssignmentObjectType(DjangoObjectType): + class Meta: + model = LearningContentAssignment + interfaces = (LearningContentInterface,) + fields = [ + "content_assignment", + "assignment_type", + ] + + +class LearningContentDocumentListObjectType(DjangoObjectType): + class Meta: + model = LearningContentDocumentList + interfaces = (LearningContentInterface,) + fields = [] + + +class LearningUnitObjectType(DjangoObjectType): + learning_contents = graphene.List(LearningContentInterface) + + class Meta: + model = LearningUnit + interfaces = (CoursePageInterface,) + fields = [] + + @staticmethod + def resolve_learning_contents(root: LearningUnit, info, **kwargs): + siblings = None + if hasattr(info.context, "circle_descendants"): + circle_descendants = info.context.circle_descendants + index = circle_descendants.index(root) + siblings = circle_descendants[index + 1 :] + + if not siblings: + siblings = root.get_siblings().live().specific() + + learning_contents = [] + for sibling in siblings: + if ( + sibling.specific_class == LearningUnit + or sibling.specific_class == LearningSequence + ): + break + else: + learning_contents.append(sibling.specific) + return learning_contents + + +class LearningSequenceObjectType(DjangoObjectType): + learning_units = graphene.List(LearningUnitObjectType) + + class Meta: + model = LearningSequence + interfaces = (CoursePageInterface,) + fields = ["icon"] + + @staticmethod + def resolve_learning_units(root: LearningSequence, info, **kwargs): + siblings = None + if hasattr(info.context, "circle_descendants"): + circle_descendants = info.context.circle_descendants + index = circle_descendants.index(root) + siblings = circle_descendants[index + 1 :] + + if not siblings: + siblings = root.get_siblings().live().specific() + + learning_units = [] + for sibling in siblings: + if sibling.specific_class == LearningSequence: + # finished with this sequence + break + if sibling.specific_class == LearningUnit: + learning_units.append(sibling.specific) + return learning_units + + +class CircleObjectType(DjangoObjectType): + learning_sequences = graphene.List(LearningSequenceObjectType) + + class Meta: + model = Circle + interfaces = (CoursePageInterface,) + fields = [ + "description", + "goals", + ] + + @staticmethod + def resolve_learning_sequences(root: Circle, info, **kwargs): + circle_descendants = None + + if hasattr(info.context, "learning_path_descendants"): + children = info.context.learning_path_descendants + circle_start_index = children.index(root) + next_circle_index = find_first_index( + children[circle_start_index + 1 :], + pred=lambda child: child.specific_class == Circle, + ) + circle_descendants = children[circle_start_index + 1 : next_circle_index] + + if not circle_descendants: + circle_descendants = list(root.get_descendants().live().specific()) + + # store flattened descendents to improve performance (no need for db queries) + info.context.circle_descendants = list(circle_descendants) + + return [ + descendant.specific + for descendant in circle_descendants + if descendant.specific_class == LearningSequence + ] + + +class TopicObjectType(DjangoObjectType): + circles = graphene.List(CircleObjectType) + + class Meta: + model = Topic + interfaces = (CoursePageInterface,) + fields = [ + "is_visible", + ] + + @staticmethod + def resolve_circles(root: LearningPath, info, **kwargs): + siblings = None + + if hasattr(info.context, "learning_path_descendants"): + learning_path_descendants = info.context.learning_path_descendants + index = learning_path_descendants.index(root) + siblings = learning_path_descendants[index + 1 :] + + if not siblings: + siblings = root.get_next_siblings().live().specific() + + circles = [] + for sibling in siblings: + if sibling.specific_class == Topic: + # finished with this topic + break + if sibling.specific_class == Circle: + circles.append(sibling.specific) + return circles + + +class LearningPathObjectType(DjangoObjectType): + topics = graphene.List(TopicObjectType) + + class Meta: + model = LearningPath + interfaces = (CoursePageInterface,) + + @staticmethod + def resolve_topics(root: LearningPath, info, **kwargs): + learning_path_descendants_qs = root.get_descendants().live().specific() + + # store flattened descendents to improve performance (no need for db queries) + info.context.learning_path_descendants = list(learning_path_descendants_qs) + return [ + descendant.specific + for descendant in learning_path_descendants_qs + if descendant.specific_class == Topic + ] diff --git a/server/vbv_lernwelt/learnpath/utils.py b/server/vbv_lernwelt/learnpath/utils.py deleted file mode 100644 index f5cbf0ad..00000000 --- a/server/vbv_lernwelt/learnpath/utils.py +++ /dev/null @@ -1,2 +0,0 @@ -def get_wagtail_type(obj): - return obj._meta.app_label + "." + type(obj).__name__