Repository: learningequality/kolibri

Issue:
Allow for destructive teleportation and merging of FacilityUsers across different facilities
Currently, no Kolibri device possesses the capacity to engage in single user syncing. This has resulted in there being many devices, particularly app installation devices that have a standalone facility containing as few as 1 users.

In the future, even with single user syncing, this will continue to be an issue, as many users will wish to start using Kolibri in advance of being able to sync to a specific facility server device. They will therefore, quite reasonably, expect to be able to create a user account straight away and start having their progress logged and tracked.

Reconciling these facility users or later allowing them to join a facility has a bit of a blocker due to the way that we partition data by the facility they are in. We cannot therefore, simply update the facility foreign key and dataset foreign key and call it a day - the partitioning of the UUIDs of all the data associated with the user will still be based on the original facility.

In order to resolve this issue, we need to give the appearance to the end user of this seamless facility switching happening, but in a way that obeys the constraints of our data synchronization mechanisms.

As such, we will engage in destructive teleportation - on any particular local device that has the user we wish to move, and also the data about the facility we wish to move the user to, we will give the appearance of having moved the user by recreating all the data about the user in the new facility.

The are two separate scenarios to cover here:
1. There is no target user for merging in the target facility
2. There is a user in the target facility that we wish to merge the other user into

In scenario 1, we will simply read all the data about the FacilityUser, and create a new facility user with that data in the target facility. Once the facility user has been created, we will then copy all of the associated logging models to be recreated, targeting the new user.

No assignment data will be copied.

In scenario 2, we will simply discard any information from the original user, and assume that the information on the user targeted for the merge is canonical. We will then read all of the associated logging models and recreate them with the target user as the user foreign key.

In the case of a conflict in SummaryLog, we will take a max of the progress across the two Summary Logs, and sum all the time spent across all the related session logs. In the case of a conflict in the MasteryLog, we will mark 'complete' by a logical OR on the complete fields, and take the max of the level properties.

No assignment data will be copied.

In both instances once the copy has been successful, the original user and all their data will be hard deleted.



Discussion:

Resolving patch:
diff --git a/kolibri/core/auth/api.py b/kolibri/core/auth/api.py
--- a/kolibri/core/auth/api.py
+++ b/kolibri/core/auth/api.py
@@ -24,6 +24,7 @@
 from django.db.models import Value
 from django.db.models.functions import Cast
 from django.http import Http404
+from django.http import HttpResponseForbidden
 from django.utils.decorators import method_decorator
 from django.utils.timezone import now
 from django.views.decorators.csrf import ensure_csrf_cookie
@@ -39,6 +40,7 @@
 from rest_framework import views
 from rest_framework import viewsets
 from rest_framework.response import Response
+from rest_framework.serializers import ValidationError
 
 from .constants import collection_kinds
 from .constants import role_kinds
@@ -545,9 +547,7 @@ def extract_request_data(self, request):
             "username": request.data.get("username", ""),
             "full_name": request.data.get("full_name", ""),
             "password": request.data.get("password", ""),
-            "facility": request.data.get(
-                "facility", Facility.get_default_facility().id
-            ),
+            "facility": request.data.get("facility"),
             "gender": request.data.get("gender", ""),
             "birth_year": request.data.get("birth_year", ""),
         }
@@ -555,13 +555,40 @@ def extract_request_data(self, request):
     def create(self, request):
 
         data = self.extract_request_data(request)
+        facility_id = data["facility"]
+
+        if facility_id is None:
+            facility = Facility.get_default_facility()
+            data["facility"] = facility.id
+        else:
+            try:
+                facility = Facility.objects.select_related("dataset").get(
+                    id=facility_id
+                )
+            except Facility.DoesNotExist:
+                raise ValidationError(
+                    "Facility does not exist.",
+                    code=error_constants.FACILITY_DOES_NOT_EXIST,
+                )
+
+        if not facility.dataset.learner_can_sign_up:
+            return HttpResponseForbidden("Cannot sign up to this facility")
+
         # we validate the user's input, and if valid, login as user
         serialized_user = self.serializer_class(data=data)
         if serialized_user.is_valid(raise_exception=True):
+            if (
+                data["password"] == "NOT_SPECIFIED"
+                and not facility.dataset.learner_can_login_with_no_password
+            ):
+                raise ValidationError(
+                    "No password specified and it is required",
+                    code=error_constants.PASSWORD_NOT_SPECIFIED,
+                )
             serialized_user.save()
             if data["password"] != "NOT_SPECIFIED":
                 serialized_user.instance.set_password(data["password"])
-            serialized_user.instance.save()
+                serialized_user.instance.save()
             authenticated_user = authenticate(
                 username=data["username"],
                 password=data["password"],
diff --git a/kolibri/core/auth/utils.py b/kolibri/core/auth/utils.py
--- a/kolibri/core/auth/utils.py
+++ b/kolibri/core/auth/utils.py
@@ -3,7 +3,14 @@
 from django.utils.six.moves import input
 
 from kolibri.core.auth.models import AdHocGroup
+from kolibri.core.auth.models import FacilityDataset
+from kolibri.core.auth.models import FacilityUser
 from kolibri.core.auth.models import Membership
+from kolibri.core.logger.models import AttemptLog
+from kolibri.core.logger.models import ContentSessionLog
+from kolibri.core.logger.models import ContentSummaryLog
+from kolibri.core.logger.models import MasteryLog
+from kolibri.core.logger.models import UserSessionLog
 
 
 def confirm_or_exit(message):
@@ -20,3 +27,88 @@ def create_adhoc_group_for_learners(classroom, learners):
     for learner in learners:
         Membership.objects.create(user=learner, collection=adhoc_group)
     return adhoc_group
+
+
+def _merge_user_models(source_user, target_user):
+    for f in ["gender", "birth_year", "id_number"]:
+        source_value = getattr(source_user, f, None)
+        target_value = getattr(target_user, f, None)
+        if not target_value and source_value:
+            setattr(target_user, f, source_value)
+    target_user.save()
+
+
+blocklist = set(["id", "_morango_partition"])
+
+
+def merge_users(source_user, target_user):
+    """
+    Utility to merge two users. It makes no assumptions about whether
+    the users are in the same facility and does raw copies of all
+    associated user data, rather than try to do anything clever.
+    """
+    if source_user.id == target_user.id:
+        raise ValueError("Cannot merge a user with themselves")
+
+    _merge_user_models(source_user, target_user)
+
+    id_map = {
+        FacilityUser: {source_user.id: target_user.id},
+        FacilityDataset: {
+            source_user.dataset_id: target_user.dataset_id,
+        },
+    }
+
+    def _merge_log_data(LogModel):
+        log_map = {}
+        id_map[LogModel] = log_map
+        new_logs = []
+        related_fields = [f for f in LogModel._meta.concrete_fields if f.is_relation]
+        source_logs = LogModel.objects.filter(user=source_user)
+        target_log_ids = set(
+            LogModel.objects.filter(user=target_user).values_list("id", flat=True)
+        )
+        for log in source_logs:
+            # Get all serialializable fields
+            data = log.serialize()
+            # Remove fields that we explicitly know we don't want to copy
+            for f in blocklist:
+                if f in data:
+                    del data[f]
+            # Iterate through each relation and map the old id to the new id for the foreign key
+            for relation in related_fields:
+                data[relation.attname] = id_map[relation.related_model][
+                    data[relation.attname]
+                ]
+            # If this is a randomly created source id, preserve it, so we can stop the same logs
+            # being copied in repeatedly. If it is not random, remove it, so we can recreate
+            # it on the target.
+            if log.calculate_source_id() is not None:
+                del data["_morango_source_id"]
+            new_log = LogModel.deserialize(data)
+            if not new_log._morango_source_id:
+                new_log.id = new_log.calculate_uuid()
+            else:
+                # Have to do this, otherwise morango will overwrite the current source id on the model
+                new_log.id = new_log.compute_namespaced_id(
+                    new_log.calculate_partition(),
+                    new_log._morango_source_id,
+                    new_log.morango_model_name,
+                )
+                new_log._morango_partition = new_log.calculate_partition().replace(
+                    new_log.ID_PLACEHOLDER, new_log.id
+                )
+            log_map[log.id] = new_log.id
+            if new_log.id not in target_log_ids:
+                new_logs.append(new_log)
+        LogModel.objects.bulk_create(new_logs, batch_size=750)
+
+    _merge_log_data(ContentSessionLog)
+
+    _merge_log_data(ContentSummaryLog)
+
+    _merge_log_data(UserSessionLog)
+
+    _merge_log_data(MasteryLog)
+
+    _merge_log_data(AttemptLog)


Test patch:
diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py
--- a/kolibri/core/auth/test/test_api.py
+++ b/kolibri/core/auth/test/test_api.py
@@ -925,6 +925,36 @@ def test_sign_up_able_no_guest_access(self):
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertTrue(models.FacilityUser.objects.all())
 
+    def test_no_sign_up_no_signups(self):
+        self.facility.dataset.learner_can_sign_up = False
+        self.facility.dataset.save()
+        response = self.post_to_sign_up(
+            {"username": "user", "password": DUMMY_PASSWORD}
+        )
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+        self.assertFalse(models.FacilityUser.objects.all())
+        self.facility.dataset.learner_can_sign_up = True
+        self.facility.dataset.save()
+
+    def test_password_not_specified_password_required(self):
+        self.facility.dataset.learner_can_login_with_no_password = False
+        self.facility.dataset.save()
+        response = self.post_to_sign_up(
+            {"username": "user", "password": "NOT_SPECIFIED"}
+        )
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertFalse(models.FacilityUser.objects.all())
+
+    def test_password_not_specified_password_not_required(self):
+        self.facility.dataset.learner_can_login_with_no_password = True
+        self.facility.dataset.learner_can_edit_password = False
+        self.facility.dataset.save()
+        response = self.post_to_sign_up(
+            {"username": "user", "password": "NOT_SPECIFIED"}
+        )
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertTrue(models.FacilityUser.objects.all())
+
 
 class FacilityDatasetAPITestCase(APITestCase):
     @classmethod
diff --git a/kolibri/core/auth/test/test_utils.py b/kolibri/core/auth/test/test_utils.py
--- a/kolibri/core/auth/test/test_utils.py
+++ b/kolibri/core/auth/test/test_utils.py
@@ -2,14 +2,21 @@
 from __future__ import print_function
 from __future__ import unicode_literals
 
+import datetime
+import random
 import uuid
 
+import factory
 import mock
 from django.core.management.base import CommandError
 from django.test import TestCase
 
 from ..models import Facility
 from kolibri.core.auth.management import utils
+from kolibri.core.auth.test.test_api import FacilityFactory
+from kolibri.core.auth.test.test_api import FacilityUserFactory
+from kolibri.core.auth.utils import merge_users
+from kolibri.core.logger import models as log_models
 
 
 class GetFacilityTestCase(TestCase):
@@ -50,3 +57,427 @@ class GetFacilityFailureTestCase(TestCase):
     def test_get_facility_no_facilities(self):
         with self.assertRaisesRegexp(CommandError, "no facilities"):
             utils.get_facility()
+
+
+class ContentSessionLogFactory(factory.DjangoModelFactory):
+    class Meta:
+        model = log_models.ContentSessionLog
+
+    user = factory.SubFactory(FacilityUserFactory)
+    start_timestamp = datetime.datetime.now()
+    content_id = factory.LazyFunction(lambda: uuid.uuid4().hex)
+    channel_id = factory.LazyFunction(lambda: uuid.uuid4().hex)
+    progress = factory.LazyFunction(random.random)
+
+
+class ContentSummaryLogFactory(factory.DjangoModelFactory):
+    class Meta:
+        model = log_models.ContentSummaryLog
+
+    user = factory.SubFactory(FacilityUserFactory)
+    start_timestamp = datetime.datetime.now()
+    content_id = factory.LazyFunction(lambda: uuid.uuid4().hex)
+    channel_id = factory.LazyFunction(lambda: uuid.uuid4().hex)
+    progress = factory.LazyFunction(random.random)
+
+
+class UserSessionLogFactory(factory.DjangoModelFactory):
+    class Meta:
+        model = log_models.UserSessionLog
+
+    user = factory.SubFactory(FacilityUserFactory)
+
+
+class MasteryLogFactory(factory.DjangoModelFactory):
+    class Meta:
+        model = log_models.MasteryLog
+
+    user = factory.SubFactory(FacilityUserFactory)
+    summarylog = factory.SubFactory(ContentSummaryLogFactory)
+    start_timestamp = datetime.datetime.now()
+    mastery_level = factory.LazyFunction(lambda: random.randint(1, 10))
+
+
+class AttemptLogFactory(factory.DjangoModelFactory):
+    class Meta:
+        model = log_models.AttemptLog
+
+    user = factory.SubFactory(FacilityUserFactory)
+    masterylog = factory.SubFactory(MasteryLogFactory)
+    sessionlog = factory.SubFactory(ContentSessionLogFactory)
+    start_timestamp = datetime.datetime.now()
+    end_timestamp = datetime.datetime.now()
+    correct = False
+    time_spent = factory.LazyFunction(random.random)
+
+
+class TeleportUserTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.facility = FacilityFactory.create()
+        user = FacilityUserFactory.create(facility=cls.facility)
+
+        sess_logs = []
+        summ_logs = []
+
+        for _ in range(3):
+            sess_logs.append(
+                ContentSessionLogFactory.create(
+                    user=user,
+                )
+            )
+
+            summ_logs.append(
+                ContentSummaryLogFactory.create(
+                    user=user,
+                )
+            )
+        ex_csessl = ContentSessionLogFactory.create(
+            user=user,
+        )
+
+        ex_csmlog = ContentSummaryLogFactory.create(
+            user=user,
+        )
+        masterylog = MasteryLogFactory.create(user=user, summarylog=ex_csmlog)
+        attemptlog = AttemptLogFactory.create(
+            user=user, masterylog=masterylog, sessionlog=ex_csessl
+        )
+        usersessionlog = UserSessionLogFactory.create(user=user)
+
+        sess_logs.append(ex_csessl)
+        summ_logs.append(ex_csmlog)
+
+        setattr(cls, "user_1", user)
+        setattr(cls, "user_1_sess_logs", sess_logs)
+        setattr(cls, "user_1_summ_logs", summ_logs)
+        setattr(cls, "user_1_masterylog", masterylog)
+        setattr(cls, "user_1_attemptlog", attemptlog)
+        setattr(cls, "user_1_usersessionlog", usersessionlog)
+
+        cls.user_1_id = cls.user_1.id
+
+        cls.user_2 = FacilityUserFactory.create(facility=cls.facility)
+
+        merge_users(cls.user_1, cls.user_2)
+
+    def test_masterylogs(self):
+        self.assertEqual(
+            log_models.MasteryLog.objects.filter(user=self.user_2).count(), 1
+        )
+        log = self.user_1_masterylog
+        self.assertTrue(
+            log_models.MasteryLog.objects.filter(
+                summarylog__progress=log.summarylog.progress,
+                user=self.user_2,
+                mastery_level=log.mastery_level,
+                summarylog__channel_id=log.summarylog.channel_id,
+                summarylog__content_id=log.summarylog.content_id,
+            ).exists()
+        )
+
+    def test_usersessionlogs(self):
+        self.assertEqual(
+            log_models.UserSessionLog.objects.filter(user=self.user_2).count(), 1
+        )
+
+    def test_attemptlogs(self):
+        self.assertEqual(
+            log_models.AttemptLog.objects.filter(user=self.user_2).count(), 1
+        )
+        log = self.user_1_attemptlog
+        self.assertTrue(
+            log_models.AttemptLog.objects.filter(
+                masterylog__summarylog__progress=log.masterylog.summarylog.progress,
+                user=self.user_2,
+                time_spent=log.time_spent,
+                masterylog__summarylog__channel_id=log.masterylog.summarylog.channel_id,
+                masterylog__summarylog__content_id=log.masterylog.summarylog.content_id,
+            ).exists()
+        )
+
+    def test_contentsessionlogs(self):
+        self.assertEqual(
+            log_models.ContentSessionLog.objects.filter(user=self.user_2).count(),
+            len(self.user_1_sess_logs),
+        )
+        for log in self.user_1_sess_logs:
+            self.assertTrue(
+                log_models.ContentSessionLog.objects.filter(
+                    progress=log.progress,
+                    user=self.user_2,
+                    content_id=log.content_id,
+                    channel_id=log.channel_id,
+                ).exists()
+            )
+
+    def test_contentsummarylogs(self):
+        self.assertEqual(
+            log_models.ContentSummaryLog.objects.filter(user=self.user_2).count(),
+            len(self.user_1_summ_logs),
+        )
+        for log in self.user_1_summ_logs:
+            self.assertTrue(
+                log_models.ContentSummaryLog.objects.filter(
+                    progress=log.progress,
+                    user=self.user_2,
+                    content_id=log.content_id,
+                    channel_id=log.channel_id,
+                ).exists()
+            )
+
+
+class TeleportUserTwiceTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.facility = FacilityFactory.create()
+        i = 1
+        user = FacilityUserFactory.create(facility=cls.facility)
+
+        sess_logs = []
+        summ_logs = []
+
+        for _ in range(3):
+            sess_logs.append(
+                ContentSessionLogFactory.create(
+                    user=user,
+                )
+            )
+
+            summ_logs.append(
+                ContentSummaryLogFactory.create(
+                    user=user,
+                )
+            )
+        ex_csessl = ContentSessionLogFactory.create(
+            user=user,
+        )
+
+        ex_csmlog = ContentSummaryLogFactory.create(
+            user=user,
+        )
+        masterylog = MasteryLogFactory.create(user=user, summarylog=ex_csmlog)
+        attemptlog = AttemptLogFactory.create(
+            user=user, masterylog=masterylog, sessionlog=ex_csessl
+        )
+        usersessionlog = UserSessionLogFactory.create(user=user)
+
+        sess_logs.append(ex_csessl)
+        summ_logs.append(ex_csmlog)
+
+        setattr(cls, "user_{}".format(str(i)), user)
+        setattr(cls, "user_{}_sess_logs".format(str(i)), sess_logs)
+        setattr(cls, "user_{}_summ_logs".format(str(i)), summ_logs)
+        setattr(cls, "user_{}_masterylog".format(str(i)), masterylog)
+        setattr(cls, "user_{}_attemptlog".format(str(i)), attemptlog)
+        setattr(cls, "user_{}_usersessionlog".format(str(i)), usersessionlog)
+
+        cls.user_1_id = cls.user_1.id
+
+        cls.user_2 = FacilityUserFactory.create(facility=cls.facility)
+
+        merge_users(cls.user_1, cls.user_2)
+        merge_users(cls.user_1, cls.user_2)
+
+    def test_masterylogs(self):
+        self.assertEqual(
+            log_models.MasteryLog.objects.filter(user=self.user_2).count(), 1
+        )
+        log = self.user_1_masterylog
+        self.assertTrue(
+            log_models.MasteryLog.objects.filter(
+                summarylog__progress=log.summarylog.progress,
+                user=self.user_2,
+                mastery_level=log.mastery_level,
+                summarylog__channel_id=log.summarylog.channel_id,
+                summarylog__content_id=log.summarylog.content_id,
+            ).exists()
+        )
+
+    def test_usersessionlogs(self):
+        self.assertEqual(
+            log_models.UserSessionLog.objects.filter(user=self.user_2).count(), 1
+        )
+
+    def test_attemptlogs(self):
+        self.assertEqual(
+            log_models.AttemptLog.objects.filter(user=self.user_2).count(), 1
+        )
+        log = self.user_1_attemptlog
+        self.assertTrue(
+            log_models.AttemptLog.objects.filter(
+                masterylog__summarylog__progress=log.masterylog.summarylog.progress,
+                user=self.user_2,
+                time_spent=log.time_spent,
+                masterylog__summarylog__channel_id=log.masterylog.summarylog.channel_id,
+                masterylog__summarylog__content_id=log.masterylog.summarylog.content_id,
+            ).exists()
+        )
+
+    def test_contentsessionlogs(self):
+        self.assertEqual(
+            log_models.ContentSessionLog.objects.filter(user=self.user_2).count(),
+            len(self.user_1_sess_logs),
+        )
+        for log in self.user_1_sess_logs:
+            self.assertTrue(
+                log_models.ContentSessionLog.objects.filter(
+                    progress=log.progress,
+                    user=self.user_2,
+                    content_id=log.content_id,
+                    channel_id=log.channel_id,
+                ).exists()
+            )
+
+    def test_contentsummarylogs(self):
+        self.assertEqual(
+            log_models.ContentSummaryLog.objects.filter(user=self.user_2).count(),
+            len(self.user_1_summ_logs),
+        )
+        for log in self.user_1_summ_logs:
+            self.assertTrue(
+                log_models.ContentSummaryLog.objects.filter(
+                    progress=log.progress,
+                    user=self.user_2,
+                    content_id=log.content_id,
+                    channel_id=log.channel_id,
+                ).exists()
+            )
+
+
+class MergeUsersTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.facility = FacilityFactory.create()
+        content_identifiers = [(uuid.uuid4().hex, uuid.uuid4().hex) for i in range(3)]
+        ex_identifiers = (uuid.uuid4().hex, uuid.uuid4().hex)
+        for i in range(1, 3):
+            user = FacilityUserFactory.create(facility=cls.facility)
+
+            sess_logs = []
+            summ_logs = []
+
+            for channel_id, content_id in content_identifiers:
+
+                sess_logs.append(
+                    ContentSessionLogFactory.create(
+                        user=user,
+                        channel_id=channel_id,
+                        content_id=content_id,
+                    )
+                )
+
+                summ_logs.append(
+                    ContentSummaryLogFactory.create(
+                        user=user,
+                        channel_id=channel_id,
+                        content_id=content_id,
+                    )
+                )
+            channel_id, content_id = ex_identifiers
+            ex_csessl = ContentSessionLogFactory.create(
+                user=user,
+                channel_id=channel_id,
+                content_id=content_id,
+            )
+
+            ex_csmlog = ContentSummaryLogFactory.create(
+                user=user,
+                channel_id=channel_id,
+                content_id=content_id,
+            )
+            masterylog = MasteryLogFactory.create(
+                user=user, summarylog=ex_csmlog, mastery_level=1
+            )
+            attemptlog = AttemptLogFactory.create(
+                user=user, masterylog=masterylog, sessionlog=ex_csessl
+            )
+            usersessionlog = UserSessionLogFactory.create(user=user)
+
+            sess_logs.append(ex_csessl)
+            summ_logs.append(ex_csmlog)
+
+            setattr(cls, "user_{}".format(str(i)), user)
+            setattr(cls, "user_{}_sess_logs".format(str(i)), sess_logs)
+            setattr(cls, "user_{}_summ_logs".format(str(i)), summ_logs)
+            setattr(cls, "user_{}_masterylog".format(str(i)), masterylog)
+            setattr(cls, "user_{}_attemptlog".format(str(i)), attemptlog)
+            setattr(cls, "user_{}_usersessionlog".format(str(i)), usersessionlog)
+
+        cls.user_1_id = cls.user_1.id
+        cls.user_1.birth_year = "1984"
+        cls.user_1.id_number = "101"
+        cls.user_1.save()
+        cls.user_2.id_number = "13"
+        cls.user_2.save()
+        merge_users(cls.user_1, cls.user_2)
+
+    def test_user_data_after_merge(self):
+        self.user_2.refresh_from_db()
+        self.assertEqual(self.user_2.birth_year, "1984")
+        self.assertEqual(self.user_2.id_number, "13")
+
+    def test_masterylogs(self):
+        self.assertEqual(
+            log_models.MasteryLog.objects.filter(user=self.user_2).count(), 1
+        )
+        log = self.user_2_masterylog
+        self.assertTrue(
+            log_models.MasteryLog.objects.filter(
+                summarylog__progress=log.summarylog.progress,
+                user=self.user_2,
+                mastery_level=log.mastery_level,
+                summarylog__channel_id=log.summarylog.channel_id,
+                summarylog__content_id=log.summarylog.content_id,
+            ).exists()
+        )
+
+    def test_usersessionlogs(self):
+        self.assertEqual(
+            log_models.UserSessionLog.objects.filter(user=self.user_2).count(), 2
+        )
+
+    def test_attemptlogs(self):
+        self.assertEqual(
+            log_models.AttemptLog.objects.filter(user=self.user_2).count(), 2
+        )
+        log = self.user_1_attemptlog
+        self.assertTrue(
+            log_models.AttemptLog.objects.filter(
+                masterylog__summarylog__progress=self.user_2_attemptlog.masterylog.summarylog.progress,
+                user=self.user_2,
+                time_spent=log.time_spent,
+                masterylog__summarylog__channel_id=log.masterylog.summarylog.channel_id,
+                masterylog__summarylog__content_id=log.masterylog.summarylog.content_id,
+            ).exists()
+        )
+
+    def test_contentsessionlogs(self):
+        self.assertEqual(
+            log_models.ContentSessionLog.objects.filter(user=self.user_2).count(),
+            len(self.user_1_sess_logs) + len(self.user_2_sess_logs),
+        )
+        for log in self.user_1_sess_logs:
+            self.assertTrue(
+                log_models.ContentSessionLog.objects.filter(
+                    progress=log.progress,
+                    user=self.user_2,
+                    content_id=log.content_id,
+                    channel_id=log.channel_id,
+                ).exists()
+            )
+
+    def test_contentsummarylogs(self):
+        self.assertEqual(
+            log_models.ContentSummaryLog.objects.filter(user=self.user_2).count(),
+            len(self.user_1_summ_logs),
+        )
+        for log in self.user_2_summ_logs:
+            self.assertTrue(
+                log_models.ContentSummaryLog.objects.filter(
+                    progress=log.progress,
+                    user=self.user_2,
+                    content_id=log.content_id,
+                    channel_id=log.channel_id,
+                ).exists()
+            )