Repository: learningequality/kolibri

Issue:
Create proof of concept single user syncing to test for edge cases and possible issues
To ensure that single user syncing behaviour is feasible and properly scoped, first create a proof of concept that handles the following data set:

* Single facility
* Two classrooms
* Multiple users in each classroom
* Multiple learner groups in each classroom
* Lessons and quizzes in each classroom, both assigned and unassigned
* Learner to be synced is a member of at least two groups
* At least 2 lessons and quizzes are assigned to each of the groups in the learner's classroom
* At least 2 lessons and quizzes are assigned to the learner's classroom

Conduct syncing and identify any errors, or when data gets stuck in the store as not able to deserialize due to missing foreign keys.


Discussion:

Resolving patch:
diff --git a/kolibri/core/auth/management/commands/sync.py b/kolibri/core/auth/management/commands/sync.py
--- a/kolibri/core/auth/management/commands/sync.py
+++ b/kolibri/core/auth/management/commands/sync.py
@@ -1,6 +1,7 @@
 import json
 import logging
 import math
+import re
 from contextlib import contextmanager
 
 from django.core.management import call_command
@@ -14,6 +15,8 @@
 from ..utils import get_baseurl
 from ..utils import get_client_and_server_certs
 from ..utils import get_dataset_id
+from ..utils import get_single_user_sync_filter
+from ..utils import provision_single_user_device
 from kolibri.core.auth.constants.morango_sync import PROFILE_FACILITY_DATA
 from kolibri.core.auth.constants.morango_sync import ScopeDefinitions
 from kolibri.core.auth.constants.morango_sync import State
@@ -59,12 +62,17 @@ def add_arguments(self, parser):
         parser.add_argument(
             "--username",
             type=str,
-            help="username of superuser on server we are syncing with",
+            help="username of superuser or facility admin on server we are syncing with",
         )
         parser.add_argument(
             "--password",
             type=str,
-            help="password of superuser on server we are syncing with",
+            help="password of superuser or facility admin on server we are syncing with",
+        )
+        parser.add_argument(
+            "--user",
+            type=str,
+            help="for single-user syncing, the user ID of the account to be synced",
         )
         parser.add_argument(
             "--no-provision",
@@ -81,6 +89,7 @@ def handle_async(self, *args, **options):  # noqa C901
             chunk_size,
             username,
             password,
+            user_id,
             no_push,
             no_pull,
             noninteractive,
@@ -91,6 +100,7 @@ def handle_async(self, *args, **options):  # noqa C901
             options["chunk_size"],
             options["username"],
             options["password"],
+            options["user"],
             options["no_push"],
             options["no_pull"],
             options["noninteractive"],
@@ -122,7 +132,36 @@ def handle_async(self, *args, **options):  # noqa C901
                 "Device can not sync with itself. Please recheck base URL and try again."
             )
 
-        if PORTAL_SYNC:  # do portal sync setup
+        if user_id:  # it's a single-user sync
+
+            if not facility_id:
+                raise CommandError(
+                    "Facility ID must be specified in order to do single-user syncing"
+                )
+            if not re.match("[a-f0-9]{32}", user_id):
+                raise CommandError("User ID must be a 32-character UUID (no dashes)")
+
+            dataset_id = get_dataset_id(
+                baseurl, identifier=facility_id, noninteractive=True
+            )
+
+            client_cert, server_cert, username = get_client_and_server_certs(
+                username,
+                password,
+                dataset_id,
+                network_connection,
+                user_id=user_id,
+                noninteractive=noninteractive,
+            )
+
+            scopes = [client_cert.scope_definition_id, server_cert.scope_definition_id]
+
+            if len(set(scopes)) != 2:
+                raise CommandError(
+                    "To do a single-user sync, one device must have a single-user certificate, and the other a full-facility certificate."
+                )
+
+        elif PORTAL_SYNC:  # do portal sync setup
             facility = get_facility(
                 facility_id=facility_id, noninteractive=noninteractive
             )
@@ -181,16 +220,34 @@ def handle_async(self, *args, **options):  # noqa C901
         try:
             # pull from server
             if not no_pull:
-                self._handle_pull(sync_session_client, noninteractive, dataset_id)
+                self._handle_pull(
+                    sync_session_client,
+                    noninteractive,
+                    dataset_id,
+                    client_cert,
+                    server_cert,
+                    user_id=user_id,
+                )
             # and push our own data to server
             if not no_push:
-                self._handle_push(sync_session_client, noninteractive, dataset_id)
+                self._handle_push(
+                    sync_session_client,
+                    noninteractive,
+                    dataset_id,
+                    client_cert,
+                    server_cert,
+                    user_id=user_id,
+                )
 
             if not no_provision:
                 with self._lock():
-                    create_superuser_and_provision_device(
-                        username, dataset_id, noninteractive=noninteractive
-                    )
+                    if user_id:
+                        provision_single_user_device(user_id)
+                    else:
+                        create_superuser_and_provision_device(
+                            username, dataset_id, noninteractive=noninteractive
+                        )
+
         except UserCancelledError:
             if self.job:
                 self.job.extra_metadata.update(sync_state=State.CANCELLED)
@@ -224,7 +281,15 @@ def _raise_cancel(self, *args, **kwargs):
         if self.is_cancelled() and (not self.job or self.job.cancellable):
             raise UserCancelledError()
 
-    def _handle_pull(self, sync_session_client, noninteractive, dataset_id):
+    def _handle_pull(
+        self,
+        sync_session_client,
+        noninteractive,
+        dataset_id,
+        client_cert,
+        server_cert,
+        user_id,
+    ):
         """
         :type sync_session_client: morango.sync.syncsession.SyncSessionClient
         :type noninteractive: bool
@@ -259,12 +324,32 @@ def _handle_pull(self, sync_session_client, noninteractive, dataset_id):
             "Completed pull transfer session",
         )
 
-        sync_client.initialize(Filter(dataset_id))
+        if not user_id:
+            # full-facility sync
+            sync_client.initialize(Filter(dataset_id))
+        else:
+            # single-user sync
+            client_is_single_user = (
+                client_cert.scope_definition_id == ScopeDefinitions.SINGLE_USER
+            )
+            filt = get_single_user_sync_filter(
+                dataset_id, user_id, is_read=client_is_single_user
+            )
+            sync_client.initialize(Filter(filt))
+
         sync_client.run()
         with self._lock():
             sync_client.finalize()
 
-    def _handle_push(self, sync_session_client, noninteractive, dataset_id):
+    def _handle_push(
+        self,
+        sync_session_client,
+        noninteractive,
+        dataset_id,
+        client_cert,
+        server_cert,
+        user_id,
+    ):
         """
         :type sync_session_client: morango.sync.syncsession.SyncSessionClient
         :type noninteractive: bool
@@ -299,7 +384,18 @@ def _handle_push(self, sync_session_client, noninteractive, dataset_id):
         )
 
         with self._lock():
-            sync_client.initialize(Filter(dataset_id))
+            if not user_id:
+                # full-facility sync
+                sync_client.initialize(Filter(dataset_id))
+            else:
+                # single-user sync
+                client_is_single_user = (
+                    client_cert.scope_definition_id == ScopeDefinitions.SINGLE_USER
+                )
+                filt = get_single_user_sync_filter(
+                    dataset_id, user_id, is_read=not client_is_single_user
+                )
+                sync_client.initialize(Filter(filt))
 
         sync_client.run()
 
diff --git a/kolibri/core/auth/management/utils.py b/kolibri/core/auth/management/utils.py
--- a/kolibri/core/auth/management/utils.py
+++ b/kolibri/core/auth/management/utils.py
@@ -12,6 +12,7 @@
 from django.urls import reverse
 from django.utils.six.moves import input
 from morango.models import Certificate
+from morango.models import ScopeDefinition
 from six.moves.urllib.parse import urljoin
 
 from kolibri.core.auth.constants.morango_sync import ScopeDefinitions
@@ -159,21 +160,10 @@ def get_baseurl(baseurl):
 
 
 def get_client_and_server_certs(
-    username, password, dataset_id, nc, noninteractive=False
+    username, password, dataset_id, nc, user_id=None, noninteractive=False
 ):
-    # get servers certificates which server has a private key for
-    server_certs = nc.get_remote_certificates(
-        dataset_id, scope_def_id=ScopeDefinitions.FULL_FACILITY
-    )
-    if not server_certs:
-        raise CommandError(
-            "Server does not have any certificates for dataset_id: {}".format(
-                dataset_id
-            )
-        )
-    server_cert = server_certs[0]
 
-    # check for the certs we own for the specific facility
+    # get any full-facility certificates we have for the facility
     owned_certs = (
         Certificate.objects.filter(id=dataset_id)
         .get_descendants(include_self=True)
@@ -181,6 +171,50 @@ def get_client_and_server_certs(
         .exclude(_private_key=None)
     )
 
+    if not user_id:  # it's a full-facility sync
+
+        csr_scope_params = {"dataset_id": dataset_id}
+
+        client_scope = ScopeDefinitions.FULL_FACILITY
+        server_scope = ScopeDefinitions.FULL_FACILITY
+
+    else:  # it's a single-user sync
+
+        csr_scope_params = {"dataset_id": dataset_id, "user_id": user_id}
+
+        if owned_certs:
+            # client is the one with a full-facility cert
+            client_scope = ScopeDefinitions.FULL_FACILITY
+            server_scope = ScopeDefinitions.SINGLE_USER
+        else:
+            # server must be the one with the full-facility cert
+            client_scope = ScopeDefinitions.SINGLE_USER
+            server_scope = ScopeDefinitions.FULL_FACILITY
+
+            # check for certs we own for the specific user_id for single-user syncing
+            owned_certs = (
+                Certificate.objects.filter(id=dataset_id)
+                .get_descendants(include_self=True)
+                .filter(scope_definition_id=ScopeDefinitions.SINGLE_USER)
+                .filter(scope_params__contains=user_id)
+                .exclude(_private_key=None)
+            )
+
+    # get server certificates that server has a private key for
+    server_certs = nc.get_remote_certificates(dataset_id, scope_def_id=server_scope)
+
+    # filter down to the single-user certificates for this specific user, if needed
+    if server_scope == ScopeDefinitions.SINGLE_USER:
+        server_certs = [cert for cert in server_certs if user_id in cert.scope_params]
+
+    if not server_certs:
+        raise CommandError(
+            "Server does not have needed certificate with scope '{}'".format(
+                server_scope
+            )
+        )
+    server_cert = server_certs[0]
+
     # if we don't own any certs, do a csr request
     if not owned_certs:
 
@@ -194,8 +228,8 @@ def get_client_and_server_certs(
 
         client_cert = nc.certificate_signing_request(
             server_cert,
-            ScopeDefinitions.FULL_FACILITY,
-            {"dataset_id": dataset_id},
+            client_scope,
+            csr_scope_params,
             userargs=username,
             password=password,
         )
@@ -249,6 +283,28 @@ def create_superuser_and_provision_device(username, dataset_id, noninteractive=F
         )
 
 
+def provision_single_user_device(user_id):
+
+    user = FacilityUser.objects.get(id=user_id)
+
+    # if device has not been provisioned, set it up
+    if not device_provisioned():
+        provision_device(default_facility=user.facility)
+
+    DevicePermissions.objects.get_or_create(
+        user=user, defaults={"is_superuser": False, "can_manage_content": True}
+    )
+
+
+def get_single_user_sync_filter(dataset_id, user_id, is_read):
+    scopedef = ScopeDefinition.objects.get(id=ScopeDefinitions.SINGLE_USER)
+    scope = scopedef.get_scope({"dataset_id": dataset_id, "user_id": user_id})
+    if is_read:
+        return str(scope.read_filter)
+    else:
+        return str(scope.write_filter)
+
+
 def run_once(f):
     """
     Runs a function once, useful for connection once to a signal


Test patch:
diff --git a/kolibri/core/auth/test/sync_utils.py b/kolibri/core/auth/test/sync_utils.py
--- a/kolibri/core/auth/test/sync_utils.py
+++ b/kolibri/core/auth/test/sync_utils.py
@@ -1,6 +1,5 @@
 from __future__ import with_statement
 
-import glob
 import os
 import shutil
 import socket
@@ -25,13 +24,9 @@ def get_free_tcp_port():
 
 
 class KolibriServer(object):
-
-    _pre_migrated_db_dir = None
-
     def __init__(
         self,
         autostart=True,
-        pre_migrate=True,
         settings="kolibri.deployment.default.settings.base",
         db_name="default",
     ):
@@ -39,22 +34,54 @@ def __init__(
         self.env["KOLIBRI_HOME"] = tempfile.mkdtemp()
         self.env["DJANGO_SETTINGS_MODULE"] = settings
         self.env["POSTGRES_DB"] = db_name
+        self.env["KOLIBRI_ZIP_CONTENT_PORT"] = str(get_free_tcp_port())
         self.db_path = os.path.join(self.env["KOLIBRI_HOME"], "db.sqlite3")
         self.db_alias = uuid.uuid4().hex
         self.port = get_free_tcp_port()
         self.baseurl = "http://127.0.0.1:{}/".format(self.port)
         if autostart:
-            self.start(pre_migrate=pre_migrate)
+            self.start()
 
-    def start(self, pre_migrate=True):
-        if pre_migrate:
-            KolibriServer.get_pre_migrated_database(self.env["KOLIBRI_HOME"])
+    def start(self):
         self._instance = subprocess.Popen(
-            ["kolibri start", "--port {}".format(self.port), "--foreground"],
+            ["kolibri", "start", "--port", str(self.port), "--foreground"],
             env=self.env,
         )
         self._wait_for_server_start()
 
+    def manage(self, *args):
+        subprocess.call(
+            ["kolibri", "manage"] + list(args),
+            env=self.env,
+        )
+
+    def create_model(self, model, **kwargs):
+        kwarg_text = ",".join(
+            '{key}=\\"{value}\\"'.format(key=key, value=value)
+            for key, value in kwargs.items()
+        )
+        self.pipe_shell(
+            "from {module_path} import {model_name}; {model_name}.objects.create({})".format(
+                kwarg_text, module_path=model.__module__, model_name=model.__name__
+            )
+        )
+
+    def delete_model(self, model, **kwargs):
+        kwarg_text = ",".join(
+            '{key}=\\"{value}\\"'.format(key=key, value=value)
+            for key, value in kwargs.items()
+        )
+        self.pipe_shell(
+            "from {module_path} import {model_name}; obj = {model_name}.objects.get({}); obj.delete()".format(
+                kwarg_text, module_path=model.__module__, model_name=model.__name__
+            )
+        )
+
+    def pipe_shell(self, text):
+        subprocess.call(
+            'echo "{}" | kolibri shell'.format(text), env=self.env, shell=True
+        )
+
     def _wait_for_server_start(self, timeout=20):
         for i in range(timeout * 2):
             try:
@@ -69,25 +96,12 @@ def _wait_for_server_start(self, timeout=20):
 
     def kill(self):
         try:
+            subprocess.call("kolibri stop", env=self.env, shell=True)
             self._instance.kill()
             shutil.rmtree(self.env["KOLIBRI_HOME"])
         except OSError:
             pass
 
-    @classmethod
-    def get_pre_migrated_database(cls, home_path):
-        if not cls._pre_migrated_db_dir:
-            # if no pre-migrated db files, then create them
-            server = cls(autostart=False)
-            subprocess.run(["kolibri manage migrate"], env=server.env)
-            # put pre-migrated files in temp directory
-            cls._pre_migrated_db_dir = tempfile.mkdtemp()
-            for f in glob.glob(os.path.join(server.env["KOLIBRI_HOME"], "db.sqlite3*")):
-                shutil.copy(f, cls._pre_migrated_db_dir)
-        # if pre-migrated db files, then copy those files to this servers directory
-        for f in glob.glob(os.path.join(cls._pre_migrated_db_dir, "db.sqlite3*")):
-            shutil.copy(f, home_path)
-
 
 class multiple_kolibri_servers(object):
     def __init__(self, count=2):
@@ -115,7 +129,6 @@ def __enter__(self):
             if self.server_count == 3:
                 self.servers = [
                     KolibriServer(
-                        pre_migrate=False,
                         settings="kolibri.deployment.default.settings.postgres_test",
                         db_name="eco_test" + str(i + 1),
                     )
@@ -125,7 +138,6 @@ def __enter__(self):
             if self.server_count == 5:
                 self.servers = [
                     KolibriServer(
-                        pre_migrate=False,
                         settings="kolibri.deployment.default.settings.postgres_test",
                         db_name="eco2_test" + str(i + 1),
                     )
diff --git a/kolibri/core/auth/test/test_morango_integration.py b/kolibri/core/auth/test/test_morango_integration.py
--- a/kolibri/core/auth/test/test_morango_integration.py
+++ b/kolibri/core/auth/test/test_morango_integration.py
@@ -2,18 +2,15 @@
 Tests related specifically to integration with Morango.
 """
 import os
-import sys
 import unittest
 import uuid
 
-import requests
 from django.test import TestCase
+from django.utils import timezone
 from morango.models import InstanceIDModel
 from morango.models import Store
 from morango.models import syncable_models
 from morango.sync.controller import MorangoProfileController
-from rest_framework import status
-from six.moves.urllib.parse import urljoin
 
 from ..constants.morango_sync import PROFILE_FACILITY_DATA
 from ..models import Classroom
@@ -21,9 +18,12 @@
 from ..models import FacilityDataset
 from ..models import FacilityUser
 from ..models import LearnerGroup
+from ..models import Membership
 from ..models import Role
 from .helpers import DUMMY_PASSWORD
 from .sync_utils import multiple_kolibri_servers
+from kolibri.core.logger.models import ContentSessionLog
+from kolibri.core.logger.models import ContentSummaryLog
 
 
 class FacilityDatasetCertificateTestCase(TestCase):
@@ -62,13 +62,17 @@ def test_deserializing_field(self):
             self.fail(e.message)
 
 
-@unittest.skipIf(sys.platform.startswith("win"), "can't run on Windows")
 @unittest.skipIf(
-    not os.environ.get("TRAVIS_TAG"), "This test will only be run during tagged builds."
+    not os.environ.get("INTEGRATION_TEST"),
+    "This test will only be run during integration testing.",
 )
 class EcosystemTestCase(TestCase):
-    def _data(self, *args, **kwargs):
-        return kwargs
+    """
+    Where possible this test case uses the using kwarg with the db alias in order
+    to save models to the write DB. Unfortunately, because of an internal issue with
+    MPTT, this will sometimes fail for MPTT models.
+    TODO: defer this to the server class as an implementation detail.
+    """
 
     def _create_objects(self, server):
         fac = Facility.objects.using(server.db_alias).first()
@@ -80,52 +84,16 @@ def _create_objects(self, server):
             username=uuid.uuid4().hex[:30], password=DUMMY_PASSWORD, facility=fac
         )
         learner.save(using=server.db_alias)
-        class_resp = self.request_server(
-            server, "classroom", data=self._data(parent=fac.id, name=uuid.uuid4().hex)
-        )
-        lg_resp = self.request_server(
-            server,
-            "learnergroup",
-            data=self._data(parent=class_resp.json()["id"], name=uuid.uuid4().hex),
-        )
-        self.request_server(
-            server,
-            "membership",
-            data=self._data(user=learner.id, collection=class_resp.json()["id"]),
-        )
-        self.request_server(
-            server,
-            "membership",
-            data=self._data(user=learner.id, collection=lg_resp.json()["id"]),
-        )
-        self.request_server(
-            server,
-            "role",
-            data=self._data(collection=fac.id, user=admin.id, kind="admin"),
-        )
-
-    def request_server(
-        self, server, endpoint, method="POST", lookup=None, data={}, params={}
-    ):
-        """
-
-        :param server: kolibri instance we are querying
-        :param endpoint: constant representing which kolibri endpoint we are querying
-        :param method: HTTP verb/method for request
-        :param lookup: the pk value for the specific object we are querying
-        :param data: dict that will be form-encoded in request
-        :param params: dict to be sent as part of URL's query string
-        :return: ``Response`` object from request
-        """
-
-        # build up url and send request
-        if lookup:
-            lookup = lookup + "/"
-        url = urljoin(urljoin(server.baseurl, "api/auth/" + endpoint + "/"), lookup)
-        auth = ("superuser", "password")
-        resp = requests.request(method, url, json=data, params=params, auth=auth)
-        resp.raise_for_status()
-        return resp
+
+        name = uuid.uuid4().hex
+        server.create_model(Classroom, parent_id=fac.id, name=name)
+        class_id = Classroom.objects.using(server.db_alias).get(name=name).id
+        name = uuid.uuid4().hex
+        server.create_model(LearnerGroup, parent_id=class_id, name=name)
+        lg_id = LearnerGroup.objects.using(server.db_alias).get(name=name).id
+        server.create_model(Membership, user_id=learner.id, collection_id=class_id)
+        server.create_model(Membership, user_id=learner.id, collection_id=lg_id)
+        server.create_model(Role, collection_id=fac.id, user_id=admin.id, kind="admin")
 
     def assertServerQuerysetEqual(self, s1, s2, dataset_id):
         models = syncable_models.get_models(PROFILE_FACILITY_DATA)
@@ -166,12 +134,26 @@ def test_scenarios(self, servers):
         s2_alias = servers[2].db_alias
         s2_url = servers[2].baseurl
         servers[0].manage("loaddata", "content_test")
-        servers[0].manage("generateuserdata", no_onboarding=True, num_content_items=1)
+        servers[0].manage(
+            "generateuserdata", "--no-onboarding", "--num-content-items", "1"
+        )
         servers[1].manage(
-            "sync", baseurl=s0_url, username="superuser", password="password"
+            "sync",
+            "--baseurl",
+            s0_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
         )
         servers[2].manage(
-            "sync", baseurl=s1_url, username="superuser", password="password"
+            "sync",
+            "--baseurl",
+            s1_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
         )
 
         # assert that all kolibri instances start off equal
@@ -189,7 +171,13 @@ def test_scenarios(self, servers):
             facility=Facility.objects.using(s0_alias).first(),
         ).save(using=s0_alias)
         servers[1].manage(
-            "sync", baseurl=s0_url, username="superuser", password="password"
+            "sync",
+            "--baseurl",
+            s0_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
         )
         self.assertTrue(
             FacilityUser.objects.using(s1_alias).filter(username="user").exists()
@@ -198,15 +186,19 @@ def test_scenarios(self, servers):
         # missing foreign key lookup should be handled gracefully (https://github.com/learningequality/kolibri/pull/5734)
         user = FacilityUser.objects.using(s1_alias).get(username="user")
         fac = Facility.objects.using(s1_alias).get()
-        self.request_server(
-            servers[1],
-            "role",
-            data=self._data(collection=fac.id, user=user.id, kind="admin"),
+        servers[1].create_model(
+            Role, collection_id=fac.id, user_id=user.id, kind="admin"
         )
-        self.request_server(servers[0], "facilityuser", method="DELETE", lookup=user.id)
+        servers[0].delete_model(FacilityUser, id=user.id)
         # role object that is synced will try to do FK lookup on deleted user
         servers[0].manage(
-            "sync", baseurl=s1_url, username="superuser", password="password"
+            "sync",
+            "--baseurl",
+            s1_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
         )
 
         # create user with same username on two servers and check they both exist
@@ -221,7 +213,13 @@ def test_scenarios(self, servers):
             facility=Facility.objects.using(s1_alias).first(),
         ).save(using=s1_alias)
         servers[1].manage(
-            "sync", baseurl=s0_url, username="superuser", password="password"
+            "sync",
+            "--baseurl",
+            s0_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
         )
         self.assertEqual(
             FacilityUser.objects.using(s0_alias).filter(username="copycat").count(), 2
@@ -231,30 +229,38 @@ def test_scenarios(self, servers):
         )
 
         # Add a classroom
-        self.request_server(
-            servers[0],
-            "classroom",
-            data=self._data(
-                name="classroom", parent=Facility.objects.using(s0_alias).first().id
-            ),
+        servers[0].create_model(
+            Classroom,
+            name="classroom",
+            parent_id=Facility.objects.using(s0_alias).first().id,
         )
         servers[1].manage(
-            "sync", baseurl=s0_url, username="superuser", password="password"
+            "sync",
+            "--baseurl",
+            s0_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
         )
         self.assertTrue(
             Classroom.objects.using(s1_alias).filter(name="classroom").exists()
         )
 
         # Add a learnergroup
-        self.request_server(
-            servers[0],
-            "learnergroup",
-            data=self._data(
-                name="learnergroup", parent=Classroom.objects.using(s0_alias).first().id
-            ),
+        servers[0].create_model(
+            LearnerGroup,
+            name="learnergroup",
+            parent_id=Classroom.objects.using(s0_alias).first().id,
         )
         servers[1].manage(
-            "sync", baseurl=s0_url, username="superuser", password="password"
+            "sync",
+            "--baseurl",
+            s0_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
         )
         self.assertTrue(
             LearnerGroup.objects.using(s1_alias).filter(name="learnergroup").exists()
@@ -263,29 +269,35 @@ def test_scenarios(self, servers):
         # assert conflicting serialized data is appended after same role is created on different device
         fac = Facility.objects.using(s1_alias).get()
         alk_user = FacilityUser.objects.using(s0_alias).get(username="Antemblowind")
-        self.request_server(
-            servers[1],
-            "role",
-            data=self._data(collection=fac.id, user=alk_user.id, kind="admin"),
+        servers[1].create_model(
+            Role, collection_id=fac.id, user_id=alk_user.id, kind="admin"
         )
-        self.request_server(
-            servers[0],
-            "role",
-            data=self._data(collection=fac.id, user=alk_user.id, kind="admin"),
+        servers[0].create_model(
+            Role, collection_id=fac.id, user_id=alk_user.id, kind="admin"
         )
         servers[1].manage(
-            "sync", baseurl=s0_url, username="superuser", password="password"
+            "sync",
+            "--baseurl",
+            s0_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
         )
         role = Role.objects.using(s1_alias).get(user=alk_user)
         admin_role = Store.objects.using(s1_alias).get(id=role.id)
         self.assertTrue(admin_role.conflicting_serialized_data)
 
         # assert deleted object is propagated
-        self.request_server(
-            servers[0], "facilityuser", method="DELETE", lookup=alk_user.id
-        )
+        servers[0].delete_model(FacilityUser, id=alk_user.id)
         servers[1].manage(
-            "sync", baseurl=s0_url, username="superuser", password="password"
+            "sync",
+            "--baseurl",
+            s0_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
         )
         self.assertFalse(
             FacilityUser.objects.using(s1_alias)
@@ -297,13 +309,22 @@ def test_scenarios(self, servers):
         # # role deletion and re-creation
         # Change roles for users
         alto_user = FacilityUser.objects.using(s1_alias).get(username="Altobjews1977")
-        resp = self.request_server(
-            servers[1],
-            "role",
-            data=self._data(collection=fac.id, user=alto_user.id, kind="admin"),
+        servers[1].create_model(
+            Role, collection_id=fac.id, user_id=alto_user.id, kind="admin"
+        )
+        role_id = (
+            Role.objects.using(s1_alias)
+            .get(collection_id=fac.id, user_id=alto_user.id)
+            .id
         )
         servers[1].manage(
-            "sync", baseurl=s2_url, username="superuser", password="password"
+            "sync",
+            "--baseurl",
+            s2_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
         )
 
         self.assertEqual(
@@ -315,54 +336,62 @@ def test_scenarios(self, servers):
             "admin",
         )
         # delete admin role and sync
-        self.request_server(
-            servers[2], "role", method="DELETE", lookup=resp.json()["id"]
-        )
+        servers[2].delete_model(Role, id=role_id)
         servers[1].manage(
-            "sync", baseurl=s2_url, username="superuser", password="password"
+            "sync",
+            "--baseurl",
+            s2_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
         )
         # create admin role and sync
-        resp = self.request_server(
-            servers[1],
-            "role",
-            data=self._data(collection=fac.id, user=alto_user.id, kind="admin"),
+        servers[1].create_model(
+            Role, collection_id=fac.id, user_id=alto_user.id, kind="admin"
+        )
+        role_id = (
+            Role.objects.using(s1_alias).get(collection=fac.id, user=alto_user.id).id
         )
         servers[1].manage(
-            "sync", baseurl=s2_url, username="superuser", password="password"
+            "sync",
+            "--baseurl",
+            s2_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
         )
 
-        self.assertFalse(
-            Store.objects.using(s2_alias).get(id=resp.json()["id"]).deleted
-        )
+        self.assertFalse(Store.objects.using(s2_alias).get(id=role_id).deleted)
 
-        # Change password for a user, check if you can log in on other device
-        self.request_server(
-            servers[1],
-            "facilityuser",
-            method="PATCH",
-            lookup=alto_user.id,
-            data=self._data(password="syncing"),
-        )
+        # Change password for a user, check is changed on other device
+        server1_fu = FacilityUser.objects.using(s1_alias).get(id=alto_user.id)
+        server1_fu.set_password("syncing")
+        server1_fu.save(using=s1_alias)
         servers[1].manage(
-            "sync", baseurl=s0_url, username="superuser", password="password"
-        )
-        resp = self.request_server(
-            servers[0],
-            "session",
-            data=self._data(
-                username=alto_user.username, password="syncing", facility=fac.id
-            ),
+            "sync",
+            "--baseurl",
+            s0_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
         )
-        self.assertEqual(resp.status_code, status.HTTP_200_OK)
+        server0_fu = FacilityUser.objects.using(s0_alias).get(id=alto_user.id)
+        self.assertTrue(server0_fu.check_password("syncing"))
 
         # sync in a circle node twice to ensure full consistency
         for i in range(2):
             for j in range(servers_len):
                 servers[j].manage(
                     "sync",
-                    baseurl=servers[(j + 1) % servers_len].baseurl,
-                    username="superuser",
-                    password="password",
+                    "--baseurl",
+                    servers[(j + 1) % servers_len].baseurl,
+                    "--username",
+                    "superuser",
+                    "--password",
+                    "password",
                 )
 
         # assert that the data of specific models match up
@@ -378,13 +407,16 @@ def test_chaos_sync(self, servers):
         servers_len = len(servers)
 
         # consistent state for all servers
-        servers[0].manage("generateuserdata", no_onboarding=True)
+        servers[0].manage("generateuserdata", "--no-onboarding")
         for i in range(servers_len - 1):
             servers[i + 1].manage(
                 "sync",
-                baseurl=servers[0].baseurl,
-                username="superuser",
-                password="password",
+                "--baseurl",
+                servers[0].baseurl,
+                "--username",
+                "superuser",
+                "--password",
+                "password",
             )
 
         # randomly create objects on two servers and sync with each other
@@ -396,9 +428,12 @@ def test_chaos_sync(self, servers):
 
             servers[2].manage(
                 "sync",
-                baseurl=servers[4].baseurl,
-                username="superuser",
-                password="password",
+                "--baseurl",
+                servers[4].baseurl,
+                "--username",
+                "superuser",
+                "--password",
+                "password",
             )
 
         # sync in a circle node twice to ensure full consistency
@@ -406,9 +441,12 @@ def test_chaos_sync(self, servers):
             for j in range(servers_len):
                 servers[j].manage(
                     "sync",
-                    baseurl=servers[(j + 1) % servers_len].baseurl,
-                    username="superuser",
-                    password="password",
+                    "--baseurl",
+                    servers[(j + 1) % servers_len].baseurl,
+                    "--username",
+                    "superuser",
+                    "--password",
+                    "password",
                 )
 
         # assert that the data of specific models match up
@@ -418,3 +456,141 @@ def test_chaos_sync(self, servers):
                 servers[(i + 1) % servers_len],
                 FacilityDataset.objects.using(servers[0].db_alias).first().id,
             )
+
+    @multiple_kolibri_servers(3)
+    def test_single_user_sync(self, servers):
+        self.maxDiff = None
+        s0_alias = servers[0].db_alias
+        s0_url = servers[0].baseurl
+        s1_alias = servers[1].db_alias
+        s2_alias = servers[2].db_alias
+        servers[0].manage("loaddata", "content_test")
+        servers[0].manage(
+            "generateuserdata", "--no-onboarding", "--num-content-items", "1"
+        )
+
+        facility_id = Facility.objects.using(s0_alias).get().id
+
+        learner1 = FacilityUser.objects.using(s0_alias).filter(roles__isnull=True)[0]
+
+        learner2 = FacilityUser.objects.using(s0_alias).filter(roles__isnull=True)[1]
+
+        learner2.set_password("syncing")
+        learner2.save(using=s0_alias)
+
+        # Test that we can single user sync with admin creds
+        servers[1].manage(
+            "sync",
+            "--baseurl",
+            s0_url,
+            "--username",
+            "superuser",
+            "--password",
+            "password",
+            "--facility",
+            facility_id,
+            "--user",
+            learner1.id,
+        )
+        # Test that we can single user sync with learner creds
+        servers[2].manage(
+            "sync",
+            "--baseurl",
+            s0_url,
+            "--username",
+            learner2.username,
+            "--password",
+            "syncing",
+            "--facility",
+            facility_id,
+            "--user",
+            learner2.id,
+        )
+
+        # Check that learner 1 is on server 1
+        self.assertTrue(
+            FacilityUser.objects.using(s1_alias).filter(id=learner1.id).exists()
+        )
+        # Check that learner 2 is not on server 1
+        self.assertFalse(
+            FacilityUser.objects.using(s1_alias).filter(id=learner2.id).exists()
+        )
+
+        # Check that learner 2 is on server 2
+        self.assertTrue(
+            FacilityUser.objects.using(s2_alias).filter(id=learner2.id).exists()
+        )
+        # Check that learner 1 is not on server 2
+        self.assertFalse(
+            FacilityUser.objects.using(s2_alias).filter(id=learner1.id).exists()
+        )
+
+        channel_id = "725257a0570044acbd59f8cf6a68b2be"
+        content_id = "9f9438fe6b0d42dd8e913d7d04cfb2b2"
+
+        servers[1].create_model(
+            ContentSessionLog,
+            channel_id=channel_id,
+            content_id=content_id,
+            user_id=learner1.id,
+            start_timestamp=timezone.now(),
+            kind="audio",
+        )
+        servers[1].create_model(
+            ContentSummaryLog,
+            channel_id=channel_id,
+            content_id=content_id,
+            user_id=learner1.id,
+            start_timestamp=timezone.now(),
+            kind="audio",
+        )
+
+        servers[2].create_model(
+            ContentSessionLog,
+            channel_id=channel_id,
+            content_id=content_id,
+            user_id=learner2.id,
+            start_timestamp=timezone.now(),
+            kind="audio",
+        )
+        servers[2].create_model(
+            ContentSummaryLog,
+            channel_id=channel_id,
+            content_id=content_id,
+            user_id=learner2.id,
+            start_timestamp=timezone.now(),
+            kind="audio",
+        )
+
+        servers[1].manage(
+            "sync",
+            "--baseurl",
+            s0_url,
+            "--facility",
+            facility_id,
+            "--user",
+            learner1.id,
+        )
+        # Test that we can single user sync with learner creds
+        servers[2].manage(
+            "sync",
+            "--baseurl",
+            s0_url,
+            "--facility",
+            facility_id,
+            "--user",
+            learner2.id,
+        )
+
+        self.assertEqual(
+            ContentSessionLog.objects.using(s0_alias)
+            .filter(channel_id=channel_id, content_id=content_id)
+            .count(),
+            2,
+        )
+        self.assertEqual(
+            ContentSummaryLog.objects.using(s0_alias)
+            .filter(channel_id=channel_id, content_id=content_id)
+            .count(),
+            2,
+        )