Skip to content

Commit fb5832f

Browse files
authored
Merge branch 'develop' into textboxes
2 parents 75a6c5a + 938945f commit fb5832f

File tree

168 files changed

+7861
-1046
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

168 files changed

+7861
-1046
lines changed

.eslintrc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
parserOptions:
22
ecmaVersion: 7
33
sourceType: module
4+
globals:
5+
__version: true
46
extends:
57
- eslint:recommended
68
- prettier
@@ -23,4 +25,4 @@ rules:
2325
vue/html-quotes: [2, 'double']
2426
vue/order-in-components: 2
2527
# ignores the 'kolibri.*' imports
26-
import/no-unresolved: [2, { ignore: ['kolibri\.'] }]
28+
import/no-unresolved: [2, { ignore: ['kolibri[_.]'] }]

frontend_build/src/parse_bundle_plugin.js

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,22 @@ var parseBundlePlugin = function(data, base_dir) {
9292
],
9393
};
9494

95+
// Calculate these paths here, so that we can export __publicPath as a variable in the webpack define plugin
96+
var publicPath, outputPath;
97+
98+
if (process.env.DEV_SERVER) {
99+
var devServerConfig = require('./webpackdevserverconfig');
100+
// If running webpack dev server point to that endpoint.
101+
publicPath = devServerConfig.publicPath;
102+
// Set output path to base dir, as no files will be written - all built files are cached in memory.
103+
outputPath = devServerConfig.basePath
104+
? path.resolve(path.join(base_dir, devServerConfig.basePath))
105+
: path.resolve(base_dir);
106+
} else {
107+
publicPath = path.join('/', data.static_url_root, data.name, '/');
108+
outputPath = path.join(data.static_dir, data.name);
109+
}
110+
95111
bundle.plugins = bundle.plugins.concat([
96112
new ExtractTextPlugin('[name]' + data.version + '.css'),
97113
new WebpackRTLPlugin(),
@@ -110,27 +126,14 @@ var parseBundlePlugin = function(data, base_dir) {
110126
__events: JSON.stringify(data.events || {}),
111127
__once: JSON.stringify(data.once || {}),
112128
__version: JSON.stringify(data.version),
129+
// This is necessary to allow modules that use service workers to fetch their service worker code
130+
__publicPath: JSON.stringify(publicPath),
113131
}),
114132
new extract$trs(data.locale_data_folder, data.name),
115133
]);
116134

117135
bundle = merge.smart(bundle, local_config);
118136

119-
var publicPath, outputPath;
120-
121-
if (process.env.DEV_SERVER) {
122-
var devServerConfig = require('./webpackdevserverconfig');
123-
// If running webpack dev server point to that endpoint.
124-
publicPath = devServerConfig.publicPath;
125-
// Set output path to base dir, as no files will be written - all built files are cached in memory.
126-
outputPath = devServerConfig.basePath
127-
? path.resolve(path.join(base_dir, devServerConfig.basePath))
128-
: path.resolve(base_dir);
129-
} else {
130-
publicPath = path.join('/', data.static_url_root, data.name, '/');
131-
outputPath = path.join(data.static_dir, data.name);
132-
}
133-
134137
bundle.core_name = data.core_name;
135138
bundle.name = data.name;
136139
bundle.context = base_dir;

kolibri/content/api.py

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
from django.db.models import Q, Sum
88
from django.db.models.aggregates import Count
99
from kolibri.content import models, serializers
10-
from kolibri.content.content_db_router import get_active_content_database
10+
from kolibri.content.content_db_router import get_active_content_database, using_content_database
1111
from kolibri.logger.models import ContentSessionLog, ContentSummaryLog
1212
from le_utils.constants import content_kinds
1313
from rest_framework import filters, pagination, viewsets
1414
from rest_framework.decorators import detail_route, list_route
15+
from rest_framework.generics import get_object_or_404
1516
from rest_framework.response import Response
1617
from six.moves.urllib.parse import parse_qs, urlparse
1718

@@ -250,15 +251,48 @@ class ContentNodeViewset(viewsets.ModelViewSet):
250251
filter_class = ContentNodeFilter
251252
pagination_class = OptionalPageNumberPagination
252253

253-
def get_queryset(self):
254-
return models.ContentNode.objects.all().prefetch_related(
254+
def prefetch_related(self, queryset):
255+
return queryset.prefetch_related(
255256
'assessmentmetadata',
256257
'files',
257258
).select_related('license')
258259

260+
def get_queryset(self, prefetch=True):
261+
queryset = models.ContentNode.objects.all()
262+
if prefetch:
263+
return self.prefetch_related(queryset)
264+
return queryset
265+
266+
def get_object(self, prefetch=True):
267+
"""
268+
Returns the object the view is displaying.
269+
You may want to override this if you need to provide non-standard
270+
queryset lookups. Eg if objects are referenced using multiple
271+
keyword arguments in the url conf.
272+
"""
273+
queryset = self.filter_queryset(self.get_queryset(prefetch=prefetch))
274+
275+
# Perform the lookup filtering.
276+
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
277+
278+
assert lookup_url_kwarg in self.kwargs, (
279+
'Expected view %s to be called with a URL keyword argument '
280+
'named "%s". Fix your URL conf, or set the `.lookup_field` '
281+
'attribute on the view correctly.' %
282+
(self.__class__.__name__, lookup_url_kwarg)
283+
)
284+
285+
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
286+
obj = get_object_or_404(queryset, **filter_kwargs)
287+
288+
# May raise a permission denied
289+
self.check_object_permissions(self.request, obj)
290+
291+
return obj
292+
259293
@detail_route(methods=['get'])
260294
def descendants(self, request, **kwargs):
261-
node = self.get_object()
295+
node = self.get_object(prefetch=False)
262296
kind = self.request.query_params.get('descendant_kind', None)
263297
descendants = node.get_descendants()
264298
if kind:
@@ -269,7 +303,16 @@ def descendants(self, request, **kwargs):
269303

270304
@detail_route(methods=['get'])
271305
def ancestors(self, request, **kwargs):
272-
return Response(self.get_object().get_ancestors().values('pk', 'title'))
306+
cache_key = 'contentnode_ancestors_{db}_{pk}'.format(db=get_active_content_database(), pk=kwargs.get('pk'))
307+
308+
if cache.get(cache_key) is not None:
309+
return Response(cache.get(cache_key))
310+
311+
ancestors = list(self.get_object(prefetch=False).get_ancestors().values('pk', 'title'))
312+
313+
cache.set(cache_key, ancestors, 60 * 10)
314+
315+
return Response(ancestors)
273316

274317
@detail_route(methods=['get'])
275318
def next_content(self, request, **kwargs):
@@ -316,10 +359,11 @@ def get_queryset(self):
316359

317360
class ChannelFileSummaryViewSet(viewsets.ViewSet):
318361
def list(self, request, **kwargs):
319-
file_summary = models.File.objects.aggregate(
320-
total_files=Count('pk'),
321-
total_file_size=Sum('file_size')
322-
)
323-
file_summary['channel_id'] = get_active_content_database()
324-
# Need to wrap in an array to be fetchable as a Collection on client
325-
return Response([file_summary])
362+
with using_content_database(kwargs['channel_id']):
363+
file_summary = models.File.objects.aggregate(
364+
total_files=Count('pk'),
365+
total_file_size=Sum('file_size')
366+
)
367+
file_summary['channel_id'] = get_active_content_database()
368+
# Need to wrap in an array to be fetchable as a Collection on client
369+
return Response([file_summary])

kolibri/content/fixtures/content_test.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,23 +159,29 @@
159159
{
160160
"fields": {
161161
"lang_code": "en",
162-
"lang_subcode": "01"
162+
"lang_subcode": "01",
163+
"lang_name": "English-Test",
164+
"lang_direction": "ltr"
163165
},
164166
"pk": 1,
165167
"model": "content.language"
166168
},
167169
{
168170
"fields": {
169171
"lang_code": "zh",
170-
"lang_subcode": "02"
172+
"lang_subcode": "02",
173+
"lang_name": "Chinese-Test",
174+
"lang_direction": "ltr"
171175
},
172176
"pk": 2,
173177
"model": "content.language"
174178
},
175179
{
176180
"fields": {
177181
"lang_code": "es",
178-
"lang_subcode": "03"
182+
"lang_subcode": "03",
183+
"lang_name": "Spanish-Test",
184+
"lang_direction": "ltr"
179185
},
180186
"pk": 3,
181187
"model": "content.language"

kolibri/content/middleware.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ class ContentDBRoutingMiddleware(object):
99
"""
1010

1111
def process_view(self, request, view_func, view_args, view_kwargs):
12-
request.PREVIOUSLY_ACTIVE_CONTENT_DATABASE = get_active_content_database(return_none_if_not_set=True)
13-
if "channel_id" in view_kwargs:
14-
set_active_content_database(view_kwargs["channel_id"])
12+
if view_func.__name__ == 'TasksViewSet':
13+
pass # Fix #1818.1: skip get_active_content_database for Task workers to avoid locking DB
14+
else:
15+
request.PREVIOUSLY_ACTIVE_CONTENT_DATABASE = get_active_content_database(return_none_if_not_set=True)
16+
if "channel_id" in view_kwargs:
17+
set_active_content_database(view_kwargs["channel_id"])
1518

1619
def process_response(self, request, response):
1720
set_active_content_database(getattr(request, "PREVIOUSLY_ACTIVE_CONTENT_DATABASE", None))
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated by Django 1.9.7 on 2017-08-01 19:50
3+
from __future__ import unicode_literals
4+
5+
import django.db.models.deletion
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('content', '0003_auto_20170607_1212'),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name='contentnode',
18+
name='lang',
19+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='content.Language'),
20+
),
21+
migrations.AddField(
22+
model_name='language',
23+
name='lang_direction',
24+
field=models.CharField(choices=[('ltr', 'Left to Right'), ('rtl', 'Right to Left')], default='ltr', max_length=3),
25+
preserve_default=False,
26+
),
27+
migrations.AddField(
28+
model_name='language',
29+
name='lang_name',
30+
field=models.CharField(blank=True, max_length=100, null=True),
31+
),
32+
migrations.AlterField(
33+
model_name='language',
34+
name='id',
35+
field=models.CharField(max_length=14, primary_key=True, serialize=False),
36+
),
37+
migrations.AlterField(
38+
model_name='language',
39+
name='lang_subcode',
40+
field=models.CharField(blank=True, db_index=True, max_length=10, null=True),
41+
),
42+
]

kolibri/content/models.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from jsonfield import JSONField
1717
from kolibri.core.fields import DateTimeTzField
1818
from le_utils.constants import content_kinds, file_formats, format_presets
19+
from le_utils.constants.languages import LANGUAGE_DIRECTIONS
1920
from mptt.models import MPTTModel, TreeForeignKey
2021
from mptt.querysets import TreeQuerySet
2122

@@ -123,6 +124,7 @@ class ContentNode(MPTTModel, ContentDatabaseModel):
123124
kind = models.CharField(max_length=200, choices=content_kinds.choices, blank=True)
124125
available = models.BooleanField(default=False)
125126
stemmed_metaphone = models.CharField(max_length=1800, blank=True) # for fuzzy search in title and description
127+
lang = models.ForeignKey('Language', blank=True, null=True)
126128

127129
objects = ContentQuerySet.as_manager()
128130

@@ -158,14 +160,17 @@ def get_descendant_kind_counts(self):
158160

159161
@python_2_unicode_compatible
160162
class Language(ContentDatabaseModel):
161-
id = models.CharField(max_length=7, primary_key=True)
163+
id = models.CharField(max_length=14, primary_key=True)
162164
lang_code = models.CharField(max_length=3, db_index=True)
163-
lang_subcode = models.CharField(max_length=3, db_index=True, blank=True, null=True)
165+
lang_subcode = models.CharField(max_length=10, db_index=True, blank=True, null=True)
166+
# Localized name
167+
lang_name = models.CharField(max_length=100, blank=True, null=True)
168+
lang_direction = models.CharField(max_length=3, choices=LANGUAGE_DIRECTIONS)
164169

165170
objects = ContentQuerySet.as_manager()
166171

167172
def __str__(self):
168-
return self.lang_code
173+
return self.lang_name
169174

170175

171176
@python_2_unicode_compatible

kolibri/content/serializers.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.core.cache import cache
12
from django.db.models import Manager, Sum
23
from django.db.models.query import RawQuerySet
34
from kolibri.content.models import AssessmentMetaData, ChannelMetadataCache, ContentNode, File
@@ -115,6 +116,20 @@ class ContentNodeListSerializer(serializers.ListSerializer):
115116

116117
def to_representation(self, data):
117118

119+
# Dealing with nested relationships, data can be a Manager,
120+
# so, first get a queryset from the Manager if needed
121+
data = data.all() if isinstance(data, Manager) else data
122+
123+
cache_key = None
124+
# Cache parent look ups only
125+
if "parent" in self.context['request'].GET:
126+
cache_key = 'contentnode_list_{db}_{parent}'.format(
127+
db=get_active_content_database(),
128+
parent=self.context['request'].GET.get('parent'))
129+
130+
if cache.get(cache_key):
131+
return cache.get(cache_key)
132+
118133
if not data:
119134
return data
120135

@@ -125,17 +140,25 @@ def to_representation(self, data):
125140
# Don't annotate topic progress as too expensive
126141
progress_dict = get_content_progress_fractions(data, user)
127142

128-
# Dealing with nested relationships, data can be a Manager,
129-
# so, first get a queryset from the Manager if needed
130-
iterable = data.all() if isinstance(data, Manager) else data
131-
132-
return [
133-
self.child.to_representation(
143+
result = []
144+
topic_only = True
145+
for item in data:
146+
obj = self.child.to_representation(
134147
item,
135148
progress_fraction=progress_dict.get(item.content_id),
136149
annotate_progress_fraction=False
137-
) for item in iterable
138-
]
150+
)
151+
topic_only = topic_only and obj.get('kind') == content_kinds.TOPIC
152+
result.append(obj)
153+
154+
# Only store if all nodes are topics, because we don't annotate progress on them
155+
# This has the happy side effect of not caching our dynamically calculated
156+
# recommendation queries, which might change for the same user over time
157+
# because they do not return topics
158+
if topic_only and cache_key:
159+
cache.set(cache_key, result, 60 * 10)
160+
161+
return result
139162

140163

141164
class ContentNodeSerializer(serializers.ModelSerializer):
@@ -165,7 +188,8 @@ def to_representation(self, instance, progress_fraction=None, annotate_progress_
165188
progress_fraction = 0.0
166189
else:
167190
user = self.context["request"].user
168-
progress_fraction = get_content_progress_fraction(instance, user)
191+
if instance.kind != content_kinds.TOPIC:
192+
progress_fraction = get_content_progress_fraction(instance, user)
169193
value = super(ContentNodeSerializer, self).to_representation(instance)
170194
value['progress_fraction'] = progress_fraction
171195
return value

kolibri/content/test/test_content_app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def test_all_str(self):
135135
self.assertEqual(str(p), 'tag_2')
136136
# test for Language __str__
137137
p = content.Language.objects.get(lang_code="en")
138-
self.assertEqual(str(p), 'en')
138+
self.assertEqual(str(p), 'English-Test')
139139
# test for ChannelMetadata __str__
140140
p = content.ChannelMetadata.objects.get(name="testing")
141141
self.assertEqual(str(p), 'testing')

kolibri/content/utils/annotation.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,7 @@ def update_channel_metadata_cache():
3232
if ch_metadata_obj.last_updated is None:
3333
ch_metadata_obj.last_updated = local_now()
3434
ch_metadata_obj.save()
35+
36+
# Fix #1818.1 content database files get locked (this is called on startup)
37+
from django.db import connections
38+
connections.close_all()

0 commit comments

Comments
 (0)