Repository: learningequality/kolibri

Issue:
Add UserSyncStatus API response fields for downloads and removals
<!--
Instructions:
 * Fill out the sections below, replace …'s with information about your issue
 * Use the 'preview' function above this text box to verify formatting before submitting
-->
Depends on: https://github.com/learningequality/kolibri/pull/10365

## Background
In order to complete Learner notifications for automatic content syncing, we need additional data points made available with work from #10365. This will allow us to wire up the [already implemented notifications](https://github.com/learningequality/kolibri/pull/10056/files#diff-155b7b2af6eb8f44179578e0b3670d02f3938df8170a9a2bcdaea7ff196dbcb7R71-R72) with this new data.

## Deliverables
### The [`UserSyncStatus` API](https://github.com/learningequality/kolibri/blob/develop/kolibri/core/device/api.py#L284)
a) It should have a new response field `has_downloads` which annotates the existence of `ContentDownloadRequest` models for the user. For example:
```python
Exists(
  ContentDownloadRequest.objects.filter(
    source_id=OuterRef("user_id"), 
    source_model=FacilityUser.morango_model_name, 
    reason=ContentRequestReason.UserInitiated,
  )
)
```
b) It should have a new response field `last_download_removed` which annotates the date of the most recent `ContentRemovalRequest` model for the user with the `SyncInitiated` reason. For example:
```python
Subquery(
  ContentRemovalRequest.objects.filter(
    source_id=OuterRef("user_id"), 
    source_model=FacilityUser.morango_model_name, 
    reason=ContentRequestReason.SyncInitiated,
  )
  .annotate(last_removal=Max("requested_at"))
  .values("last_removal")
)
```

### The composable [`useUserSyncStatus`](https://github.com/learningequality/kolibri/blob/develop/kolibri/core/assets/src/composables/useUserSyncStatus.js)
a) It should be updated to extract the newly added fields `has_downloads` and `last_download_removed`

### The learner Vue component [`StorageNotification.vue`](https://github.com/AllanOXDi/kolibri/blob/develop/kolibri/core/assets/src/views/StorageNotification.vue#L67-L72)
a) It should be refactored to use the `useUserSyncStatus` composable internally, instead of accepting a `showBanner` prop from `AppBarPage.vue`
b) It should update usage of `insufficientSpace` to match `deviceStatus === LearnerDeviceStatus.INSUFFICIENT_STORAGE`
c) It should update usage of `availableDownloads` to match the new API response field `has_downloads`
d) It should update usage of `resourcesRemoved` to match the new API response field `last_download_removed`
e) It should update usage of `hasDevicePermission` to use [`canManageContent`](https://github.com/learningequality/kolibri/blob/develop/kolibri/core/assets/src/state/modules/session.js#L38) from the user session
f) It should persist the user's dismissal of the notification, using the dates of the last sync and the last removed download

## Acceptance criteria
The notification should properly render the notification as designed in the Figma spec https://www.figma.com/file/MpCG7r5PULOCJsEvnTSti8/Android-Designs-2022?node-id=194-2922

## References
https://github.com/learningequality/kolibri/pull/10056
https://github.com/learningequality/kolibri/issues/10184


Discussion:

Resolving patch:
diff --git a/kolibri/core/content/models.py b/kolibri/core/content/models.py
--- a/kolibri/core/content/models.py
+++ b/kolibri/core/content/models.py
@@ -457,7 +457,7 @@ def build_for_user(cls, user):
         :return: A ContentRequest
         :rtype: ContentRequest
         """
-        return ContentRequest(
+        return cls(
             facility_id=user.facility_id,
             source_model=FacilityUser.morango_model_name,
             source_id=user.id,
diff --git a/kolibri/core/device/api.py b/kolibri/core/device/api.py
--- a/kolibri/core/device/api.py
+++ b/kolibri/core/device/api.py
@@ -4,6 +4,7 @@
 
 from django.conf import settings
 from django.contrib.auth import login
+from django.db.models import Exists
 from django.db.models import Max
 from django.db.models import OuterRef
 from django.db.models.expressions import Subquery
@@ -40,12 +41,17 @@
 from kolibri.core.auth.api import KolibriAuthPermissions
 from kolibri.core.auth.api import KolibriAuthPermissionsFilter
 from kolibri.core.auth.models import Collection
+from kolibri.core.auth.models import FacilityUser
+from kolibri.core.content.models import ContentDownloadRequest
+from kolibri.core.content.models import ContentRemovalRequest
+from kolibri.core.content.models import ContentRequestReason
 from kolibri.core.content.permissions import CanManageContent
 from kolibri.core.content.utils.channels import get_mounted_drive_by_id
 from kolibri.core.content.utils.channels import get_mounted_drives_with_channel_info
 from kolibri.core.device.permissions import IsSuperuser
 from kolibri.core.device.utils import get_device_setting
 from kolibri.core.discovery.models import DynamicNetworkLocation
+from kolibri.core.fields import DateTimeTzField
 from kolibri.core.public.constants.user_sync_options import DELAYED_SYNC
 from kolibri.core.public.constants.user_sync_statuses import INSUFFICIENT_STORAGE
 from kolibri.core.public.constants.user_sync_statuses import NOT_RECENTLY_SYNCED
@@ -71,6 +77,7 @@
 from kolibri.utils.system import get_free_space
 from kolibri.utils.time_utils import local_now
 
+
 logger = logging.getLogger(__name__)
 
 
@@ -291,6 +298,8 @@ class UserSyncStatusViewSet(ReadOnlyValuesViewset):
         "device_status",
         "device_status_sentiment",
         "user",
+        "has_downloads",
+        "last_download_removed",
     )
 
     field_map = {
@@ -327,6 +336,24 @@ def annotate_queryset(self, queryset):
             instance_id=OuterRef("sync_session__client_instance_id"),
         )
 
+        has_download = Exists(
+            ContentDownloadRequest.objects.filter(
+                source_id=OuterRef("user_id"),
+                source_model=FacilityUser.morango_model_name,
+                reason=ContentRequestReason.UserInitiated,
+            )
+        )
+
+        last_download_removal = Subquery(
+            ContentRemovalRequest.objects.filter(
+                source_id=OuterRef("user_id"),
+                source_model=FacilityUser.morango_model_name,
+                reason=ContentRequestReason.SyncInitiated,
+            )
+            .annotate(last_removal=Max("requested_at"))
+            .values("last_removal"),
+            output_field=DateTimeTzField(),
+        )
         queryset = queryset.annotate(
             transfer_status=Subquery(most_recent_sync_status),
             device_status=Subquery(
@@ -335,8 +362,9 @@ def annotate_queryset(self, queryset):
             device_status_sentiment=Subquery(
                 most_recent_synced_device_status.values("status_sentiment")[:1]
             ),
+            has_downloads=has_download,
+            last_download_removed=last_download_removal,
         )
-
         return queryset
 
 


Test patch:
diff --git a/kolibri/core/device/test/test_api.py b/kolibri/core/device/test/test_api.py
--- a/kolibri/core/device/test/test_api.py
+++ b/kolibri/core/device/test/test_api.py
@@ -31,6 +31,7 @@
 from kolibri.core.auth.test.test_api import ClassroomFactory
 from kolibri.core.auth.test.test_api import FacilityFactory
 from kolibri.core.auth.test.test_api import FacilityUserFactory
+from kolibri.core.content.models import ContentDownloadRequest
 from kolibri.core.device.models import DevicePermissions
 from kolibri.core.device.models import DeviceSettings
 from kolibri.core.device.models import DeviceStatus
@@ -862,3 +863,25 @@ def test_usersyncstatus_list__unknown_non_negative_device_status(self):
         self.assertEqual(len(response.data), 1)
         self.assertEqual(response.data[0]["user"], self.user1.id)
         self.assertEqual(response.data[0]["status"], user_sync_statuses.RECENTLY_SYNCED)
+
+    def test_downloads_queryset(self):
+        self.client.login(
+            username=self.user1.username,
+            password=DUMMY_PASSWORD,
+            facility=self.facility,
+        )
+        self._create_transfer_session(
+            active=False,
+        )
+        content_request = ContentDownloadRequest.build_for_user(self.user1)
+        content_request.contentnode_id = uuid.uuid4().hex
+        device_status = self._create_device_status("oopsie", StatusSentiment.Neutral)
+        self.syncsession1.client_instance_id = device_status.instance_id
+        self.syncsession1.save()
+        response = self.client.get(reverse("kolibri:core:usersyncstatus-list"))
+        content_request.save()
+        self.assertEqual(len(response.data), 1)
+        self.assertEqual(response.data[0]["user"], self.user1.id)
+        self.assertEqual(response.data[0]["status"], user_sync_statuses.RECENTLY_SYNCED)
+        self.assertFalse(response.data[0]["has_downloads"])
+        self.assertIsNone(response.data[0]["last_download_removed"])