Skip to content

Commit

Permalink
Merge pull request #8138 from rtibbles/a_cow_a_cloud_a_tree_milton_ke…
Browse files Browse the repository at this point in the history
…ynes

Tree viewset for retrieving nested, paginated views of topic trees
  • Loading branch information
rtibbles authored Jun 21, 2021
2 parents e0606d1 + 8225c03 commit 0c00b9c
Show file tree
Hide file tree
Showing 6 changed files with 762 additions and 113 deletions.
79 changes: 65 additions & 14 deletions kolibri/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from rest_framework.mixins import DestroyModelMixin
from rest_framework.mixins import UpdateModelMixin as BaseUpdateModelMixin
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.serializers import Serializer
from rest_framework.serializers import UUIDField
from rest_framework.serializers import ValidationError
Expand Down Expand Up @@ -117,15 +118,15 @@ def remove_invalid_fields(self, queryset, fields, view, request):
return ordering


class ReadOnlyValuesViewset(viewsets.ReadOnlyModelViewSet):
class BaseValuesViewset(viewsets.GenericViewSet):
"""
A viewset that uses a values call to get all model/queryset data in
a single database query, rather than delegating serialization to a
DRF ModelSerializer.
"""

# A tuple of values to get from the queryset
values = None
# values = None
# A map of target_key, source_key where target_key is the final target_key that will be set
# and source_key is the key on the object retrieved from the values call.
# Alternatively, the source_key can be a callable that will be passed the object and return
Expand All @@ -134,20 +135,62 @@ class ReadOnlyValuesViewset(viewsets.ReadOnlyModelViewSet):
field_map = {}

def __init__(self, *args, **kwargs):
viewset = super(ReadOnlyValuesViewset, self).__init__(*args, **kwargs)
if not isinstance(self.values, tuple):
viewset = super(BaseValuesViewset, self).__init__(*args, **kwargs)
if not hasattr(self, "values") or not isinstance(self.values, tuple):
raise TypeError("values must be defined as a tuple")
self._values = tuple(self.values)
if not isinstance(self.field_map, dict):
raise TypeError("field_map must be defined as a dict")
self._field_map = self.field_map.copy()
return viewset

def generate_serializer(self):
queryset = getattr(self, "queryset", None)
if queryset is None:
try:
queryset = self.get_queryset()
except Exception:
pass
model = getattr(queryset, "model", None)
if model is None:
return Serializer
mapped_fields = {v: k for k, v in self.field_map.items() if isinstance(v, str)}
fields = []
extra_kwargs = {}
for value in self.values:
try:
model._meta.get_field(value)
if value in mapped_fields:
extra_kwargs[mapped_fields[value]] = {"source": value}
value = mapped_fields[value]
fields.append(value)
except Exception:
pass

meta = type(
"Meta",
(object,),
{
"fields": fields,
"read_only_fields": fields,
"model": model,
"extra_kwargs": extra_kwargs,
},
)
CustomSerializer = type(
"{}Serializer".format(self.__class__.__name__),
(ModelSerializer,),
{"Meta": meta},
)

return CustomSerializer

def get_serializer_class(self):
if self.serializer_class is not None:
return self.serializer_class
# Hack to prevent the renderer logic from breaking completely.
return Serializer
self.__class__.serializer_class = self.generate_serializer()
return self.__class__.serializer_class

def _get_lookup_filter(self):
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
Expand Down Expand Up @@ -205,6 +248,18 @@ def serialize(self, queryset):
list(map(self._map_fields, values_queryset or [])), queryset
)

def serialize_object(self, **filter_kwargs):
queryset = self.get_queryset()
try:
filter_kwargs = filter_kwargs or self._get_lookup_filter()
return self.serialize(queryset.filter(**filter_kwargs))[0]
except IndexError:
raise Http404(
"No %s matches the given query." % queryset.model._meta.object_name
)


class ListModelMixin(object):
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())

Expand All @@ -218,20 +273,16 @@ def list(self, request, *args, **kwargs):

return Response(self.serialize(queryset))

def serialize_object(self, **filter_kwargs):
queryset = self.get_queryset()
try:
filter_kwargs = filter_kwargs or self._get_lookup_filter()
return self.serialize(queryset.filter(**filter_kwargs))[0]
except IndexError:
raise Http404(
"No %s matches the given query." % queryset.model._meta.object_name
)

class RetrieveModelMixin(object):
def retrieve(self, request, *args, **kwargs):
return Response(self.serialize_object())


class ReadOnlyValuesViewset(BaseValuesViewset, RetrieveModelMixin, ListModelMixin):
pass


class CreateModelMixin(BaseCreateModelMixin):
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
Expand Down
140 changes: 140 additions & 0 deletions kolibri/core/assets/src/api-resources/contentNode.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,83 @@
import isPlainObject from 'lodash/isPlainObject';
import { Resource } from 'kolibri.lib.apiResource';
import Store from 'kolibri.coreVue.vuex.store';
import urls from 'kolibri.urls';
import cloneDeep from '../cloneDeep';

/**
* Type definition for Language metadata
* @typedef {Object} Language
* @property {string} id - an IETF language tag
* @property {string} lang_code - the ISO 639‑1 language code
* @property {string} lang_subcode - the regional identifier
* @property {string} lang_name - the name of the language in that language
* @property {('ltr'|'rtl'|)} lang_direction - Direction of the language's script,
* top to bottom is not supported currently
*/

/**
* Type definition for AssessmentMetadata
* @typedef {Object} AssessmentMetadata
* @property {string[]} assessment_item_ids - an array of ids for assessment items
* @property {number} number_of_assessments - the length of assessment_item_ids
* @property {Object} mastery_model - object describing the mastery criterion for finishing practice
* @property {boolean} randomize - whether to randomize the order of assessments
* @property {boolean} is_manipulable - Whether this assessment can be programmatically updated
*/

/**
* Type definition for File
* @typedef {Object} File
* @property {string} id - id of the file object
* @property {string} checksum - md5 checksum of the file, used to generate the file name
* @property {boolean} available - whether the file is available on the server
* @property {number} file_size - file_size in bytes
* @property {string} extension - file extension, also used to generate the file name
* @property {string} preset - preset, the role that the file plays for this content node
* @property {Language|null} lang - The language of the File
* @property {boolean} supplementary - Whether this file is optional
* @property {boolean} thumbnail - Whether this file is a thumbnail
*/

/**
* Type definition for ContentNode metadata
* @typedef {Object} ContentNode
* @property {string} id - unique id of the ContentNode
* @property {string} channel_id - unique channel_id of the channel that the ContentNode is in
* @property {string} content_id - identifier that is common across all instances of this resource
* @property {string} title - A title that summarizes this ContentNode for the user
* @property {string} description - detailed description of the ContentNode
* @property {string} author - author of the ContentNode
* @property {string} thumbnail_url - URL for the thumbnail for this ContentNode,
* this may be any valid URL format including base64 encoded or blob URL
* @property {boolean} available - Whether the ContentNode has all necessary files for rendering
* @property {boolean} coach_content - Whether the ContentNode is intended only for coach users
* @property {Language|null} lang - The primary language of the ContentNode
* @property {string} license_description - The description of the license, which may be localized
* @property {string} license_name - The human readable name of the license, localized
* @property {string} license_owner - The name of the person or organization that holds copyright
* @property {number} num_coach_contents - Number of coach contents that are descendants of this
* @property {string} parent - The unique id of the parent of this ContentNode
* @property {number} sort_order - The order of display for this node in its channel
* if depth recursion was not deep enough
* @property {string[]} tags - Tags that apply to this content
* @property {boolean} is_leaf - Whether is a leaf resource or not
* @property {AssessmentMetadata|null} assessmentmetadata - Additional metadata for assessments
* @property {File[]} files - array of file objects associated with this ContentNode
* @property {Object[]} ancestors - array of objects with 'id' and 'title' properties
* @property {Children} [children] - optional pagination object with children of this ContentNode
*/

/**
* Type definition for children pagination object
* @typedef {Object} Children
* @property {Object} more - parameters for requesting more objects
* @property {string} more.id - the id of the parent of these child nodes
* @property {Object} more.params - the get parameters that should be used for requesting more nodes
* @property {number} more.params.depth - 1 or 2, how deep the nesting should be returned
* @property {number} more.params.lft__gt - integer value to return a lft value greater than
* @property {ContentNode[]} results - the array of ContentNodes for this page
*/

export default new Resource({
name: 'contentnode',
Expand Down Expand Up @@ -34,4 +112,66 @@ export default new Resource({
fetchNextSteps(getParams) {
return this.fetchDetailCollection('next_steps', Store.getters.currentUserId, getParams);
},
cache: {},
fetchModel({ id }) {
if (this.cache[id]) {
return Promise.resolve(cloneDeep(this.cache[id]));
}
return this.client({ url: this.modelUrl(id) }).then(response => {
this.cacheData(response.data);
return response.data;
});
},
cacheData(data) {
if (Array.isArray(data)) {
for (let model of data) {
this.cacheData(model);
}
} else if (isPlainObject(data)) {
if (data[this.idKey]) {
this.cache[data[this.idKey]] = Object.assign(
this.cache[data[this.idKey]] || {},
cloneDeep(data)
);
if (data.children) {
this.cacheData(data.children);
}
} else if (data.results) {
for (let model of data.results) {
this.cacheData(model);
}
}
}
},
fetchCollection(params) {
return this.client({ url: this.collectionUrl(), params }).then(response => {
this.cacheData(response.data);
return response.data;
});
},
/**
* A method to request paginated tree data from the backend
* @param {string} id - the id of the parent node for this request
* @param {Object} params - the GET parameters to return more results,
* may be both pagination and non-pagination specific parameters
* @return {Promise<ContentNode>} Promise that resolves with the model data
*/
fetchTree(id, params) {
const url = urls['kolibri:core:contentnode_tree_detail'](id);
return this.client({ url, params }).then(response => {
this.cacheData(response.data);
return response.data;
});
},
/**
* A method to simplify requesting more items from a previously paginated response from fetchTree
* @param {Object} more - the 'more' property of the 'children' pagination object from a response.
* @param {string} more.id - the id of the parent node for this request
* @param {Object} more.params - the GET parameters to return more results,
* may be both pagination and non-pagination specific parameters
* @return {Promise<ContentNode>} Promise that resolves with the model data
*/
fetchMoreTree({ id, params }) {
return this.fetchTree(id, params);
},
});
Loading

0 comments on commit 0c00b9c

Please sign in to comment.