-
Notifications
You must be signed in to change notification settings - Fork 630
/
api.py
1608 lines (1376 loc) · 59.3 KB
/
api.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import logging
import re
from collections import OrderedDict
from functools import reduce
from random import sample
import requests
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db.models import Exists
from django.db.models import OuterRef
from django.db.models import Q
from django.db.models import Subquery
from django.db.models import Sum
from django.db.models.aggregates import Count
from django.http import Http404
from django.http.request import HttpRequest
from django.utils.cache import patch_response_headers
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _
from django.views.decorators.cache import cache_page
from django.views.decorators.http import etag
from django_filters.rest_framework import BaseInFilter
from django_filters.rest_framework import BooleanFilter
from django_filters.rest_framework import CharFilter
from django_filters.rest_framework import ChoiceFilter
from django_filters.rest_framework import DjangoFilterBackend
from django_filters.rest_framework import FilterSet
from django_filters.rest_framework import NumberFilter
from django_filters.rest_framework import UUIDFilter
from le_utils.constants import content_kinds
from le_utils.constants import languages
from rest_framework import filters
from rest_framework import mixins
from rest_framework import status
from rest_framework import viewsets
from rest_framework.decorators import detail_route
from rest_framework.decorators import list_route
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response
from kolibri.core.api import BaseValuesViewset
from kolibri.core.api import ListModelMixin
from kolibri.core.api import ReadOnlyValuesViewset
from kolibri.core.auth.api import KolibriAuthPermissions
from kolibri.core.auth.api import KolibriAuthPermissionsFilter
from kolibri.core.auth.middleware import session_exempt
from kolibri.core.bookmarks.models import Bookmark
from kolibri.core.content import models
from kolibri.core.content import serializers
from kolibri.core.content.permissions import CanManageContent
from kolibri.core.content.utils.content_types_tools import (
renderable_contentnodes_q_filter,
)
from kolibri.core.content.utils.file_availability import LocationError
from kolibri.core.content.utils.importability_annotation import (
get_channel_stats_from_disk,
)
from kolibri.core.content.utils.importability_annotation import (
get_channel_stats_from_peer,
)
from kolibri.core.content.utils.importability_annotation import (
get_channel_stats_from_studio,
)
from kolibri.core.content.utils.paths import get_channel_lookup_url
from kolibri.core.content.utils.paths import get_info_url
from kolibri.core.content.utils.paths import get_local_content_storage_file_url
from kolibri.core.content.utils.search import get_available_metadata_labels
from kolibri.core.content.utils.stopwords import stopwords_set
from kolibri.core.decorators import query_params_required
from kolibri.core.device.models import ContentCacheKey
from kolibri.core.lessons.models import Lesson
from kolibri.core.logger.models import ContentSessionLog
from kolibri.core.logger.models import ContentSummaryLog
from kolibri.core.query import SQSum
from kolibri.core.utils.pagination import ValuesViewsetCursorPagination
from kolibri.core.utils.pagination import ValuesViewsetLimitOffsetPagination
from kolibri.core.utils.pagination import ValuesViewsetPageNumberPagination
logger = logging.getLogger(__name__)
def cache_forever(some_func):
"""
Decorator for patch_response_headers function
"""
# Approximately 1 year
# Source: https://stackoverflow.com/a/3001556/405682
cache_timeout = 31556926
def wrapper_func(*args, **kwargs):
response = some_func(*args, **kwargs)
# This caching has the unfortunate effect of also caching the dynamically
# generated filters for recommendation, this quick hack checks if
# the request is any of those filters, and then applies less long running
# caching on it.
timeout = cache_timeout
try:
request = args[0]
request = kwargs.get("request", request)
except IndexError:
request = kwargs.get("request", None)
if isinstance(request, HttpRequest):
if any(map(lambda x: x in request.path, ["next_steps", "resume"])):
timeout = 0
elif "popular" in request.path:
timeout = 600
patch_response_headers(response, cache_timeout=timeout)
return response
return session_exempt(wrapper_func)
class ChannelMetadataFilter(FilterSet):
available = BooleanFilter(method="filter_available", label="Available")
has_exercise = BooleanFilter(method="filter_has_exercise", label="Has exercises")
class Meta:
model = models.ChannelMetadata
fields = ("available", "has_exercise")
def filter_has_exercise(self, queryset, name, value):
queryset = queryset.annotate(
has_exercise=Exists(
models.ContentNode.objects.filter(
kind=content_kinds.EXERCISE,
available=True,
channel_id=OuterRef("id"),
)
)
)
return queryset.filter(has_exercise=True)
def filter_available(self, queryset, name, value):
return queryset.filter(root__available=value)
@method_decorator(cache_forever, name="dispatch")
class ChannelMetadataViewSet(ReadOnlyValuesViewset):
filter_backends = (DjangoFilterBackend,)
filter_class = ChannelMetadataFilter
values = (
"author",
"description",
"tagline",
"id",
"last_updated",
"root__lang__lang_code",
"root__lang__lang_name",
"name",
"root",
"thumbnail",
"version",
"root__available",
"root__num_coach_contents",
"public",
)
field_map = {
"num_coach_contents": "root__num_coach_contents",
"available": "root__available",
"lang_code": "root__lang__lang_code",
"lang_name": "root__lang__lang_name",
}
def get_queryset(self):
return models.ChannelMetadata.objects.all()
@list_route(methods=["get"])
def filter_options(self, request, **kwargs):
channel_id = self.request.query_params.get("id")
nodes = models.ContentNode.objects.filter(channel_id=channel_id)
authors = (
nodes.exclude(author="")
.order_by("author")
.values_list("author")
.annotate(Count("author"))
)
kinds = nodes.order_by("kind").values_list("kind").annotate(Count("kind"))
tag_nodes = models.ContentTag.objects.filter(
tagged_content__channel_id=channel_id
)
tags = (
tag_nodes.order_by("tag_name")
.values_list("tag_name")
.annotate(Count("tag_name"))
)
data = {
"available_authors": dict(authors),
"available_kinds": dict(kinds),
"available_tags": dict(tags),
}
return Response(data)
class IdFilter(FilterSet):
ids = CharFilter(method="filter_ids")
def filter_ids(self, queryset, name, value):
return queryset.filter_by_uuids(value.split(","))
class Meta:
fields = ["ids"]
MODALITIES = set(["QUIZ"])
class UUIDInFilter(BaseInFilter, UUIDFilter):
pass
class CharInFilter(BaseInFilter, CharFilter):
pass
contentnode_filter_fields = [
"parent",
"parent__isnull",
"prerequisite_for",
"has_prerequisite",
"related",
"exclude_content_ids",
"ids",
"content_id",
"channel_id",
"kind",
"include_coach_content",
"kind_in",
"contains_quiz",
"grade_levels",
"resource_types",
"learning_activities",
"accessibility_labels",
"categories",
"learner_needs",
"keywords",
"channels",
"languages",
"tree_id",
"lft__gt",
"rght__lt",
]
class ContentNodeFilter(IdFilter):
kind = ChoiceFilter(
method="filter_kind",
choices=(content_kinds.choices + (("content", _("Resource")),)),
)
exclude_content_ids = CharFilter(method="filter_exclude_content_ids")
kind_in = CharFilter(method="filter_kind_in")
parent = UUIDFilter("parent")
parent__isnull = BooleanFilter(field_name="parent", lookup_expr="isnull")
include_coach_content = BooleanFilter(method="filter_include_coach_content")
contains_quiz = CharFilter(method="filter_contains_quiz")
grade_levels = CharFilter(method="bitmask_contains_and")
resource_types = CharFilter(method="bitmask_contains_and")
learning_activities = CharFilter(method="bitmask_contains_and")
accessibility_labels = CharFilter(method="bitmask_contains_and")
categories = CharFilter(method="bitmask_contains_and")
learner_needs = CharFilter(method="bitmask_contains_and")
keywords = CharFilter(method="filter_keywords")
channels = UUIDInFilter(name="channel_id")
languages = CharInFilter(name="lang_id")
categories__isnull = BooleanFilter(field_name="categories", lookup_expr="isnull")
lft__gt = NumberFilter(field_name="lft", lookup_expr="gt")
rght__lt = NumberFilter(field_name="rght", lookup_expr="lt")
authors = CharFilter(method="filter_by_authors")
tags = CharFilter(method="filter_by_tags")
descendant_of = UUIDFilter(method="filter_descendant_of")
class Meta:
model = models.ContentNode
fields = contentnode_filter_fields
def filter_by_authors(self, queryset, name, value):
"""
Show content filtered by author
:param queryset: all content nodes for this channel
:param value: an array of authors to filter by
:return: content nodes that match the authors
"""
authors = value.split(",")
return queryset.filter(author__in=authors).order_by("lft")
def filter_by_tags(self, queryset, name, value):
"""
Show content filtered by tag
:param queryset: all content nodes for this channel
:param value: an array of tags to filter by
:return: content nodes that match the tags
"""
tags = value.split(",")
return queryset.filter(tags__tag_name__in=tags).order_by("lft").distinct()
def filter_descendant_of(self, queryset, name, value):
"""
Show content that is descendant of the given node
:param queryset: all content nodes for this channel
:param value: the root node to filter descendant of
:return: all descendants content
"""
try:
node = models.ContentNode.objects.values("lft", "rght", "tree_id").get(
pk=value
)
except (models.ContentNode.DoesNotExist, ValueError):
return queryset.none()
return queryset.filter(
lft__gt=node["lft"], rght__lt=node["rght"], tree_id=node["tree_id"]
)
def filter_kind(self, queryset, name, value):
"""
Show only content of a given kind.
:param queryset: all content nodes for this channel
:param value: 'content' for everything except topics, or one of the content kind constants
:return: content nodes of the given kind
"""
if value == "content":
return queryset.exclude(kind=content_kinds.TOPIC).order_by("lft")
return queryset.filter(kind=value).order_by("lft")
def filter_kind_in(self, queryset, name, value):
"""
Show only content of given kinds.
:param queryset: all content nodes for this channel
:param value: A list of content node kinds
:return: content nodes of the given kinds
"""
kinds = value.split(",")
return queryset.filter(kind__in=kinds).order_by("lft")
def filter_exclude_content_ids(self, queryset, name, value):
return queryset.exclude_by_content_ids(value.split(","))
def filter_include_coach_content(self, queryset, name, value):
if value:
return queryset
return queryset.filter(coach_content=False)
def filter_contains_quiz(self, queryset, name, value):
if value:
quizzes = models.ContentNode.objects.filter(
options__contains='"modality": "QUIZ"'
).get_ancestors(include_self=True)
return queryset.filter(pk__in=quizzes.values_list("pk", flat=True))
return queryset
def filter_keywords(self, queryset, name, value):
# all words with punctuation removed
all_words = [w for w in re.split('[?.,!";: ]', value) if w]
# words in all_words that are not stopwords
critical_words = [w for w in all_words if w not in stopwords_set]
words = critical_words if critical_words else all_words
query = union(
[
# all critical words in title
intersection([Q(title__icontains=w) for w in words]),
# all critical words in description
intersection([Q(description__icontains=w) for w in words]),
]
)
return queryset.filter(query)
def bitmask_contains_and(self, queryset, name, value):
return queryset.has_all_labels(name, value.split(","))
class OptionalPageNumberPagination(ValuesViewsetPageNumberPagination):
"""
Pagination class that allows for page number-style pagination, when requested.
To activate, the `page_size` argument must be set. For example, to request the first 20 records:
`?page_size=20&page=1`
"""
page_size = None
page_size_query_param = "page_size"
def map_file(file):
file["checksum"] = file.pop("local_file__id")
file["available"] = file.pop("local_file__available")
file["file_size"] = file.pop("local_file__file_size")
file["extension"] = file.pop("local_file__extension")
file["storage_url"] = get_local_content_storage_file_url(
{
"available": file["available"],
"id": file["checksum"],
"extension": file["extension"],
}
)
return file
def _split_text_field(text):
return text.split(",") if text else []
class BaseContentNodeMixin(object):
"""
A base mixin for viewsets that need to return the same format of data
serialization for ContentNodes.
"""
filter_backends = (DjangoFilterBackend,)
filter_class = ContentNodeFilter
values = (
"id",
"author",
"available",
"channel_id",
"coach_content",
"content_id",
"description",
"kind",
"lang_id",
"license_description",
"license_name",
"license_owner",
"num_coach_contents",
"options",
"parent",
"sort_order",
"title",
"lft",
"rght",
"tree_id",
"learning_activities",
"grade_levels",
"resource_types",
"accessibility_labels",
"categories",
"duration",
"ancestors",
)
field_map = {
"learning_activities": lambda x: _split_text_field(x["learning_activities"]),
"grade_levels": lambda x: _split_text_field(x["grade_levels"]),
"resource_types": lambda x: _split_text_field(x["resource_types"]),
"accessibility_labels": lambda x: _split_text_field(x["accessibility_labels"]),
"categories": lambda x: _split_text_field(x["categories"]),
}
def get_queryset(self):
return models.ContentNode.objects.filter(available=True)
def get_related_data_maps(self, items, queryset):
assessmentmetadata_map = {
a["contentnode"]: a
for a in models.AssessmentMetaData.objects.filter(
contentnode__in=queryset
).values(
"assessment_item_ids",
"number_of_assessments",
"mastery_model",
"randomize",
"is_manipulable",
"contentnode",
)
}
files_map = {}
files = list(
models.File.objects.filter(contentnode__in=queryset).values(
"id",
"contentnode",
"local_file__id",
"priority",
"local_file__available",
"local_file__file_size",
"local_file__extension",
"preset",
"lang_id",
"supplementary",
"thumbnail",
)
)
lang_ids = set([obj["lang_id"] for obj in items + files])
languages_map = {
lang["id"]: lang
for lang in models.Language.objects.filter(id__in=lang_ids).values(
"id", "lang_code", "lang_subcode", "lang_name", "lang_direction"
)
}
for f in files:
contentnode_id = f.pop("contentnode")
if contentnode_id not in files_map:
files_map[contentnode_id] = []
lang_id = f.pop("lang_id")
f["lang"] = languages_map.get(lang_id)
files_map[contentnode_id].append(map_file(f))
tags_map = {}
for t in (
models.ContentTag.objects.filter(tagged_content__in=queryset)
.values(
"tag_name",
"tagged_content",
)
.order_by("tag_name")
):
if t["tagged_content"] not in tags_map:
tags_map[t["tagged_content"]] = [t["tag_name"]]
else:
tags_map[t["tagged_content"]].append(t["tag_name"])
return assessmentmetadata_map, files_map, languages_map, tags_map
def consolidate(self, items, queryset):
output = []
if items:
(
assessmentmetadata,
files_map,
languages_map,
tags,
) = self.get_related_data_maps(items, queryset)
for item in items:
item["assessmentmetadata"] = assessmentmetadata.get(item["id"])
item["tags"] = tags.get(item["id"], [])
item["files"] = files_map.get(item["id"], [])
thumb_file = next(
iter(filter(lambda f: f["thumbnail"] is True, item["files"])),
None,
)
if thumb_file:
item["thumbnail"] = thumb_file["storage_url"]
else:
item["thumbnail"] = None
lang_id = item.pop("lang_id")
item["lang"] = languages_map.get(lang_id)
item["is_leaf"] = item.get("kind") != content_kinds.TOPIC
output.append(item)
return output
class OptionalPagination(ValuesViewsetCursorPagination):
ordering = ("lft", "id")
page_size_query_param = "max_results"
class OptionalContentNodePagination(OptionalPagination):
def paginate_queryset(self, queryset, request, view=None):
# Record the queryset for use in returning available filters
self.queryset = queryset
return super(OptionalContentNodePagination, self).paginate_queryset(
queryset, request, view=view
)
def get_paginated_response(self, data):
return Response(
OrderedDict(
[
("more", self.get_more()),
("results", data),
("labels", get_available_metadata_labels(self.queryset)),
]
)
)
def get_paginated_response_schema(self, schema):
return {
"type": "object",
"properties": {
"more": {
"type": "object",
"nullable": True,
"example": {
"cursor": "asdadshjashjadh",
},
},
"results": schema,
"labels": {
"type": "object",
"example": {"accessibility_labels": ["id1", "id2"]},
},
},
}
def get_resume_queryset(request, queryset):
user = request.user
# if user is anonymous, don't return any nodes
# if person requesting is not the data they are requesting for, also return no nodes
if not user.is_facility_user:
return queryset.none()
# get the most recently viewed, but not finished, content nodes
content_ids = (
ContentSummaryLog.objects.filter(user=user, progress__gt=0)
.exclude(progress=1)
.values_list("content_id", flat=True)
)
return queryset.filter(content_id__in=content_ids)
@method_decorator(cache_forever, name="dispatch")
class ContentNodeViewset(BaseContentNodeMixin, ReadOnlyValuesViewset):
pagination_class = OptionalContentNodePagination
@list_route(methods=["get"])
def random(self, request, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
max_results = int(self.request.query_params.get("max_results", 10))
ids = list(queryset.order_by("?")[:max_results].values_list("id", flat=True))
queryset = models.ContentNode.objects.filter(id__in=ids)
return Response(self.serialize(queryset))
@list_route(methods=["get"])
def descendants(self, request):
"""
Returns a slim view all the descendants of a set of content nodes (as designated by the passed in ids).
In addition to id, title, kind, and content_id, each node is also annotated with the ancestor_id of one
of the ids that are passed into the request.
In the case where a node has more than one ancestor in the set of content nodes requested, duplicates of
that content node are returned, each annotated with one of the ancestor_ids for a node.
"""
ids = self.request.query_params.get("ids", None)
if not ids:
return Response([])
ids = ids.split(",")
kind = self.request.query_params.get("descendant_kind", None)
nodes = models.ContentNode.objects.filter_by_uuids(ids).filter(available=True)
data = []
for node in nodes:
def copy_node(new_node):
new_node["ancestor_id"] = node.id
new_node["is_leaf"] = new_node.get("kind") != content_kinds.TOPIC
return new_node
node_data = node.get_descendants().filter(available=True)
if kind:
node_data = node_data.filter(kind=kind)
data += map(
copy_node, node_data.values("id", "title", "kind", "content_id")
)
return Response(data)
@list_route(methods=["get"])
def descendants_assessments(self, request):
ids = self.request.query_params.get("ids", None)
if not ids:
return Response([])
ids = ids.split(",")
queryset = models.ContentNode.objects.filter_by_uuids(ids).filter(
available=True
)
data = list(
queryset.annotate(
num_assessments=SQSum(
models.ContentNode.objects.filter(
tree_id=OuterRef("tree_id"),
lft__gte=OuterRef("lft"),
lft__lt=OuterRef("rght"),
kind=content_kinds.EXERCISE,
available=True,
).values_list(
"assessmentmetadata__number_of_assessments", flat=True
),
field="number_of_assessments",
)
).values("id", "num_assessments")
)
return Response(data)
@list_route(methods=["get"])
def node_assessments(self, request):
ids = self.request.query_params.get("ids", "").split(",")
data = 0
if ids and ids[0]:
nodes = (
models.ContentNode.objects.filter_by_uuids(ids)
.filter(available=True)
.prefetch_related("assessmentmetadata")
)
data = (
nodes.aggregate(Sum("assessmentmetadata__number_of_assessments"))[
"assessmentmetadata__number_of_assessments__sum"
]
or 0
)
return Response(data)
@detail_route(methods=["get"])
def copies(self, request, pk=None):
"""
Returns each nodes that has this content id, along with their ancestors.
"""
# let it be noted that pk is actually the content id in this case
cache_key = "contentnode_copies_ancestors_{content_id}".format(content_id=pk)
if cache.get(cache_key) is not None:
return Response(cache.get(cache_key))
copies = []
nodes = models.ContentNode.objects.filter(content_id=pk, available=True)
for node in nodes:
copies.append(node.get_ancestors(include_self=True).values("id", "title"))
cache.set(cache_key, copies, 60 * 10)
return Response(copies)
@list_route(methods=["get"])
def copies_count(self, request, **kwargs):
"""
Returns the number of node copies for each content id.
"""
content_id_string = self.request.query_params.get("content_ids")
if content_id_string:
content_ids = content_id_string.split(",")
counts = (
models.ContentNode.objects.filter_by_content_ids(content_ids)
.filter(available=True)
.values("content_id")
.order_by()
.annotate(count=Count("content_id"))
)
else:
counts = 0
return Response(counts)
@detail_route(methods=["get"])
def next_content(self, request, **kwargs):
# retrieve the "next" content node, according to depth-first tree traversal.
# topicOnly flag set to true will find the next topic node after the parent
# of this item. Will return this_item parent if nothing found
this_item = self.get_object()
topic_only = request.query_params.get("topicOnly")
next_item_query = models.ContentNode.objects.filter(
available=True, tree_id=this_item.tree_id, lft__gt=this_item.rght
)
if topic_only:
next_item_query.filter(kind=content_kinds.TOPIC)
next_item = next_item_query.order_by("lft").first()
if not next_item:
next_item = this_item.get_root()
thumbnails = serializers.FileSerializer(
next_item.files.filter(thumbnail=True), many=True
).data
thumbnail = thumbnails[0]["storage_url"] if thumbnails else None
return Response(
{
"kind": next_item.kind,
"id": next_item.id,
"title": next_item.title,
"thumbnail": thumbnail,
"is_leaf": next_item.kind != content_kinds.TOPIC,
"learning_activities": _split_text_field(next_item.learning_activities),
"duration": next_item.duration,
}
)
@detail_route(methods=["get"])
def recommendations_for(self, request, **kwargs):
"""
Recommend items that are similar to this piece of content.
"""
queryset = self.filter_queryset(self.get_queryset())
pk = kwargs.get("pk", None)
node = get_object_or_404(queryset, pk=pk)
queryset = self.filter_queryset(self.get_queryset())
queryset = queryset & node.get_siblings(include_self=False).exclude(
kind=content_kinds.TOPIC
)
return Response(self.serialize(queryset))
@list_route(methods=["get"])
def next_steps(self, request, **kwargs):
"""
Recommend content that has user completed content as a prerequisite, or leftward sibling.
:param request: request object
:return: uncompleted content nodes, or empty queryset if user is anonymous
"""
user = request.user
queryset = self.get_queryset()
# if user is anonymous, don't return any nodes
# if person requesting is not the data they are requesting for, also return no nodes
if not user.is_facility_user:
queryset = queryset.none()
else:
completed_content_ids = ContentSummaryLog.objects.filter(
user=user, progress=1
).values_list("content_id", flat=True)
# If no logs, don't bother doing the other queries
if not completed_content_ids.exists():
queryset = queryset.none()
else:
completed_content_nodes = queryset.filter_by_content_ids(
completed_content_ids
).order_by()
# Filter to only show content that the user has not engaged in, so as not to be redundant with resume
queryset = (
queryset.exclude_by_content_ids(
ContentSummaryLog.objects.filter(user=user).values_list(
"content_id", flat=True
),
validate=False,
)
.filter(
Q(has_prerequisite__in=completed_content_nodes)
| Q(
lft__in=[
rght + 1
for rght in completed_content_nodes.values_list(
"rght", flat=True
)
]
)
)
.order_by()
)
if not (
user.roles.exists() or user.is_superuser
): # must have coach role or higher
queryset = queryset.exclude(coach_content=True)
return Response(self.serialize(queryset))
@list_route(methods=["get"])
def popular(self, request, **kwargs):
"""
Recommend content that is popular with all users.
:param request: request object
:return: 10 most popular content nodes
"""
cache_key = "popular_content"
if cache.get(cache_key) is not None:
return Response(cache.get(cache_key))
queryset = self.filter_queryset(self.get_queryset())
if ContentSessionLog.objects.count() < 50:
# return 25 random content nodes if not enough session logs
pks = queryset.values_list("pk", flat=True).exclude(
kind=content_kinds.TOPIC
)
# .count scales with table size, so can get slow on larger channels
count_cache_key = "content_count_for_popular"
count = cache.get(count_cache_key) or min(pks.count(), 25)
queryset = queryset.filter_by_uuids(
sample(list(pks), count), validate=False
)
else:
# get the most accessed content nodes
# search for content nodes that currently exist in the database
content_nodes = models.ContentNode.objects.filter(available=True)
content_counts_sorted = (
ContentSessionLog.objects.filter(
content_id__in=content_nodes.values_list(
"content_id", flat=True
).distinct()
)
.values_list("content_id", flat=True)
.annotate(Count("content_id"))
.order_by("-content_id__count")
)
most_popular = queryset.filter_by_content_ids(
list(content_counts_sorted[:20]), validate=False
)
queryset = most_popular.dedupe_by_content_id(use_distinct=False)
data = self.serialize(queryset)
# cache the popular results queryset for 10 minutes, for efficiency
cache.set(cache_key, data, 60 * 10)
return Response(data)
@list_route(methods=["get"])
def resume(self, request, **kwargs):
"""
Recommend content that the user has recently engaged with, but not finished.
:param request: request object
:return: 10 most recently viewed content nodes
"""
queryset = get_resume_queryset(request, self.get_queryset())
return Response(self.serialize(queryset))
# The max recursed page size should be less than 25 for a couple of reasons:
# 1. At this size the query appears to be relatively performant, and will deliver most of the tree
# data to the frontend in a single query.
# 2. In the case where the tree topology means that this will not produce the full query, the limit of
# 25 immediate children and 25 grand children means that we are at most using 1 + 25 + 25 * 25 = 651
# SQL parameters in the query to get the nodes for serialization - this means that we should not ever
# run into an issue where we hit a SQL parameters limit in the queries in here.
# If we find that this page size is too high, we should lower it, but for the reasons noted above, we
# should not raise it.
NUM_CHILDREN = 12
NUM_GRANDCHILDREN_PER_CHILD = 12
class TreeQueryMixin(object):
def validate_and_return_params(self, request):
depth = request.query_params.get("depth", 2)
next__gt = request.query_params.get("next__gt")
try:
depth = int(depth)
if 1 > depth or depth > 2:
raise ValueError
except ValueError:
raise ValidationError("Depth query parameter must have the value 1 or 2")
if next__gt is not None:
try:
next__gt = int(next__gt)
if 1 > next__gt:
raise ValueError
except ValueError:
raise ValidationError(
"next__gt query parameter must be a positive integer if specified"
)
return depth, next__gt
def get_grandchild_ids(self, child_ids, depth, page_size):
if depth == 2:
# Use this to keep track of how many grand children we have accumulated per child of the parent node
gc_by_parent = {}
# Iterate through the grand children of the parent node in lft order so we follow the tree traversal order
for gc in (
self.filter_queryset(self.get_queryset())
.filter(parent_id__in=child_ids)
.values("id", "parent_id")
.order_by("lft")
):
# If we have not already added a list of nodes to the gc_by_parent map, initialize it here
if gc["parent_id"] not in gc_by_parent:
gc_by_parent[gc["parent_id"]] = []
# If the number of grand children for a specific child node is less than the page size
# then we keep on adding them to both lists
# If not, we just skip this node, as we have already hit the page limit for the node that is
# its parent.
if len(gc_by_parent[gc["parent_id"]]) < page_size:
gc_by_parent[gc["parent_id"]].append(gc["id"])
yield gc["id"]
def get_tree_queryset(self, request, pk):
# Get the model for the parent node here - we do this so that we trigger a 404 immediately if the node
# does not exist (or exists but is not available, or is filtered).
parent_id = (
pk
if pk and self.filter_queryset(self.get_queryset()).filter(id=pk).exists()
else None
)
if parent_id is None:
raise Http404
depth, next__gt = self.validate_and_return_params(request)
# Get a list of child_ids of the parent node up to the pagination limit
child_qs = self.get_queryset().filter(parent_id=parent_id)
if next__gt is not None:
child_qs = child_qs.filter(lft__gt=next__gt)
child_ids = child_qs.values_list("id", flat=True).order_by("lft")[
0:NUM_CHILDREN
]
# Get a flat list of ids for grandchildren we will be returning
gc_ids = self.get_grandchild_ids(child_ids, depth, NUM_GRANDCHILDREN_PER_CHILD)
return self.filter_queryset(self.get_queryset()).filter(
Q(id=parent_id) | Q(id__in=child_ids) | Q(id__in=gc_ids)
)
@method_decorator(cache_forever, name="dispatch")