Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use manifest.json files to download collections #436

Merged
merged 9 commits into from
Jul 14, 2022
34 changes: 27 additions & 7 deletions kolibri_explore_plugin/assets/src/views/ExploreIndex.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,22 @@
<div>
<GradeSelectionModal
:visible="gradeModalVisible"
:error="installError"
@gradeSelected="gradeSelected"
/>
<CollectionSelectionModal
:visible="collectionModalVisible"
:grade="grade"
:collections="collections"
:collections="gradeCollections"
@downloadCollection="downloadCollection"
@goBack="visibleModal = 'grade'"
/>
<InstallContentModal
:visible="contentModalVisible"
:collection="downloadingCollection"
:grade="grade"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like "grade" is an awkward word to use here since it's very specific to our current implementation, but at the same time the alternatives I can think of ("group", "category") would be terrrible and confusing, so 🤷

@showModal="visibleModal = 'content'"
@hide="visibleModal = 'none'"
@hide="installContentHide"
@newContent="reloadChannels"
/>
</div>
Expand Down Expand Up @@ -52,7 +54,7 @@
import commonCoreStrings from 'kolibri.coreVue.mixins.commonCoreStrings';
import LoadingImage from 'eos-components/src/assets/loading-animation.gif';
import responsiveWindowMixin from 'kolibri.coreVue.mixins.responsiveWindowMixin';
import { ChannelResource } from 'kolibri.resources';
import { ChannelResource, ContentNodeResource } from 'kolibri.resources';
import axios from 'axios';
import { constants } from 'eos-components';
import { showChannels } from '../modules/topicsRoot/handlers';
Expand Down Expand Up @@ -90,12 +92,16 @@
isLoading: false,
visibleModal: 'none',
downloadingCollection: null,
collections: [],
collections: {},
grade: 'intermediate',
installError: '',
};
},
computed: {
...mapState(['noContent', 'pageName']),
gradeCollections() {
return Object.values(this.collections[this.grade] || {});
},
currentPage() {
return pageNameToComponentMap[this.pageName] || null;
},
Expand Down Expand Up @@ -141,11 +147,13 @@
mounted() {
axios.get(constants.ApiURL).then(({ data }) => {
if (data.collections) {
this.collections = Object.values(data.collections);
this.collections = data.collections;
}

if (data.collection) {
this.downloadCollection(data.collections[data.collection]);
const [grade, size] = data.collection.split('-');
const collection = data.collections[grade][size];
this.downloadCollection(grade, collection);
}
});
},
Expand All @@ -157,18 +165,30 @@
this.isLoading = false;
},
reloadChannels() {
ContentNodeResource.useContentCacheKey = false;
ContentNodeResource.clearCache();
ChannelResource.useContentCacheKey = false;
ChannelResource.clearCache();
showChannels(this.$store);
this.$store.commit('SET_NOCONTENT', false);
},
downloadCollection(collection) {
downloadCollection(grade, collection) {
this.grade = grade;
this.downloadingCollection = collection;
this.visibleModal = 'content';
},
gradeSelected(grade) {
this.grade = grade;
this.visibleModal = 'collection';
this.installError = '';
},
installContentHide(error) {
if (error || this.installError) {
this.visibleModal = 'grade';
this.installError = 'Can not install the selected collection';
} else {
this.visibleModal = 'none';
}
},
},
};
Expand Down
8 changes: 8 additions & 0 deletions kolibri_explore_plugin/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@
to the package install directory.
""",
},
"CONTENT_COLLECTIONS_PATH": {
"type": "string",
"default": "",
"description": """
Location where collections manifests are stored. Defaults
to the static/collections folder.
""",
},
"SHOW_AS_STANDALONE_CHANNEL": {
"type": "boolean",
"default": False,
Expand Down
130 changes: 92 additions & 38 deletions kolibri_explore_plugin/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@
from django.views.generic.base import TemplateView
from django.views.generic.base import View
from kolibri.core.content.api import cache_forever
from kolibri.core.content.api import RemoteChannelViewSet
from kolibri.core.content.zip_wsgi import add_security_headers
from kolibri.core.content.zip_wsgi import get_embedded_file
from kolibri.core.decorators import cache_no_user_data
from kolibri.core.tasks.api import _job_to_response
from kolibri.core.tasks.api import _remoteimport
from kolibri.core.tasks.exceptions import JobNotFound
from kolibri.core.tasks.job import State
from kolibri.core.tasks.main import queue
from kolibri.utils import conf
Expand All @@ -35,6 +35,13 @@
APPS_BUNDLE_PATHS.append(os.path.join(os.path.dirname(__file__), "apps"))


COLLECTION_PATHS = os.path.join(
os.path.dirname(__file__), "static", "collections"
)
if conf.OPTIONS["Explore"]["CONTENT_COLLECTIONS_PATH"]:
COLLECTION_PATHS = conf.OPTIONS["Explore"]["CONTENT_COLLECTIONS_PATH"]


@method_decorator(cache_no_user_data, name="dispatch")
class ExploreView(TemplateView):
template_name = "explore/explore.html"
Expand Down Expand Up @@ -112,46 +119,84 @@ def get(self, request, app):

@method_decorator(csrf_exempt, name="dispatch")
class EndlessLearningCollection(View):
COLLECTIONS = {
"small": {
"title": "3 GB",
"subtitle": "Small",
"channels": 10,
"size": 3,
"text": "Primary",
"token": "kopip-lakip",
"available": True,
grade_collections = {
"primary": {
"small": {
"title": "2 GB",
"subtitle": "Small",
"channels": 13,
"size": 2,
"text": "Primary",
"token": "kopip-lakip",
"available": True,
},
"large": {
"title": "5 GB",
"subtitle": "Large",
"channels": 14,
"size": 5,
"text": "Primary",
"token": "vofog-gufap",
"available": True,
},
},
"medium": {
"title": "6 GB",
"subtitle": "Medium",
"channels": 10,
"size": 6,
"text": "Intermediate",
"token": "zubit-vusus",
"available": True,
"intermediate": {
"small": {
"title": "3 GB",
"subtitle": "Small",
"channels": 22,
"size": 3,
"text": "Intermediate",
"token": "kopip-lakip",
"available": True,
},
"large": {
"title": "6 GB",
"subtitle": "Large",
"channels": 22,
"size": 6,
"text": "Intermediate",
"token": "vofog-gufap",
"available": True,
},
},
"large": {
"title": "12 GB",
"subtitle": "Large",
"channels": 10,
"size": 12,
"text": "Secondary",
"token": "vofog-gufap",
"available": True,
"secondary": {
"small": {
"title": "3 GB",
"subtitle": "Small",
"channels": 23,
"size": 3,
"text": "Secondary",
"token": "kopip-lakip",
"available": True,
},
"large": {
"title": "6 GB",
"subtitle": "Large",
"channels": 24,
"size": 6,
"text": "Secondary",
"token": "vofog-gufap",
"available": True,
},
},
}

BASE_URL = "https://kolibri-content.endlessos.org/"

def check_collection_availability(self):
free_space_gb = get_free_space() / 1024**3
for _k, v in self.COLLECTIONS.items():
v["available"] = v["size"] < free_space_gb
for collections in self.grade_collections.values():
for v in collections.values():
v["available"] = v["size"] < free_space_gb

def get(self, request):
job_ids = request.session.get("job_ids", [])
jobs = [queue.fetch_job(job) for job in job_ids]
try:
jobs = [queue.fetch_job(job) for job in job_ids]
except JobNotFound:
request.session["job_ids"] = []
jobs = []
running = [job for job in jobs if job.state == State.RUNNING]
pid, _, _ = get_status()

Expand Down Expand Up @@ -184,7 +229,7 @@ def get(self, request):
collection = request.session.get("downloading")
self.check_collection_availability()
jobs_response = {
"collections": self.COLLECTIONS,
"collections": self.grade_collections,
"collection": collection,
"jobs": [_job_to_response(job) for job in jobs],
}
Expand All @@ -194,25 +239,30 @@ def get(self, request):
)

def post(self, request):
grade = "primary"
collection = "small"
if request.body:
data = json.loads(request.body)
collection = data.get("collection", "small")
grade = data.get("grade", "primary")

token = self.COLLECTIONS[collection]["token"]

channel_viewset = RemoteChannelViewSet()
channels = channel_viewset._make_channel_endpoint_request(
identifier=token
collection_manifest = os.path.join(
COLLECTION_PATHS, f"{grade}-{collection}.json"
)

job_ids = []
if not os.path.exists(collection_manifest):
raise Http404("Collection manifest not found")

manifest = {}
with open(collection_manifest) as f:
manifest = json.load(f)
channels = manifest.get("channels", [])

job_ids = []
pid, _, _ = get_status()
for channel in channels:
task = {
"channel_id": channel["id"],
"channel_name": channel["name"],
"baseurl": self.BASE_URL,
"started_by_username": "endless",
"type": "REMOTEIMPORT",
Expand All @@ -223,6 +273,10 @@ def post(self, request):
_remoteimport,
task["channel_id"],
task["baseurl"],
# Done this way to convert [] to None
node_ids=channel.get("include_node_ids") or None,
# Done this way to convert [] to None
exclude_node_ids=channel.get("exclude_node_ids") or None,
Comment on lines +276 to +279
Copy link
Collaborator

@dylanmccall dylanmccall Jul 13, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 there's a subtle difference in how node_ids=[] is handled between the code that copies data and the code that marks nodes as available, so this is a useful way to deal with that.

It's worth noting this behaviour will be different with learningequality/kolibri#9460. Setting include_node_ids: [] is the same as saying "no nodes", while include_node_ids: None means "the default, which is all of the nodes".

Once we have Kolibri with my changes applied, the manifest file code should actually never create a manifest file with the latter: if it includes all the nodes from a channel, it ends up with include_node_ids: [${channel_id}]. If a manifest file has include_node_ids: [], it's probably wrong but there's no need arguing with it.

Hopefully in a future iteration we can pass a manifest file directly to the remoteimport task so we don't need to deal with all this :)

extra_metadata=task,
track_progress=True,
cancellable=True,
Expand All @@ -232,7 +286,7 @@ def post(self, request):
# Two weeks session expiry
request.session.set_expiry(1209600)
request.session["job_ids"] = job_ids
request.session["downloading"] = collection
request.session["downloading"] = f"{grade}-{collection}"
return HttpResponse(
json.dumps(job_ids), content_type="application/json"
)
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
:text="`${collection.channels} Channels`"
:secondaryText="collection.text"
:disabled="!collection.available"
@click="$emit('downloadCollection', collection)"
@click="$emit('downloadCollection', grade, collection)"
>
<b-button
class="mt-3"
Expand Down
13 changes: 13 additions & 0 deletions packages/eos-components/src/components/GradeSelectionModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@
selections:
</h6>

<b-alert
v-if="error"
variant="danger"
show
fade
>
{{ error }}
</b-alert>

<b-card-group deck class="py-5">
<WelcomeCard
v-for="grade in grades"
Expand Down Expand Up @@ -65,6 +74,10 @@
type: Boolean,
default: false,
},
error: {
type: String,
default: '',
},
},
data() {
return {
Expand Down
Loading