Skip to content

Commit

Permalink
Single-user sync exams, migrate to hooks, and upgrade morango to 0.6.2
Browse files Browse the repository at this point in the history
  • Loading branch information
jamalex committed Jul 27, 2021
1 parent c52ed7b commit 455b6c8
Show file tree
Hide file tree
Showing 13 changed files with 414 additions and 87 deletions.
7 changes: 7 additions & 0 deletions kolibri/core/auth/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,10 @@ class KolibriAuthConfig(AppConfig):
def ready(self):
from .signals import cascade_delete_membership # noqa: F401
from .signals import cascade_delete_user # noqa: F401

from kolibri.core.auth.sync_event_hook_utils import (
register_sync_event_handlers,
) # noqa: F401
from morango.api.viewsets import session_controller # noqa: F401

register_sync_event_handlers(session_controller)
29 changes: 29 additions & 0 deletions kolibri/core/auth/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from kolibri.plugins.hooks import define_hook
from kolibri.plugins.hooks import KolibriHook


@define_hook
class FacilityDataSyncHook(KolibriHook):
"""
A hook to allow plugins to register callbacks for sync events they're interested in.
"""

def pre_transfer(
self,
dataset_id,
local_is_single_user,
remote_is_single_user,
single_user_id,
context,
):
pass

def post_transfer(
self,
dataset_id,
local_is_single_user,
remote_is_single_user,
single_user_id,
context,
):
pass
7 changes: 3 additions & 4 deletions kolibri/core/auth/management/commands/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,14 @@
from kolibri.core.auth.management.utils import get_facility
from kolibri.core.auth.management.utils import run_once
from kolibri.core.auth.models import dataset_cache
from kolibri.core.lessons.single_user_assignment_utils import (
register_single_user_sync_lesson_handlers,
)
from kolibri.core.auth.sync_event_hook_utils import register_sync_event_handlers
from kolibri.core.logger.utils.data import bytes_for_humans
from kolibri.core.tasks.exceptions import UserCancelledError
from kolibri.core.tasks.management.commands.base import AsyncCommand
from kolibri.core.utils.lock import db_lock
from kolibri.utils import conf


DATA_PORTAL_SYNCING_BASE_URL = conf.OPTIONS["Urls"]["DATA_PORTAL_SYNCING_BASE_URL"]
TRANSFER_MESSAGE = "{records_transferred}/{records_total}, {transfer_total}"

Expand Down Expand Up @@ -221,7 +220,7 @@ def handle_async(self, *args, **options): # noqa C901
client_cert, server_cert, chunk_size=chunk_size
)

register_single_user_sync_lesson_handlers(sync_session_client.controller)
register_sync_event_handlers(sync_session_client.controller)

try:
# pull from server
Expand Down
69 changes: 69 additions & 0 deletions kolibri/core/auth/sync_event_hook_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import json

from morango.sync.context import LocalSessionContext

from kolibri.core.auth.constants.morango_sync import ScopeDefinitions
from kolibri.core.auth.hooks import FacilityDataSyncHook


def _get_our_cert(context):
ss = context.sync_session
return ss.server_certificate if ss.is_server else ss.client_certificate


def _get_their_cert(context):
ss = context.sync_session
return ss.client_certificate if ss.is_server else ss.server_certificate


def _this_side_using_single_user_cert(context):
return _get_our_cert(context).scope_definition_id == ScopeDefinitions.SINGLE_USER


def _other_side_using_single_user_cert(context):
return _get_their_cert(context).scope_definition_id == ScopeDefinitions.SINGLE_USER


def _get_user_id_for_single_user_sync(context):
if _other_side_using_single_user_cert(context):
cert = _get_their_cert(context)
elif _this_side_using_single_user_cert(context):
cert = _get_our_cert(context)
else:
return None
return json.loads(cert.scope_params)["user_id"]


def _extract_kwargs_from_context(context):
return {
"dataset_id": _get_our_cert(context).get_root().id,
"local_is_single_user": _this_side_using_single_user_cert(context),
"remote_is_single_user": _other_side_using_single_user_cert(context),
"single_user_id": _get_user_id_for_single_user_sync(context),
"context": context,
}


def _pre_transfer_handler(context):
assert context is not None

kwargs = _extract_kwargs_from_context(context)

if isinstance(context, LocalSessionContext):
for hook in FacilityDataSyncHook.registered_hooks:
hook.pre_transfer(**kwargs)


def _post_transfer_handler(context):
assert context is not None

kwargs = _extract_kwargs_from_context(context)

if isinstance(context, LocalSessionContext):
for hook in FacilityDataSyncHook.registered_hooks:
hook.post_transfer(**kwargs)


def register_sync_event_handlers(session_controller):
session_controller.signals.initializing.completed.connect(_pre_transfer_handler)
session_controller.signals.cleanup.completed.connect(_post_transfer_handler)
2 changes: 1 addition & 1 deletion kolibri/core/auth/test/test_morango_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ def test_single_user_assignment_sync(self, servers):

# repeat the same sets of scenarios, but separately for an exam and a lesson, and with
# different methods for disabling the assignment as part of the process
for kind in ("lesson",): # ("exam", "lesson"):
for kind in ("exam", "lesson"):
for disable_assignment in (self.deactivate, self.unassign):

# Create on Laptop A, single-user sync to tablet, disable, repeat
Expand Down
35 changes: 35 additions & 0 deletions kolibri/core/exams/kolibri_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from .single_user_assignment_utils import (
update_assignments_from_individual_syncable_exams,
)
from .single_user_assignment_utils import (
update_individual_syncable_exams_from_assignments,
)
from kolibri.core.auth.hooks import FacilityDataSyncHook
from kolibri.plugins.hooks import register_hook


@register_hook
class SingleUserLessonSyncHook(FacilityDataSyncHook):
def pre_transfer(
self,
dataset_id,
local_is_single_user,
remote_is_single_user,
single_user_id,
context,
):
# if we're about to send data to a single-user device, prep the syncable exam assignments
if context.is_producer and remote_is_single_user:
update_individual_syncable_exams_from_assignments(single_user_id)

def post_transfer(
self,
dataset_id,
local_is_single_user,
remote_is_single_user,
single_user_id,
context,
):
# if we've just received data on a single-user device, update the exams and assignments
if context.is_receiver and local_is_single_user:
update_assignments_from_individual_syncable_exams(single_user_id)
69 changes: 69 additions & 0 deletions kolibri/core/exams/migrations/0005_individualsyncableexam.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2021-07-27 03:17
from __future__ import unicode_literals

import django.db.models.deletion
import morango.models.fields.uuids
from django.conf import settings
from django.db import migrations
from django.db import models

import kolibri.core.fields


class Migration(migrations.Migration):

dependencies = [
("kolibriauth", "0019_collection_no_mptt"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("exams", "0004_exam_add_dates_opened_created_and_archived"),
]

operations = [
migrations.CreateModel(
name="IndividualSyncableExam",
fields=[
(
"id",
morango.models.fields.uuids.UUIDField(
editable=False, primary_key=True, serialize=False
),
),
(
"_morango_dirty_bit",
models.BooleanField(default=True, editable=False),
),
("_morango_source_id", models.CharField(editable=False, max_length=96)),
(
"_morango_partition",
models.CharField(editable=False, max_length=128),
),
("exam_id", models.UUIDField()),
("serialized_exam", kolibri.core.fields.JSONField()),
(
"collection",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="kolibriauth.Collection",
),
),
(
"dataset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="kolibriauth.FacilityDataset",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
]
50 changes: 50 additions & 0 deletions kolibri/core/exams/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,53 @@ def calculate_source_id(self):

def calculate_partition(self):
return self.dataset_id


class IndividualSyncableExam(AbstractFacilityDataModel):
"""
Represents a Exam and its assignment to a particular user
in such a way that it can be synced to a single-user device.
Note: This is not the canonical representation of a user's
relation to an exam (which is captured in an ExamAssignment
combined with a user's Membership in an associated Collection;
the purpose of this model is as a derived/denormalized
representation of a specific user's exam assignments).
"""

morango_model_name = "individualsyncableexam"

user = models.ForeignKey(FacilityUser)
collection = models.ForeignKey(Collection)
exam_id = models.UUIDField()

serialized_exam = JSONField()

def infer_dataset(self, *args, **kwargs):
return self.cached_related_dataset_lookup("user")

def calculate_source_id(self):
return self.exam_id

def calculate_partition(self):
return "{dataset_id}:user-ro:{user_id}".format(
dataset_id=self.dataset_id, user_id=self.user_id
)

@classmethod
def serialize_exam(cls, exam):
serialized = exam.serialize()
for key in [
"active",
"creator_id",
"date_created",
"date_activated",
"collection_id",
]:
serialized.pop(key, None)
return serialized

@classmethod
def deserialize_exam(cls, serialized_exam):
exam = Exam.deserialize(serialized_exam)
exam.active = True
return exam
Loading

0 comments on commit 455b6c8

Please sign in to comment.