From 6b9b13cf9c977cefb17772395dced0cbd43db10d Mon Sep 17 00:00:00 2001 From: Siddharth Agarwal Date: Mon, 9 Dec 2024 19:43:44 +0530 Subject: [PATCH] Add `PatientAttributeSync` --- .../org/simple/clinic/di/TestAppComponent.kt | 2 + .../PatientAttributeSyncIntegrationTest.kt | 179 ++++++++++++++++++ .../org/simple/clinic/main/TypedPreference.kt | 1 + .../patientattribute/PatientAttribute.kt | 4 + .../PatientAttributeModule.kt | 21 ++ .../PatientAttributeRepository.kt | 5 + .../sync/PatientAttributePayload.kt | 9 +- .../sync/PatientAttributePullResponse.kt | 17 ++ .../sync/PatientAttributePushRequest.kt | 11 ++ .../sync/PatientAttributeSync.kt | 56 ++++++ .../sync/PatientAttributeSyncApi.kt | 24 +++ .../java/org/simple/clinic/sync/SyncModule.kt | 4 +- 12 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 app/src/androidTest/java/org/simple/clinic/sync/PatientAttributeSyncIntegrationTest.kt create mode 100644 app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributePullResponse.kt create mode 100644 app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributePushRequest.kt create mode 100644 app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributeSync.kt create mode 100644 app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributeSyncApi.kt diff --git a/app/src/androidTest/java/org/simple/clinic/di/TestAppComponent.kt b/app/src/androidTest/java/org/simple/clinic/di/TestAppComponent.kt index 6dfa32c577a..2fe2349307b 100644 --- a/app/src/androidTest/java/org/simple/clinic/di/TestAppComponent.kt +++ b/app/src/androidTest/java/org/simple/clinic/di/TestAppComponent.kt @@ -64,6 +64,7 @@ import org.simple.clinic.sync.BloodSugarSyncIntegrationTest import org.simple.clinic.sync.CallResultSyncIntegrationTest import org.simple.clinic.sync.HelpSyncIntegrationTest import org.simple.clinic.sync.MedicalHistorySyncIntegrationTest +import org.simple.clinic.sync.PatientAttributeSyncIntegrationTest import org.simple.clinic.sync.PatientSyncIntegrationTest import org.simple.clinic.sync.PrescriptionSyncIntegrationTest import org.simple.clinic.sync.ProtocolSyncIntegrationTest @@ -164,4 +165,5 @@ interface TestAppComponent { fun inject(target: QuestionnaireResponseSyncIntegrationTest) fun inject(target: DatabaseEncryptorTest) fun inject(target: PatientAttributeRepositoryAndroidTest) + fun inject(target: PatientAttributeSyncIntegrationTest) } diff --git a/app/src/androidTest/java/org/simple/clinic/sync/PatientAttributeSyncIntegrationTest.kt b/app/src/androidTest/java/org/simple/clinic/sync/PatientAttributeSyncIntegrationTest.kt new file mode 100644 index 00000000000..1dc1da2556d --- /dev/null +++ b/app/src/androidTest/java/org/simple/clinic/sync/PatientAttributeSyncIntegrationTest.kt @@ -0,0 +1,179 @@ +package org.simple.clinic.sync + +import com.f2prateek.rx.preferences2.Preference +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.simple.clinic.AppDatabase +import org.simple.clinic.TestClinicApp +import org.simple.clinic.main.TypedPreference +import org.simple.clinic.main.TypedPreference.Type.LastPatientAttributePullToken +import org.simple.clinic.patient.SyncStatus +import org.simple.clinic.patientattribute.BMIReading +import org.simple.clinic.patientattribute.PatientAttribute +import org.simple.clinic.patientattribute.PatientAttributeRepository +import org.simple.clinic.patientattribute.sync.PatientAttributeSync +import org.simple.clinic.patientattribute.sync.PatientAttributeSyncApi +import org.simple.clinic.rules.RegisterPatientRule +import org.simple.clinic.rules.SaveDatabaseRule +import org.simple.clinic.rules.ServerAuthenticationRule +import org.simple.clinic.user.UserSession +import org.simple.clinic.util.unsafeLazy +import org.simple.sharedTestCode.TestData +import org.simple.sharedTestCode.util.Rules +import java.util.Optional +import java.util.UUID +import javax.inject.Inject + +class PatientAttributeSyncIntegrationTest { + + @Inject + lateinit var appDatabase: AppDatabase + + @Inject + lateinit var repository: PatientAttributeRepository + + @Inject + @TypedPreference(LastPatientAttributePullToken) + lateinit var lastPullToken: Preference> + + @Inject + lateinit var syncApi: PatientAttributeSyncApi + + @Inject + lateinit var userSession: UserSession + + @Inject + lateinit var syncInterval: SyncInterval + + private val patientUuid = UUID.fromString("9af4f083-86dd-453f-91e5-9c716e859a9e") + + private val userUuid: UUID by unsafeLazy { userSession.loggedInUserImmediate()!!.uuid } + + @get:Rule + val ruleChain: RuleChain = Rules + .global() + .around(ServerAuthenticationRule()) + .around(RegisterPatientRule(patientUuid)) + .around(SaveDatabaseRule()) + + private lateinit var sync: PatientAttributeSync + + private val batchSize = 3 + + private lateinit var config: SyncConfig + + @Before + fun setUp() { + TestClinicApp.appComponent().inject(this) + + resetLocalData() + + config = SyncConfig( + syncInterval = syncInterval, + pullBatchSize = batchSize, + pushBatchSize = batchSize, + name = "" + ) + + sync = PatientAttributeSync( + syncCoordinator = SyncCoordinator(), + repository = repository, + api = syncApi, + lastPullToken = lastPullToken, + config = config + ) + } + + private fun resetLocalData() { + clearData() + lastPullToken.delete() + } + + private fun clearData() { + appDatabase.patientAttributeDao().clear() + } + + @Test + fun syncing_records_should_work_as_expected() { + // given + val totalNumberOfRecords = batchSize * 2 + 1 + val records = (1..totalNumberOfRecords).map { + TestData.patientAttribute( + patientUuid = patientUuid, + userUuid = userUuid, + reading = BMIReading( + height = "177", + weight = "68" + ), + syncStatus = SyncStatus.PENDING, + ) + } + assertThat(records).containsNoDuplicates() + + repository.save(records) + assertThat(repository.pendingSyncRecordCount().blockingFirst()).isEqualTo(totalNumberOfRecords) + + // when + sync.push() + clearData() + sync.pull() + + // then + val expectedPulledRecords = records.map { it.syncCompleted() } + val pulledRecords = repository.recordsWithSyncStatus(SyncStatus.DONE) + + assertThat(pulledRecords).containsAtLeastElementsIn(expectedPulledRecords) + } + + @Test + fun sync_pending_records_should_not_be_overwritten_by_server_records() { + // given + val records = (1..batchSize).map { + TestData.patientAttribute( + patientUuid = patientUuid, + userUuid = userUuid, + reading = BMIReading( + height = "177", + weight = "68" + ), + syncStatus = SyncStatus.PENDING, + ) + } + assertThat(records).containsNoDuplicates() + + repository.save(records) + sync.push() + assertThat(repository.pendingSyncRecordCount().blockingFirst()).isEqualTo(0) + + val modifiedRecord = records[1].withReading(BMIReading(height = "182", weight = "78")) + repository.save(listOf(modifiedRecord)) + assertThat(repository.pendingSyncRecordCount().blockingFirst()).isEqualTo(1) + + // when + sync.pull() + + // then + val expectedSavedRecords = records + .map { it.syncCompleted() } + .filterNot { it.patientUuid == modifiedRecord.patientUuid } + + val savedRecords = repository.recordsWithSyncStatus(SyncStatus.DONE) + val pendingSyncRecords = repository.recordsWithSyncStatus(SyncStatus.PENDING) + + assertThat(savedRecords).containsAtLeastElementsIn(expectedSavedRecords) + assertThat(pendingSyncRecords).containsExactly(modifiedRecord) + } + + private fun PatientAttribute.syncCompleted(): PatientAttribute = copy(syncStatus = SyncStatus.DONE) + + private fun PatientAttribute.withReading(reading: BMIReading): PatientAttribute { + return copy( + reading = reading.copy(height = reading.height, weight = reading.weight), + syncStatus = SyncStatus.PENDING, + timestamps = timestamps.copy(updatedAt = timestamps.updatedAt.plusMillis(1)) + ) + } +} diff --git a/app/src/main/java/org/simple/clinic/main/TypedPreference.kt b/app/src/main/java/org/simple/clinic/main/TypedPreference.kt index 270c547949d..bc5b366fbb2 100644 --- a/app/src/main/java/org/simple/clinic/main/TypedPreference.kt +++ b/app/src/main/java/org/simple/clinic/main/TypedPreference.kt @@ -23,5 +23,6 @@ annotation class TypedPreference(val value: Type) { LastQuestionnairePullToken, LastQuestionnaireResponsePullToken, DataProtectionConsent, + LastPatientAttributePullToken, } } diff --git a/app/src/main/java/org/simple/clinic/patientattribute/PatientAttribute.kt b/app/src/main/java/org/simple/clinic/patientattribute/PatientAttribute.kt index 4a1fef28037..83f23e2449a 100644 --- a/app/src/main/java/org/simple/clinic/patientattribute/PatientAttribute.kt +++ b/app/src/main/java/org/simple/clinic/patientattribute/PatientAttribute.kt @@ -13,6 +13,7 @@ import androidx.room.RawQuery import androidx.sqlite.db.SimpleSQLiteQuery import io.reactivex.Flowable import kotlinx.parcelize.Parcelize +import org.simple.clinic.medicalhistory.MedicalHistory import org.simple.clinic.patient.SyncStatus import org.simple.clinic.storage.Timestamps import java.util.UUID @@ -64,6 +65,9 @@ data class PatientAttribute( @Query("SELECT COUNT(uuid) FROM PatientAttribute WHERE syncStatus = :syncStatus") fun countWithStatus(syncStatus: SyncStatus): Flowable + @Query("SELECT * FROM MedicalHistory WHERE syncStatus = :status") + fun recordsWithSyncStatus(status: SyncStatus): List + @Query(""" SELECT * FROM PatientAttribute WHERE syncStatus = :syncStatus diff --git a/app/src/main/java/org/simple/clinic/patientattribute/PatientAttributeModule.kt b/app/src/main/java/org/simple/clinic/patientattribute/PatientAttributeModule.kt index bffe046cab0..d56ae262d51 100644 --- a/app/src/main/java/org/simple/clinic/patientattribute/PatientAttributeModule.kt +++ b/app/src/main/java/org/simple/clinic/patientattribute/PatientAttributeModule.kt @@ -1,8 +1,18 @@ package org.simple.clinic.patientattribute +import com.f2prateek.rx.preferences2.Preference +import com.f2prateek.rx.preferences2.RxSharedPreferences import dagger.Module import dagger.Provides import org.simple.clinic.AppDatabase +import org.simple.clinic.main.TypedPreference +import org.simple.clinic.main.TypedPreference.Type.LastPatientAttributePullToken +import org.simple.clinic.patientattribute.sync.PatientAttributeSyncApi +import org.simple.clinic.util.preference.StringPreferenceConverter +import org.simple.clinic.util.preference.getOptional +import retrofit2.Retrofit +import java.util.Optional +import javax.inject.Named @Module class PatientAttributeModule { @@ -10,4 +20,15 @@ class PatientAttributeModule { fun dao(appDatabase: AppDatabase): PatientAttribute.RoomDao { return appDatabase.patientAttributeDao() } + + @Provides + fun syncApi(@Named("for_deployment") retrofit: Retrofit): PatientAttributeSyncApi { + return retrofit.create(PatientAttributeSyncApi::class.java) + } + + @Provides + @TypedPreference(LastPatientAttributePullToken) + fun lastPullToken(rxSharedPrefs: RxSharedPreferences): Preference> { + return rxSharedPrefs.getOptional("last_patient_attribute_pull_token", StringPreferenceConverter()) + } } diff --git a/app/src/main/java/org/simple/clinic/patientattribute/PatientAttributeRepository.kt b/app/src/main/java/org/simple/clinic/patientattribute/PatientAttributeRepository.kt index 9962304d0c0..223b404bca8 100644 --- a/app/src/main/java/org/simple/clinic/patientattribute/PatientAttributeRepository.kt +++ b/app/src/main/java/org/simple/clinic/patientattribute/PatientAttributeRepository.kt @@ -3,6 +3,7 @@ package org.simple.clinic.patientattribute import io.reactivex.Completable import io.reactivex.Observable import org.simple.clinic.di.AppScope +import org.simple.clinic.medicalhistory.MedicalHistory import org.simple.clinic.patient.SyncStatus import org.simple.clinic.patient.SyncStatus.PENDING import org.simple.clinic.patientattribute.sync.PatientAttributePayload @@ -78,4 +79,8 @@ class PatientAttributeRepository @Inject constructor( fun getPatientAttribute(patientUuid: UUID): PatientAttribute? { return dao.patientImmediate(patientUuid) } + + fun recordsWithSyncStatus(syncStatus: SyncStatus): List { + return dao.recordsWithSyncStatus(syncStatus) + } } diff --git a/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributePayload.kt b/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributePayload.kt index 58e0daf6b16..78b37a63d9a 100644 --- a/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributePayload.kt +++ b/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributePayload.kt @@ -11,14 +11,15 @@ import java.util.UUID @JsonClass(generateAdapter = false) data class PatientAttributePayload( + @Json(name = "id") val uuid: UUID, @Json(name = "height") - val height: Float, + val height: String, @Json(name = "weight") - val weight: Float, + val weight: String, @Json(name = "patient_id") val patientUuid: UUID, @@ -41,8 +42,8 @@ data class PatientAttributePayload( patientUuid = patientUuid, userUuid = userUuid, reading = BMIReading( - height = height.toString(), - weight = weight.toString() + height = height, + weight = weight ), timestamps = Timestamps( createdAt = createdAt, diff --git a/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributePullResponse.kt b/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributePullResponse.kt new file mode 100644 index 00000000000..3594d6810ee --- /dev/null +++ b/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributePullResponse.kt @@ -0,0 +1,17 @@ +package org.simple.clinic.patientattribute.sync + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.simple.clinic.sync.DataPullResponse + +@JsonClass(generateAdapter = true) +data class PatientAttributePullResponse( + + @Json(name = "patient_attributes") + override val payloads: List, + + @Json(name = "process_token") + override val processToken: String + +) : DataPullResponse + diff --git a/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributePushRequest.kt b/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributePushRequest.kt new file mode 100644 index 00000000000..2aa99dcf66c --- /dev/null +++ b/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributePushRequest.kt @@ -0,0 +1,11 @@ +package org.simple.clinic.patientattribute.sync + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PatientAttributePushRequest( + + @Json(name = "patient_attributes") + val histories: List +) diff --git a/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributeSync.kt b/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributeSync.kt new file mode 100644 index 00000000000..4f3d627263d --- /dev/null +++ b/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributeSync.kt @@ -0,0 +1,56 @@ +package org.simple.clinic.patientattribute.sync + +import com.f2prateek.rx.preferences2.Preference +import org.simple.clinic.main.TypedPreference +import org.simple.clinic.main.TypedPreference.Type.LastPatientAttributePullToken +import org.simple.clinic.patientattribute.PatientAttribute +import org.simple.clinic.patientattribute.PatientAttributeRepository +import org.simple.clinic.sync.ModelSync +import org.simple.clinic.sync.SyncConfig +import org.simple.clinic.sync.SyncConfigType +import org.simple.clinic.sync.SyncConfigType.Type.Frequent +import org.simple.clinic.sync.SyncCoordinator +import org.simple.clinic.util.read +import java.util.Optional +import javax.inject.Inject + +class PatientAttributeSync @Inject constructor( + private val syncCoordinator: SyncCoordinator, + private val repository: PatientAttributeRepository, + private val api: PatientAttributeSyncApi, + @TypedPreference(LastPatientAttributePullToken) private val lastPullToken: Preference>, + @SyncConfigType(Frequent) private val config: SyncConfig +) : ModelSync { + + override val name: String = "Patient Attribute" + + override val requiresSyncApprovedUser = true + + override fun push() { + syncCoordinator.push(repository, config.pushBatchSize) { api.push(toRequest(it)).execute().read()!! } + } + + override fun pull() { + val batchSize = config.pullBatchSize + syncCoordinator.pull(repository, lastPullToken, batchSize) { api.pull(batchSize, it).execute().read()!! } + } + + private fun toRequest(patientAttributes: List): PatientAttributePushRequest { + val payloads = patientAttributes + .map { + it.run { + PatientAttributePayload( + uuid = uuid, + patientUuid = patientUuid, + userUuid = userUuid, + height = reading.height, + weight = reading.weight, + createdAt = timestamps.createdAt, + updatedAt = timestamps.updatedAt, + deletedAt = timestamps.deletedAt + ) + } + } + return PatientAttributePushRequest(payloads) + } +} diff --git a/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributeSyncApi.kt b/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributeSyncApi.kt new file mode 100644 index 00000000000..604f03792c9 --- /dev/null +++ b/app/src/main/java/org/simple/clinic/patientattribute/sync/PatientAttributeSyncApi.kt @@ -0,0 +1,24 @@ +package org.simple.clinic.patientattribute.sync + +import org.simple.clinic.sync.DataPushResponse +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Query + +interface PatientAttributeSyncApi { + + @POST("v4/patient_attributes/sync") + fun push( + @Body body: PatientAttributePushRequest + ): Call + + @Headers(value = ["X-RESYNC-TOKEN: 1"]) + @GET("v4/patient_attributes/sync") + fun pull( + @Query("limit") recordsToPull: Int, + @Query("process_token") lastPullToken: String? = null + ): Call +} diff --git a/app/src/main/java/org/simple/clinic/sync/SyncModule.kt b/app/src/main/java/org/simple/clinic/sync/SyncModule.kt index ec36389fb3f..9bfc89bf2c6 100644 --- a/app/src/main/java/org/simple/clinic/sync/SyncModule.kt +++ b/app/src/main/java/org/simple/clinic/sync/SyncModule.kt @@ -37,6 +37,7 @@ import org.simple.clinic.patient.PatientRepository import org.simple.clinic.patient.sync.PatientSync import org.simple.clinic.patient.sync.PatientSyncModule import org.simple.clinic.patientattribute.PatientAttributeModule +import org.simple.clinic.patientattribute.sync.PatientAttributeSync import org.simple.clinic.protocol.ProtocolModule import org.simple.clinic.protocol.sync.ProtocolSync import org.simple.clinic.questionnaire.di.QuestionnaireModule @@ -93,6 +94,7 @@ class SyncModule { callResultSync: CallResultSync, questionnaireSync: QuestionnaireSync, questionnaireResponseSync: QuestionnaireResponseSync, + patientAttributeSync: PatientAttributeSync ): List { val optionalSyncs = if (features.isEnabled(Feature.CallResultSyncEnabled)) listOf(callResultSync) else emptyList() @@ -100,7 +102,7 @@ class SyncModule { questionnaireSync, questionnaireResponseSync, protocolSync, reportsSync, helpSync, patientSync, bloodPressureSync, medicalHistorySync, appointmentSync, prescriptionSync, bloodSugarSync, teleconsultationMedicalOfficersSync, - teleconsultRecordSync, drugSync + teleconsultRecordSync, drugSync, patientAttributeSync ) + optionalSyncs }