From 37621954a1ce9b3a8a96c87ccb9e73dcc43fd29d Mon Sep 17 00:00:00 2001 From: MisRob Date: Fri, 24 Jun 2022 14:22:27 +0200 Subject: [PATCH] More granular sync status information Differentiate between successful and failed sync Resolves https://github.com/learningequality/kolibri/issues/9091 --- .../__tests__/index.spec.js | 130 +++++++++++++++++- .../sync/FacilityNameAndSyncStatus/index.vue | 30 +++- kolibri/core/auth/api.py | 47 +++++-- kolibri/core/auth/test/test_api.py | 63 +++++++++ .../assets/src/views/PostSetupModalGroup.vue | 4 +- 5 files changed, 255 insertions(+), 19 deletions(-) diff --git a/kolibri/core/assets/src/views/sync/FacilityNameAndSyncStatus/__tests__/index.spec.js b/kolibri/core/assets/src/views/sync/FacilityNameAndSyncStatus/__tests__/index.spec.js index dea9c9d4fe7..23a6d400f27 100644 --- a/kolibri/core/assets/src/views/sync/FacilityNameAndSyncStatus/__tests__/index.spec.js +++ b/kolibri/core/assets/src/views/sync/FacilityNameAndSyncStatus/__tests__/index.spec.js @@ -1,9 +1,135 @@ import { shallowMount } from '@vue/test-utils'; import FacilityNameAndSyncStatus from '../index'; -describe('FacilityNameAndSyncStatus', () => { - it('smoke test', () => { +describe(`FacilityNameAndSyncStatus`, () => { + it(`smoke test`, () => { const wrapper = shallowMount(FacilityNameAndSyncStatus); expect(wrapper.exists()).toBeTruthy(); }); + + describe(`sync status`, () => { + describe(`when a facility has never been synced`, () => { + it(`shows the "Never synced" message`, () => { + const wrapper = shallowMount(FacilityNameAndSyncStatus, { + propsData: { + facility: { + name: 'Test facility', + last_successful_sync: null, + last_failed_sync: null, + }, + }, + }); + expect(wrapper.html()).toContain('Never synced'); + }); + + describe(`when the sync task has failed`, () => { + let wrapper; + beforeEach(() => { + wrapper = shallowMount(FacilityNameAndSyncStatus, { + propsData: { + facility: { + name: 'Test facility', + last_successful_sync: null, + last_failed_sync: null, + }, + syncTaskHasFailed: true, + }, + }); + }); + + it(`doesn't show the "Never synced" message`, () => { + expect(wrapper.html()).not.toContain('Never synced'); + }); + + it(`shows the "Most recent sync failed" message`, () => { + expect(wrapper.html()).toContain('Most recent sync failed'); + }); + }); + }); + + describe(`when a facility has been synced at least once in the past`, () => { + describe(`when the last failed sync is more recent than the last successful sync`, () => { + let wrapper; + beforeEach(() => { + wrapper = shallowMount(FacilityNameAndSyncStatus, { + propsData: { + facility: { + name: 'Test facility', + last_successful_sync: '2022-04-21T16:00:00Z', + last_failed_sync: '2022-06-25T13:00:00Z', + }, + }, + }); + }); + + it(`shows relative time of the last successful sync`, () => { + expect(wrapper.html()).toContain( + `Last successful sync: ${wrapper.vm.$formatRelative('2022-04-21T16:00:00Z', { + now: wrapper.vm.now, + })}` + ); + }); + + it(`shows the "Most recent sync failed" message`, () => { + expect(wrapper.html()).toContain('Most recent sync failed'); + }); + }); + + describe(`when the last failed sync is older than the last successful sync`, () => { + let wrapper; + beforeEach(() => { + wrapper = shallowMount(FacilityNameAndSyncStatus, { + propsData: { + facility: { + name: 'Test facility', + last_successful_sync: '2022-06-25T13:00:00Z', + last_failed_sync: '2022-04-21T16:00:00Z', + }, + }, + }); + }); + + it(`shows relative time of the last successful sync`, () => { + expect(wrapper.html()).toContain( + `Last successful sync: ${wrapper.vm.$formatRelative('2022-06-25T13:00:00Z', { + now: wrapper.vm.now, + })}` + ); + }); + + it(`doesn't show the "Most recent sync failed" message`, () => { + expect(wrapper.html()).not.toContain('Most recent sync failed'); + }); + }); + + describe(`when the sync task has failed`, () => { + let wrapper; + beforeEach(() => { + wrapper = shallowMount(FacilityNameAndSyncStatus, { + propsData: { + facility: { + name: 'Test facility', + last_successful_sync: '2022-06-25T13:00:00Z', + last_failed_sync: '2022-04-21T16:00:00Z', + }, + syncTaskHasFailed: true, + }, + }); + }); + + it(`shows relative time of the last successful sync`, () => { + expect(wrapper.html()).toContain( + `Last successful sync: ${wrapper.vm.$formatRelative('2022-06-25T13:00:00Z', { + now: wrapper.vm.now, + })}` + ); + }); + + // failed sync task information takes precendence over information on the facility object + it(`shows the "Most recent sync failed" message`, () => { + expect(wrapper.html()).toContain('Most recent sync failed'); + }); + }); + }); + }); }); diff --git a/kolibri/core/assets/src/views/sync/FacilityNameAndSyncStatus/index.vue b/kolibri/core/assets/src/views/sync/FacilityNameAndSyncStatus/index.vue index 8676adea117..801c2d17242 100644 --- a/kolibri/core/assets/src/views/sync/FacilityNameAndSyncStatus/index.vue +++ b/kolibri/core/assets/src/views/sync/FacilityNameAndSyncStatus/index.vue @@ -33,15 +33,17 @@ {{ getTaskString('removingFacilityStatus') }} @@ -67,7 +69,7 @@ type: Boolean, default: false, }, - syncHasFailed: { + syncTaskHasFailed: { type: Boolean, default: false, }, @@ -81,6 +83,22 @@ now: now(), }; }, + computed: { + syncFailed() { + const lastSyncFailed = + this.facility && + this.facility.last_successful_sync && + this.facility.last_failed_sync && + new Date(this.facility.last_successful_sync).getTime() < + new Date(this.facility.last_failed_sync).getTime(); + return this.syncTaskHasFailed || lastSyncFailed; + }, + neverSynced() { + return ( + this.facility && !this.facility.last_successful_sync && !this.facility.last_failed_sync + ); + }, + }, methods: { formattedTime(datetime) { if (this.now - new Date(datetime) < 10000) { diff --git a/kolibri/core/auth/api.py b/kolibri/core/auth/api.py index c2f316a91bf..1759afb935c 100644 --- a/kolibri/core/auth/api.py +++ b/kolibri/core/auth/api.py @@ -34,6 +34,8 @@ from django_filters.rest_framework import FilterSet from django_filters.rest_framework import ModelChoiceFilter from morango.api.permissions import BasicMultiArgumentAuthentication +from morango.constants import transfer_stages +from morango.constants import transfer_statuses from morango.models import TransferSession from rest_framework import decorators from rest_framework import filters @@ -486,13 +488,28 @@ class FacilityViewSet(ValuesViewset): queryset = Facility.objects.all() serializer_class = FacilitySerializer - facility_values = ["id", "name", "num_classrooms", "num_users", "last_synced"] + facility_values = [ + "id", + "name", + "num_classrooms", + "num_users", + "last_successful_sync", + "last_failed_sync", + ] values = tuple(facility_values + dataset_keys) field_map = {"dataset": _map_dataset} def annotate_queryset(self, queryset): + transfer_session_dataset_filter = Func( + Cast(OuterRef("dataset"), TextField()), + Value("-"), + Value(""), + function="replace", + output_field=TextField(), + ) + return ( queryset.annotate( num_users=SQCount( @@ -505,15 +522,27 @@ def annotate_queryset(self, queryset): ) ) .annotate( - last_synced=Subquery( + last_successful_sync=Subquery( + # the sync command does a pull, then a push, so if the push succeeded, + # the pull likely did too, which means this should represent when the + # facility was last fully and successfully synced + TransferSession.objects.filter( + push=True, + active=False, + transfer_stage=transfer_stages.CLEANUP, + transfer_stage_status=transfer_statuses.COMPLETED, + filter=transfer_session_dataset_filter, + ) + .order_by("-last_activity_timestamp") + .values("last_activity_timestamp")[:1] + ) + ) + .annotate( + last_failed_sync=Subquery( + # Here we simply look for if any transfer session has errored TransferSession.objects.filter( - filter=Func( - Cast(OuterRef("dataset"), TextField()), - Value("-"), - Value(""), - function="replace", - output_field=TextField(), - ) + transfer_stage_status=transfer_statuses.ERRORED, + filter=transfer_session_dataset_filter, ) .order_by("-last_activity_timestamp") .values("last_activity_timestamp")[:1] diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py index 08e4c240a99..0db34635578 100644 --- a/kolibri/core/auth/test/test_api.py +++ b/kolibri/core/auth/test/test_api.py @@ -5,11 +5,18 @@ import base64 import collections import sys +import uuid +from datetime import datetime from importlib import import_module import factory from django.conf import settings from django.core.urlresolvers import reverse +from django.utils import timezone +from morango.constants import transfer_stages +from morango.constants import transfer_statuses +from morango.models import SyncSession +from morango.models import TransferSession from rest_framework import status from rest_framework.test import APITestCase as BaseTestCase @@ -430,6 +437,33 @@ def setUpTestData(cls): cls.facility2 = FacilityFactory.create() cls.user1 = FacilityUserFactory.create(facility=cls.facility1) cls.user2 = FacilityUserFactory.create(facility=cls.facility2) + cls.date_completed_push_transfer_session = datetime( + 2022, 6, 30, tzinfo=timezone.utc + ) + cls.date_failed_transfer_session = datetime(2022, 6, 14, tzinfo=timezone.utc) + cls.sync_session = SyncSession.objects.create( + id=uuid.uuid4().hex, + profile="facilitydata", + last_activity_timestamp=cls.date_completed_push_transfer_session, + ) + cls.completed_push_transfer_session = TransferSession.objects.create( + id=uuid.uuid4().hex, + sync_session_id=cls.sync_session.id, + filter=cls.facility1.dataset_id, + push=True, + active=False, + transfer_stage=transfer_stages.CLEANUP, + transfer_stage_status=transfer_statuses.COMPLETED, + last_activity_timestamp=cls.date_completed_push_transfer_session, + ) + cls.failed_transfer_session = TransferSession.objects.create( + id=uuid.uuid4().hex, + sync_session_id=cls.sync_session.id, + filter=cls.facility1.dataset_id, + push=True, + transfer_stage_status=transfer_statuses.ERRORED, + last_activity_timestamp=cls.date_failed_transfer_session, + ) def test_sanity(self): self.assertTrue( @@ -455,6 +489,35 @@ def test_facility_user_can_get_detail(self): {"name": self.facility1.name}, dict(response.data) ) + def test_facility_user_can_get_last_successful_sync(self): + self.client.login( + username=self.user1.username, + password=DUMMY_PASSWORD, + facility=self.facility1, + ) + response = self.client.get( + reverse("kolibri:core:facility-detail", kwargs={"pk": self.facility1.pk}), + format="json", + ) + self.assertEqual( + response.data["last_successful_sync"], + self.date_completed_push_transfer_session, + ) + + def test_facility_user_can_get_last_failed_sync(self): + self.client.login( + username=self.user1.username, + password=DUMMY_PASSWORD, + facility=self.facility1, + ) + response = self.client.get( + reverse("kolibri:core:facility-detail", kwargs={"pk": self.facility1.pk}), + format="json", + ) + self.assertEqual( + response.data["last_failed_sync"], self.date_failed_transfer_session + ) + def test_device_admin_can_create_facility(self): new_facility_name = "New Facility" self.client.login( diff --git a/kolibri/plugins/device/assets/src/views/PostSetupModalGroup.vue b/kolibri/plugins/device/assets/src/views/PostSetupModalGroup.vue index 793d2bb517e..617a1ffd9ba 100644 --- a/kolibri/plugins/device/assets/src/views/PostSetupModalGroup.vue +++ b/kolibri/plugins/device/assets/src/views/PostSetupModalGroup.vue @@ -62,12 +62,12 @@ }; }, computed: { - // Assume that if first facility has non-null 'last_synced' + // Assume that if first facility has non-null 'last_successful_sync' // field, then it was imported in Setup Wizard. // This used to determine Select Source workflow to enter into importedFacility() { const [facility] = this.$store.state.core.facilities; - if (facility && facility.last_synced !== null) { + if (facility && facility.last_successful_sync !== null) { return facility; } return null;