Repository: learningequality/kolibri

Issue:
Single user syncing - Lesson completion is not restored after second setup of the same learner account
## Observed behavior
I have setup a learner-only device and have completed a lesson assigned to the learner user which is then synced correctly to the server. After that I've deleted the android app storage and cache and repeated the setup for the same user expecting the lesson progress to be restored while in reality it does not get restored and the learner has to start over.

## Expected behavior
Any synced user data should be restored.

## Steps to reproduce the issue
1. Install this Windows [build](https://buildkite.com/learningequality/kolibri-windows/builds/1930) and this Android [build](https://buildkite.com/learningequality/kolibri-android-installer/builds/2692)
2. Setup the Windows app as a server and setup a learner device on Android.
3. As a learner complete an assigned lesson.
4. As an admin go to Coach>Reports>Lessons and verify that the lesson progress is synced correctly.
5. Delete the cache and storage of the android app and setup the same learner user again. Observe that there is no indication for the progress made so far.

## Additional information
Admin:
![2021-09-16_12-39-50](https://user-images.githubusercontent.com/79847249/133593574-4c5124df-d5d8-421c-8d79-9e36c04c8fe0.png)

Learner with completed lesson:
![2021-09-16_12-38-33](https://user-images.githubusercontent.com/79847249/133593661-6cf00ade-1a61-45f6-93d7-cea75eda7203.png)

Restored learner device without any indication of the progress made:
![2021-09-16_12-49-17](https://user-images.githubusercontent.com/79847249/133593883-a6232fdc-10af-46c0-a1ed-d4b566ee94b8.png)


## Windows Logs
[logs.zip](https://github.com/learningequality/kolibri/files/7176855/logs.zip)

## Android and Ubuntu Logs and DB files
https://drive.google.com/file/d/1RAZG77NHuX92puj-KxA-GtkSpx3JxjyZ/view?usp=sharing

## Usage Details
 - OS: Windows 10, Android 11
 - Browser: Chrome
Single user syncing - Lesson completion is not restored after second setup of the same learner account
## Observed behavior
I have setup a learner-only device and have completed a lesson assigned to the learner user which is then synced correctly to the server. After that I've deleted the android app storage and cache and repeated the setup for the same user expecting the lesson progress to be restored while in reality it does not get restored and the learner has to start over.

## Expected behavior
Any synced user data should be restored.

## Steps to reproduce the issue
1. Install this Windows [build](https://buildkite.com/learningequality/kolibri-windows/builds/1930) and this Android [build](https://buildkite.com/learningequality/kolibri-android-installer/builds/2692)
2. Setup the Windows app as a server and setup a learner device on Android.
3. As a learner complete an assigned lesson.
4. As an admin go to Coach>Reports>Lessons and verify that the lesson progress is synced correctly.
5. Delete the cache and storage of the android app and setup the same learner user again. Observe that there is no indication for the progress made so far.

## Additional information
Admin:
![2021-09-16_12-39-50](https://user-images.githubusercontent.com/79847249/133593574-4c5124df-d5d8-421c-8d79-9e36c04c8fe0.png)

Learner with completed lesson:
![2021-09-16_12-38-33](https://user-images.githubusercontent.com/79847249/133593661-6cf00ade-1a61-45f6-93d7-cea75eda7203.png)

Restored learner device without any indication of the progress made:
![2021-09-16_12-49-17](https://user-images.githubusercontent.com/79847249/133593883-a6232fdc-10af-46c0-a1ed-d4b566ee94b8.png)


## Windows Logs
[logs.zip](https://github.com/learningequality/kolibri/files/7176855/logs.zip)

## Android and Ubuntu Logs and DB files
https://drive.google.com/file/d/1RAZG77NHuX92puj-KxA-GtkSpx3JxjyZ/view?usp=sharing

## Usage Details
 - OS: Windows 10, Android 11
 - Browser: Chrome


Discussion:
Update: After revisiting the same case an hour later it appears to be functioning correctly and the lesson progress is displayed correctly. @rtibbles is there a time interval for fully restoring the progress made on the learner's device?
Just mentioning that I currently have reproduced the initially reported issue and the learner user's lesson progress is not synced correctly after several hours during which I even changed that user's password while the user was signed out and this change was synced correctly. So no idea what's going on here.
@pcenov Thanks -- would it be possible to share the database file (db.sqlite3) from both sides of the sync, to help with diagnosing?
Sure, @jamalex - I've now added a link to the Ubuntu and Android logs and DB files in the description.
This issue has uncovered a really important edge case in how Morango handles "filter specific instance counters" for cases (like single-user syncing) with multiple filters being applied at once. @pcenov It's fantastic that you discovered this before we rolled out 0.15 any more widely!

I have a fix in progress that I think should address this, and will hopefully be able to push it (to Morango, at least) by tomorrow.
Update: After revisiting the same case an hour later it appears to be functioning correctly and the lesson progress is displayed correctly. @rtibbles is there a time interval for fully restoring the progress made on the learner's device?
Just mentioning that I currently have reproduced the initially reported issue and the learner user's lesson progress is not synced correctly after several hours during which I even changed that user's password while the user was signed out and this change was synced correctly. So no idea what's going on here.
@pcenov Thanks -- would it be possible to share the database file (db.sqlite3) from both sides of the sync, to help with diagnosing?
Sure, @jamalex - I've now added a link to the Ubuntu and Android logs and DB files in the description.
This issue has uncovered a really important edge case in how Morango handles "filter specific instance counters" for cases (like single-user syncing) with multiple filters being applied at once. @pcenov It's fantastic that you discovered this before we rolled out 0.15 any more widely!

I have a fix in progress that I think should address this, and will hopefully be able to push it (to Morango, at least) by tomorrow.

Resolving patch:
diff --git a/kolibri/core/exams/kolibri_plugin.py b/kolibri/core/exams/kolibri_plugin.py
--- a/kolibri/core/exams/kolibri_plugin.py
+++ b/kolibri/core/exams/kolibri_plugin.py
@@ -1,5 +1,5 @@
 # To reinstate the original functionality, please remove this header comment
-# and uncomment the code below
+# and uncomment the code below (and re-enable exams in test_single_user_assignment_sync, search for "TODO")
 # from .single_user_assignment_utils import (
 #     update_assignments_from_individual_syncable_exams,
 # )


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
@@ -16,6 +16,9 @@
 from morango.models.core import DatabaseIDModel
 from requests.exceptions import RequestException
 
+from kolibri.core.auth.models import Facility
+from kolibri.core.auth.models import FacilityUser
+
 
 def get_free_tcp_port():
     tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -90,6 +93,14 @@ def delete_model(self, model, **kwargs):
             )
         )
 
+    def change_password(self, user, password):
+        self.pipe_shell(
+            'from kolibri.core.auth.models import *; user = FacilityUser.objects.get(id="{user}"); user.set_password("{password}"); user.save()'.format(
+                user=user.id if isinstance(user, FacilityUser) else user,
+                password=password,
+            )
+        )
+
     def pipe_shell(self, text):
         subprocess.call(
             "echo '{}' | kolibri shell".format(text), env=self.env, shell=True
@@ -115,6 +126,54 @@ def kill(self):
         except OSError:
             pass
 
+    def sync(
+        self, server, facility, user=None, username="superuser", password="password"
+    ):
+        """
+        Perform a sync from this device to another server. If `user` is provided, perform
+        a single-user sync. If credentials are needed, include `username` and `password`.
+        """
+
+        extra_args = []
+
+        if user:
+            extra_args += [
+                "--user",
+                user.id if isinstance(user, FacilityUser) else user,
+            ]
+
+        if username and password:
+            extra_args += [
+                "--username",
+                username,
+                "--password",
+                password,
+            ]
+
+        self.manage(
+            "sync",
+            "--baseurl",
+            server.baseurl,
+            "--facility",
+            facility.id if isinstance(facility, Facility) else facility,
+            *extra_args
+        )
+
+    def generate_base_data(self):
+
+        self.manage("loaddata", "content_test")
+        self.manage("generateuserdata", "--no-onboarding", "--num-content-items", "1")
+
+        facility = Facility.objects.using(self.db_alias).get()
+        learner = FacilityUser.objects.using(self.db_alias).filter(
+            roles__isnull=True, devicepermissions=None
+        )[0]
+        staff = FacilityUser.objects.using(self.db_alias).filter(
+            roles__isnull=True, devicepermissions=None
+        )[0]
+
+        return facility, learner, staff
+
 
 class multiple_kolibri_servers(object):
     def __init__(self, count=2):
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
@@ -32,8 +32,10 @@
 from kolibri.core.lessons.models import LessonAssignment
 from kolibri.core.logger.models import ContentSessionLog
 from kolibri.core.logger.models import ContentSummaryLog
-from kolibri.core.logger.models import ExamAttemptLog
-from kolibri.core.logger.models import ExamLog
+
+# TODO: add back in after single-user exam assignment is reinstated
+# from kolibri.core.logger.models import ExamAttemptLog
+# from kolibri.core.logger.models import ExamLog
 
 
 class FacilityDatasetCertificateTestCase(TestCase):
@@ -132,14 +134,24 @@ class EcosystemTestCase(TestCase):
 
     def _create_objects(self, server):
         fac = Facility.objects.using(server.db_alias).first()
-        admin = FacilityUser(
-            username=uuid.uuid4().hex[:30], password=DUMMY_PASSWORD, facility=fac
+        admin_username = uuid.uuid4().hex[:30]
+        learner_username = uuid.uuid4().hex[:30]
+        server.create_model(
+            FacilityUser,
+            username=admin_username,
+            password=DUMMY_PASSWORD,
+            facility_id=fac.id,
         )
-        admin.save(using=server.db_alias)
-        learner = FacilityUser(
-            username=uuid.uuid4().hex[:30], password=DUMMY_PASSWORD, facility=fac
+        server.create_model(
+            FacilityUser,
+            username=learner_username,
+            password=DUMMY_PASSWORD,
+            facility_id=fac.id,
+        )
+        admin = FacilityUser.objects.using(server.db_alias).get(username=admin_username)
+        learner = FacilityUser.objects.using(server.db_alias).get(
+            username=learner_username
         )
-        learner.save(using=server.db_alias)
 
         name = uuid.uuid4().hex
         server.create_model(Classroom, parent_id=fac.id, name=name)
@@ -183,208 +195,120 @@ def assertServerQuerysetEqual(self, s1, s2, dataset_id):
     def test_scenarios(self, servers):
         servers_len = len(servers)
         self.maxDiff = None
-        s0_alias = servers[0].db_alias
-        s0_url = servers[0].baseurl
-        s1_alias = servers[1].db_alias
-        s1_url = servers[1].baseurl
-        s2_alias = servers[2].db_alias
-        s2_url = servers[2].baseurl
-        servers[0].manage("loaddata", "content_test")
-        servers[0].manage(
-            "generateuserdata", "--no-onboarding", "--num-content-items", "1"
-        )
-        servers[1].manage(
-            "sync",
-            "--baseurl",
-            s0_url,
-            "--username",
-            "superuser",
-            "--password",
-            "password",
-        )
-        servers[2].manage(
-            "sync",
-            "--baseurl",
-            s1_url,
-            "--username",
-            "superuser",
-            "--password",
-            "password",
-        )
+        s0, s1, s2 = servers
+        facility, _, _ = s0.generate_base_data()
+
+        s1.sync(s0, facility)
+        s2.sync(s1, facility)
 
         # assert that all kolibri instances start off equal
         for i in range(servers_len):
             self.assertServerQuerysetEqual(
                 servers[i],
                 servers[(i + 1) % servers_len],
-                FacilityDataset.objects.using(servers[0].db_alias).first().id,
+                facility.dataset_id,
             )
 
         # assert created user is synced
-        FacilityUser(
+        s0.create_model(
+            FacilityUser,
             username="user",
             password=DUMMY_PASSWORD,
-            facility=Facility.objects.using(s0_alias).first(),
-        ).save(using=s0_alias)
-        servers[1].manage(
-            "sync",
-            "--baseurl",
-            s0_url,
-            "--username",
-            "superuser",
-            "--password",
-            "password",
+            facility_id=facility.id,
         )
+        s1.sync(s0, facility)
         self.assertTrue(
-            FacilityUser.objects.using(s1_alias).filter(username="user").exists()
+            FacilityUser.objects.using(s1.db_alias).filter(username="user").exists()
         )
 
         # 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()
-        servers[1].create_model(
-            Role, collection_id=fac.id, user_id=user.id, kind="admin"
-        )
-        servers[0].delete_model(FacilityUser, id=user.id)
+        user = FacilityUser.objects.using(s1.db_alias).get(username="user")
+        fac = Facility.objects.using(s1.db_alias).get()
+        s1.create_model(Role, collection_id=fac.id, user_id=user.id, kind="admin")
+        s0.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",
-        )
+        s0.sync(s1, facility)
 
         # create user with same username on two servers and check they both exist
-        FacilityUser(
+        s0.create_model(
+            FacilityUser,
             username="copycat",
             password=DUMMY_PASSWORD,
-            facility=Facility.objects.using(s0_alias).first(),
-        ).save(using=s0_alias)
-        FacilityUser(
+            facility_id=facility.id,
+        )
+        s1.create_model(
+            FacilityUser,
             username="copycat",
             password=DUMMY_PASSWORD,
-            facility=Facility.objects.using(s1_alias).first(),
-        ).save(using=s1_alias)
-        servers[1].manage(
-            "sync",
-            "--baseurl",
-            s0_url,
-            "--username",
-            "superuser",
-            "--password",
-            "password",
+            facility_id=facility.id,
         )
+        s1.sync(s0, facility)
         self.assertEqual(
-            FacilityUser.objects.using(s0_alias).filter(username="copycat").count(), 2
+            FacilityUser.objects.using(s0.db_alias).filter(username="copycat").count(),
+            2,
         )
         self.assertEqual(
-            FacilityUser.objects.using(s1_alias).filter(username="copycat").count(), 2
+            FacilityUser.objects.using(s1.db_alias).filter(username="copycat").count(),
+            2,
         )
 
         # Add a classroom
-        servers[0].create_model(
+        s0.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",
+            parent_id=Facility.objects.using(s0.db_alias).first().id,
         )
+        s1.sync(s0, facility)
         self.assertTrue(
-            Classroom.objects.using(s1_alias).filter(name="classroom").exists()
+            Classroom.objects.using(s1.db_alias).filter(name="classroom").exists()
         )
 
         # Add a learnergroup
-        servers[0].create_model(
+        s0.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",
+            parent_id=Classroom.objects.using(s0.db_alias).first().id,
         )
+        s1.sync(s0, facility)
         self.assertTrue(
-            LearnerGroup.objects.using(s1_alias).filter(name="learnergroup").exists()
+            LearnerGroup.objects.using(s1.db_alias).filter(name="learnergroup").exists()
         )
 
         # 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")
-        servers[1].create_model(
-            Role, collection_id=fac.id, user_id=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",
-        )
-        role = Role.objects.using(s1_alias).get(user=alk_user)
-        admin_role = Store.objects.using(s1_alias).get(id=role.id)
+        fac = Facility.objects.using(s1.db_alias).get()
+        alk_user = FacilityUser.objects.using(s0.db_alias).get(username="Antemblowind")
+        s1.create_model(Role, collection_id=fac.id, user_id=alk_user.id, kind="admin")
+        s0.create_model(Role, collection_id=fac.id, user_id=alk_user.id, kind="admin")
+        s1.sync(s0, facility)
+        role = Role.objects.using(s1.db_alias).get(user=alk_user)
+        admin_role = Store.objects.using(s1.db_alias).get(id=role.id)
         self.assertTrue(admin_role.conflicting_serialized_data)
 
         # assert deleted object is propagated
-        servers[0].delete_model(FacilityUser, id=alk_user.id)
-        servers[1].manage(
-            "sync",
-            "--baseurl",
-            s0_url,
-            "--username",
-            "superuser",
-            "--password",
-            "password",
-        )
+        s0.delete_model(FacilityUser, id=alk_user.id)
+        s1.sync(s0, facility)
         self.assertFalse(
-            FacilityUser.objects.using(s1_alias)
+            FacilityUser.objects.using(s1.db_alias)
             .filter(username="Antemblowind")
             .exists()
         )
-        self.assertTrue(Store.objects.using(s1_alias).get(id=alk_user.id).deleted)
+        self.assertTrue(Store.objects.using(s1.db_alias).get(id=alk_user.id).deleted)
 
         # # role deletion and re-creation
         # Change roles for users
-        alto_user = FacilityUser.objects.using(s1_alias).get(username="Altobjews1977")
-        servers[1].create_model(
-            Role, collection_id=fac.id, user_id=alto_user.id, kind="admin"
+        alto_user = FacilityUser.objects.using(s1.db_alias).get(
+            username="Altobjews1977"
         )
+        s1.create_model(Role, collection_id=fac.id, user_id=alto_user.id, kind="admin")
         role_id = (
-            Role.objects.using(s1_alias)
+            Role.objects.using(s1.db_alias)
             .get(collection_id=fac.id, user_id=alto_user.id)
             .id
         )
-        servers[1].manage(
-            "sync",
-            "--baseurl",
-            s2_url,
-            "--username",
-            "superuser",
-            "--password",
-            "password",
-        )
+        s1.sync(s2, facility)
 
         self.assertEqual(
-            FacilityUser.objects.using(s2_alias)
+            FacilityUser.objects.using(s2.db_alias)
             .get(username="Altobjews1977")
             .roles.all()
             .first()
@@ -392,70 +316,34 @@ def test_scenarios(self, servers):
             "admin",
         )
         # delete admin role and sync
-        servers[2].delete_model(Role, id=role_id)
-        servers[1].manage(
-            "sync",
-            "--baseurl",
-            s2_url,
-            "--username",
-            "superuser",
-            "--password",
-            "password",
-        )
+        s2.delete_model(Role, id=role_id)
+        s1.sync(s2, facility)
         # create admin role and sync
-        servers[1].create_model(
-            Role, collection_id=fac.id, user_id=alto_user.id, kind="admin"
-        )
+        s1.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",
+            Role.objects.using(s1.db_alias).get(collection=fac.id, user=alto_user.id).id
         )
+        s1.sync(s2, facility)
 
-        self.assertFalse(Store.objects.using(s2_alias).get(id=role_id).deleted)
+        self.assertFalse(Store.objects.using(s2.db_alias).get(id=role_id).deleted)
 
         # 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",
-        )
-        server0_fu = FacilityUser.objects.using(s0_alias).get(id=alto_user.id)
+        s1.change_password(alto_user.id, "syncing")
+        s1.sync(s0, facility)
+        server0_fu = FacilityUser.objects.using(s0.db_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",
-                )
+                servers[j].sync(servers[(j + 1) % servers_len], facility)
 
         # assert that the data of specific models match up
         for i in range(servers_len):
             self.assertServerQuerysetEqual(
                 servers[i],
                 servers[(i + 1) % servers_len],
-                FacilityDataset.objects.using(servers[0].db_alias).first().id,
+                facility.dataset_id,
             )
 
     @multiple_kolibri_servers(5)
@@ -464,16 +352,9 @@ def test_chaos_sync(self, servers):
 
         # consistent state for all servers
         servers[0].manage("generateuserdata", "--no-onboarding")
+        facility = Facility.objects.using(servers[0].db_alias).get()
         for i in range(servers_len - 1):
-            servers[i + 1].manage(
-                "sync",
-                "--baseurl",
-                servers[0].baseurl,
-                "--username",
-                "superuser",
-                "--password",
-                "password",
-            )
+            servers[i + 1].sync(servers[0], facility)
 
         # randomly create objects on two servers and sync with each other
         for i in range(10):
@@ -482,28 +363,12 @@ def test_chaos_sync(self, servers):
             else:
                 self._create_objects(servers[4])
 
-            servers[2].manage(
-                "sync",
-                "--baseurl",
-                servers[4].baseurl,
-                "--username",
-                "superuser",
-                "--password",
-                "password",
-            )
+            servers[2].sync(servers[4], facility)
 
         # 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",
-                )
+                servers[j].sync(servers[(j + 1) % servers_len], facility)
 
         # assert that the data of specific models match up
         for i in range(servers_len):
@@ -521,76 +386,49 @@ def test_chaos_sync(self, servers):
 class EcosystemSingleUserTestCase(TestCase):
     @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]
+        self.maxDiff = None
+        s0, s1, s2 = servers
 
-        learner2 = FacilityUser.objects.using(s0_alias).filter(roles__isnull=True)[1]
+        facility, _, _ = s0.generate_base_data()
 
-        learner2.set_password("syncing")
-        learner2.save(using=s0_alias)
+        learner1 = FacilityUser.objects.using(s0.db_alias).filter(
+            roles__isnull=True, devicepermissions=None
+        )[0]
+        learner2 = FacilityUser.objects.using(s0.db_alias).filter(
+            roles__isnull=True, devicepermissions=None
+        )[1]
+        s0.change_password(learner2, "syncing")
 
         # 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,
-        )
+        s1.sync(s0, facility, user=learner1)
         # 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,
+        s2.sync(
+            s0, facility, user=learner2, username=learner2.username, password="syncing"
         )
 
         # Check that learner 1 is on server 1
         self.assertTrue(
-            FacilityUser.objects.using(s1_alias).filter(id=learner1.id).exists()
+            FacilityUser.objects.using(s1.db_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()
+            FacilityUser.objects.using(s1.db_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()
+            FacilityUser.objects.using(s2.db_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()
+            FacilityUser.objects.using(s2.db_alias).filter(id=learner1.id).exists()
         )
 
         channel_id = "725257a0570044acbd59f8cf6a68b2be"
         content_id = "9f9438fe6b0d42dd8e913d7d04cfb2b2"
 
-        servers[1].create_model(
+        s1.create_model(
             ContentSessionLog,
             channel_id=channel_id,
             content_id=content_id,
@@ -598,7 +436,7 @@ def test_single_user_sync(self, servers):
             start_timestamp=timezone.now(),
             kind="audio",
         )
-        servers[1].create_model(
+        s1.create_model(
             ContentSummaryLog,
             channel_id=channel_id,
             content_id=content_id,
@@ -607,7 +445,7 @@ def test_single_user_sync(self, servers):
             kind="audio",
         )
 
-        servers[2].create_model(
+        s2.create_model(
             ContentSessionLog,
             channel_id=channel_id,
             content_id=content_id,
@@ -615,7 +453,7 @@ def test_single_user_sync(self, servers):
             start_timestamp=timezone.now(),
             kind="audio",
         )
-        servers[2].create_model(
+        s2.create_model(
             ContentSummaryLog,
             channel_id=channel_id,
             content_id=content_id,
@@ -624,33 +462,17 @@ def test_single_user_sync(self, servers):
             kind="audio",
         )
 
-        servers[1].manage(
-            "sync",
-            "--baseurl",
-            s0_url,
-            "--facility",
-            facility_id,
-            "--user",
-            learner1.id,
-        )
-        servers[2].manage(
-            "sync",
-            "--baseurl",
-            s0_url,
-            "--facility",
-            facility_id,
-            "--user",
-            learner2.id,
-        )
+        s1.sync(s0, facility, user=learner1)
+        s2.sync(s0, facility, user=learner2)
 
         self.assertEqual(
-            ContentSessionLog.objects.using(s0_alias)
+            ContentSessionLog.objects.using(s0.db_alias)
             .filter(channel_id=channel_id, content_id=content_id)
             .count(),
             2,
         )
         self.assertEqual(
-            ContentSummaryLog.objects.using(s0_alias)
+            ContentSummaryLog.objects.using(s0.db_alias)
             .filter(channel_id=channel_id, content_id=content_id)
             .count(),
             2,
@@ -670,36 +492,22 @@ def test_single_user_assignment_sync(self, servers):
         """
 
         self.maxDiff = None
-        self.servers = servers
-        self.laptop_a = 0
-        self.laptop_b = 1
-        self.tablet = 2
-
-        self.alias_a = servers[self.laptop_a].db_alias
-        self.alias_b = servers[self.laptop_b].db_alias
-        self.alias_t = servers[self.tablet].db_alias
-
-        # create the original facility on Laptop A
-        servers[self.laptop_a].manage("loaddata", "content_test")
-        servers[self.laptop_a].manage(
-            "generateuserdata", "--no-onboarding", "--num-content-items", "1"
-        )
-        self.facility_id = Facility.objects.using(self.alias_a).get().id
-        self.learner = FacilityUser.objects.using(self.alias_a).filter(
-            roles__isnull=True
-        )[0]
-        self.teacher = FacilityUser.objects.using(self.alias_a).filter(
-            roles__isnull=False
-        )[0]
-        self.classroom = Classroom.objects.using(self.alias_a).first()
-        self.classroom2 = Classroom.objects.using(self.alias_a).all()[1]
-        servers[self.laptop_a].create_model(
+        self.laptop_a, self.laptop_b, self.tablet = servers
+
+        self.facility, self.learner, self.teacher = self.laptop_a.generate_base_data()
+
+        self.classroom = Classroom.objects.using(self.laptop_a.db_alias).first()
+        self.classroom2 = Classroom.objects.using(self.laptop_a.db_alias).all()[1]
+
+        self.laptop_a.create_model(
             Membership, user_id=self.learner.id, collection_id=self.classroom.id
         )
 
         # 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 ("exam", "lesson"):
+        for kind in (
+            "lesson",
+        ):  # TODO: once exams are back, change to: ("exam", "lesson")
             for disable_assignment in (self.deactivate, self.unassign):
 
                 # Create on Laptop A, single-user sync to tablet, disable, repeat
@@ -779,151 +587,97 @@ def test_single_user_assignment_sync(self, servers):
                     self.tablet, kind, assignment_id, should_exist=False
                 )
 
-        # Create exam on Laptop A, single-user sync to tablet, then modify exam on Laptop A and
-        # single-user sync again to check that "updating" works
-        assignment_id = self.create_assignment("exam")
-        self.sync_single_user(self.laptop_a)
-        assignment_a = ExamAssignment.objects.using(self.alias_a).get(id=assignment_id)
-        assignment_a.exam.seed = 433
-        assignment_a.exam.save()
-        self.sync_single_user(self.laptop_a)
-        assignment_t = ExamAssignment.objects.using(self.alias_t).get(id=assignment_id)
-        assert assignment_t.exam.seed == 433
-
-        # Create ExamLog and ExamAttemptLog on tablet and sync to laptop A to verify receipt
-        servers[self.tablet].create_model(
-            ExamLog,
-            exam_id=assignment_t.exam_id,
-            user_id=self.learner.id,
-            completion_timestamp=timezone.now(),
-        )
-        learner_exam_log = ExamLog.objects.using(self.alias_t).get(
-            exam_id=assignment_t.exam_id, user_id=self.learner.id
-        )
-        servers[self.tablet].create_model(
-            ExamAttemptLog,
-            user_id=self.learner.id,
-            examlog_id=learner_exam_log.id,
-            content_id=uuid.uuid4().hex,
-            item=uuid.uuid4().hex,
-            start_timestamp=timezone.now(),
-            end_timestamp=timezone.now(),
-            completion_timestamp=timezone.now(),
-            time_spent=2,
-            complete=True,
-            correct=False,
-        )
-        self.sync_single_user(self.laptop_a)
-        self.assertTrue(
-            ExamLog.objects.using(self.alias_a)
-            .filter(exam_id=assignment_t.exam_id, user_id=self.learner.id)
-            .exists()
-        )
+        # TODO: uncomment the following once single-user exam assignment syncing is reinstated
+        # # Create exam on Laptop A, single-user sync to tablet, then modify exam on Laptop A and
+        # # single-user sync again to check that "updating" works
+        # assignment_id = self.create_assignment("exam")
+        # self.sync_single_user(self.laptop_a)
+        # assignment_a = ExamAssignment.objects.using(self.laptop_a.db_alias).get(id=assignment_id)
+        # assignment_a.exam.seed = 433
+        # assignment_a.exam.save()
+        # self.sync_single_user(self.laptop_a)
+        # assignment_t = ExamAssignment.objects.using(self.tablet.db_alias).get(id=assignment_id)
+        # assert assignment_t.exam.seed == 433
+
+        # # Create ExamLog and ExamAttemptLog on tablet and sync to laptop A to verify receipt
+        # self.tablet.create_model(
+        #     ExamLog,
+        #     exam_id=assignment_t.exam_id,
+        #     user_id=self.learner.id,
+        #     completion_timestamp=timezone.now(),
+        # )
+        # learner_exam_log = ExamLog.objects.using(self.tablet.db_alias).get(
+        #     exam_id=assignment_t.exam_id, user_id=self.learner.id
+        # )
+        # self.tablet.create_model(
+        #     ExamAttemptLog,
+        #     user_id=self.learner.id,
+        #     examlog_id=learner_exam_log.id,
+        #     content_id=uuid.uuid4().hex,
+        #     item=uuid.uuid4().hex,
+        #     start_timestamp=timezone.now(),
+        #     end_timestamp=timezone.now(),
+        #     completion_timestamp=timezone.now(),
+        #     time_spent=2,
+        #     complete=True,
+        #     correct=False,
+        # )
+        # self.sync_single_user(self.laptop_a)
+        # self.assertTrue(
+        #     ExamLog.objects.using(self.laptop_a.db_alias)
+        #     .filter(exam_id=assignment_t.exam_id, user_id=self.learner.id)
+        #     .exists()
+        # )
 
         # Create lesson on Laptop A, single-user sync to tablet, then modify lesson on Laptop A
         # and single-user sync again to check that "updating" works
         assignment_id = self.create_assignment("lesson")
         self.sync_single_user(self.laptop_a)
-        assignment_a = LessonAssignment.objects.using(self.alias_a).get(
+        assignment_a = LessonAssignment.objects.using(self.laptop_a.db_alias).get(
             id=assignment_id
         )
         assignment_a.lesson.title = "Bee Boo"
         assignment_a.lesson.save()
         self.sync_single_user(self.laptop_a)
-        assignment_t = LessonAssignment.objects.using(self.alias_t).get(
+        assignment_t = LessonAssignment.objects.using(self.tablet.db_alias).get(
             id=assignment_id
         )
         assert assignment_t.lesson.title == "Bee Boo"
 
         # The morango dirty bits should not be set on exams, lessons, and assignments on the tablet,
         # since we never want these "ghost" copies to sync back out to anywhere else
+        # TODO: uncomment the following once single-user exam assignment syncing is reinstated
+        # assert (
+        #     ExamAssignment.objects.using(self.tablet.db_alias)
+        #     .filter(_morango_dirty_bit=True)
+        #     .count()
+        #     == 0
+        # )
+        # assert (
+        #     Exam.objects.using(self.tablet.db_alias).filter(_morango_dirty_bit=True).count()
+        #     == 0
+        # )
         assert (
-            ExamAssignment.objects.using(self.alias_t)
+            LessonAssignment.objects.using(self.tablet.db_alias)
             .filter(_morango_dirty_bit=True)
             .count()
             == 0
         )
         assert (
-            Exam.objects.using(self.alias_t).filter(_morango_dirty_bit=True).count()
-            == 0
-        )
-        assert (
-            LessonAssignment.objects.using(self.alias_t)
+            Lesson.objects.using(self.tablet.db_alias)
             .filter(_morango_dirty_bit=True)
             .count()
             == 0
         )
-        assert (
-            Lesson.objects.using(self.alias_t).filter(_morango_dirty_bit=True).count()
-            == 0
-        )
-
-    @multiple_kolibri_servers(2)
-    def test_facility_user_conflict_syncing_from_tablet(self, servers):
-        self._test_facility_user_conflict(servers, True)
-
-    @multiple_kolibri_servers(2)
-    def test_facility_user_conflict_syncing_from_laptop(self, servers):
-        self._test_facility_user_conflict(servers, False)
-
-    def _test_facility_user_conflict(self, servers, tablet_is_client):
-        """
-        This is a regression test to handle the case of a FacilityUser being changed
-        on a SoUD as a backend side effect, e.g. by Django updating the `last_login`
-        field, leading to a merge conflict when the user is updated on another device.
-        """
-
-        self.servers = servers
-        self.laptop = 0
-        self.tablet = 1
-
-        self.alias_laptop = servers[self.laptop].db_alias
-        self.alias_tablet = servers[self.tablet].db_alias
-
-        # create the original facility on the Laptop
-        servers[self.laptop].manage("loaddata", "content_test")
-        servers[self.laptop].manage(
-            "generateuserdata", "--no-onboarding", "--num-content-items", "1"
-        )
-        self.facility_id = Facility.objects.using(self.alias_laptop).get().id
-        self.learner = FacilityUser.objects.using(self.alias_laptop).filter(
-            roles__isnull=True
-        )[0]
-
-        self.sync_single_user(self.laptop)
-
-        servers[self.laptop].update_model(
-            FacilityUser, self.learner.id, full_name="NEW"
-        )
-
-        servers[self.tablet].update_model(
-            FacilityUser, self.learner.id, last_login=datetime.datetime.now()
-        )
-
-        self.sync_single_user(self.laptop, tablet_is_client=tablet_is_client)
-
-        assert (
-            FacilityUser.objects.using(self.alias_tablet)
-            .get(id=self.learner.id)
-            .full_name
-            == "NEW"
-        )
 
     def sync_full_facility_servers(self):
         """
         Perform a full sync between Laptop A and Laptop B.
         """
 
-        self.servers[self.laptop_b].manage(
-            "sync",
-            "--baseurl",
-            self.servers[self.laptop_a].baseurl,
-            "--username",
-            "superuser",
-            "--password",
-            "password",
-            "--facility",
-            self.facility_id,
+        self.laptop_b.sync(
+            self.laptop_a,
+            self.facility,
         )
 
     def sync_single_user(self, full_server, tablet_is_client=True):
@@ -933,38 +687,26 @@ def sync_single_user(self, full_server, tablet_is_client=True):
         """
 
         if tablet_is_client:
-            self.servers[self.tablet].manage(
-                "sync",
-                "--baseurl",
-                self.servers[full_server].baseurl,
-                "--username",
-                "superuser",
-                "--password",
-                "password",
-                "--facility",
-                self.facility_id,
-                "--user",
-                self.learner.id,
+            self.tablet.sync(
+                full_server,
+                self.facility,
+                user=self.learner,
             )
         else:
-            self.servers[full_server].manage(
-                "sync",
-                "--baseurl",
-                self.servers[self.tablet].baseurl,
-                "--facility",
-                self.facility_id,
-                "--user",
-                self.learner.id,
+            full_server.sync(
+                self.tablet,
+                self.facility,
+                user=self.learner,
             )
 
     def create_assignment(self, kind):
         """
         Create an exam or lesson and assign it to the class, on a particular server.
         """
-        alias = self.servers[self.laptop_a].db_alias
+        alias = self.laptop_a.db_alias
         title = uuid.uuid4().hex
         if kind == "exam":
-            self.servers[self.laptop_a].create_model(
+            self.laptop_a.create_model(
                 Exam,
                 title=title,
                 question_count=1,
@@ -973,7 +715,7 @@ def create_assignment(self, kind):
                 creator_id=self.teacher.id,
                 active=True,
             )
-            self.servers[self.laptop_a].create_model(
+            self.laptop_a.create_model(
                 ExamAssignment,
                 exam_id=Exam.objects.using(alias).get(title=title).id,
                 collection_id=self.classroom.id,
@@ -981,7 +723,7 @@ def create_assignment(self, kind):
             )
             return ExamAssignment.objects.using(alias).get(exam__title=title).id
         elif kind == "lesson":
-            self.servers[self.laptop_a].create_model(
+            self.laptop_a.create_model(
                 Lesson,
                 title=title,
                 resources=["a"],
@@ -989,7 +731,7 @@ def create_assignment(self, kind):
                 created_by_id=self.teacher.id,
                 is_active=True,
             )
-            self.servers[self.laptop_a].create_model(
+            self.laptop_a.create_model(
                 LessonAssignment,
                 lesson_id=Lesson.objects.using(alias).get(title=title).id,
                 collection_id=self.classroom.id,
@@ -1002,9 +744,9 @@ def unassign(self, server, kind, assignment_id):
         Remove an exam or lesson assignment from a particular server.
         """
         if kind == "exam":
-            self.servers[server].delete_model(ExamAssignment, id=assignment_id)
+            server.delete_model(ExamAssignment, id=assignment_id)
         elif kind == "lesson":
-            self.servers[server].delete_model(LessonAssignment, id=assignment_id)
+            server.delete_model(LessonAssignment, id=assignment_id)
 
     def deactivate(self, server, kind, assignment_id):
         """
@@ -1016,7 +758,7 @@ def set_active_state(self, server, kind, assignment_id, state):
         """
         Set the active state of a lesson or exam on a particular server.
         """
-        alias = self.servers[server].db_alias
+        alias = server.db_alias
         if kind == "exam":
             assignment = ExamAssignment.objects.using(alias).get(id=assignment_id)
             exam = assignment.exam
@@ -1032,7 +774,7 @@ def assert_existence(self, server, kind, assignment_id, should_exist=True):
         """
         Assert that an exam or lesson is active and assigned to the class on a particular server.
         """
-        alias = self.servers[server].db_alias
+        alias = server.db_alias
         try:
             if kind == "exam":
                 ExamAssignment.objects.using(alias).get(
@@ -1053,3 +795,94 @@ def assert_existence(self, server, kind, assignment_id, should_exist=True):
             ), "Assignment {assignment_id} should exist on server {server}!".format(
                 assignment_id=assignment_id, server=server
             )
+
+
+@unittest.skipIf(
+    not os.environ.get("INTEGRATION_TEST"),
+    "This test will only be run during integration testing.",
+)
+class SingleUserSyncRegressionsTestCase(TestCase):
+    @multiple_kolibri_servers(2)
+    def test_facility_user_conflict_syncing_from_tablet(self, servers):
+        self._test_facility_user_conflict(servers, True)
+
+    @multiple_kolibri_servers(2)
+    def test_facility_user_conflict_syncing_from_laptop(self, servers):
+        self._test_facility_user_conflict(servers, False)
+
+    def _test_facility_user_conflict(self, servers, tablet_is_client):
+        """
+        This is a regression test to handle the case of a FacilityUser being changed
+        on a SoUD as a backend side effect, e.g. by Django updating the `last_login`
+        field, leading to a merge conflict when the user is updated on another device.
+        """
+
+        laptop, tablet = servers
+
+        facility, learner, _ = laptop.generate_base_data()
+
+        tablet.sync(laptop, facility, user=learner)
+
+        laptop.update_model(FacilityUser, learner.id, full_name="NEW")
+
+        tablet.update_model(
+            FacilityUser, learner.id, last_login=datetime.datetime.now()
+        )
+
+        if tablet_is_client:
+            tablet.sync(laptop, facility, user=learner)
+        else:
+            laptop.sync(tablet, facility, user=learner)
+
+        assert (
+            FacilityUser.objects.using(tablet.db_alias).get(id=learner.id).full_name
+            == "NEW"
+        )
+
+    @multiple_kolibri_servers(3)
+    def test_kolibri_issue_8439(self, servers):
+        """
+        This is to test the syncing issue identified in:
+        https://github.com/learningequality/kolibri/issues/8439
+        and should pass once Morango 0.6.6 is released.
+        """
+
+        mainserver, soud1, soud2 = servers
+
+        facility, learner, _ = mainserver.generate_base_data()
+
+        # set up soud1 as a single-user device
+        soud1.sync(mainserver, facility, user=learner)
+
+        # create a log on soud1
+        base_log_params = {
+            "channel_id": "725257a0570044acbd59f8cf6a68b2be",
+            "content_id": "9f9438fe6b0d42dd8e913d7d04cfb277",
+            "user_id": learner.id,
+        }
+        soud1.create_model(
+            ContentSummaryLog,
+            start_timestamp=timezone.now(),
+            kind="audio",
+            **base_log_params
+        )
+
+        # sync the log from soud1 back to the main server
+        soud1.sync(mainserver, facility, user=learner)
+
+        # verify that it was synced correctly
+        assert (
+            ContentSummaryLog.objects.using(mainserver.db_alias)
+            .filter(**base_log_params)
+            .exists()
+        )
+
+        # sync the same user and their data onto a new single-user device
+        soud2.sync(mainserver, facility, user=learner)
+
+        # verify that the log has made it over to the new single-user device as well
+        assert (
+            ContentSummaryLog.objects.using(soud2.db_alias)
+            .filter(**base_log_params)
+            .exists()
+        )