diff --git a/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml b/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml index 94623f25a..e26eb1757 100644 --- a/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml +++ b/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml @@ -101,14 +101,40 @@ onFlowComplete: text: "Apps" - assertVisible: "Apps" - assertVisible: "Assignments" -- assertVisible: "People" +- tapOn: "People" +- tapOn: "TeacherA User" +- tapOn: + id: "floating_action_button" # Edit button +- tapOn: "First names*" +- runFlow: + file: "subflows/erase_text.yaml" + env: + TEXT: "First names*" +- inputText: "TeacherB" +- tapOn: "Save" +- tapOn: "Change History" +- assertVisible: + id: "app_title" + text: "Change History" +- assertVisible: "Person name changed from “TeacherA User” to “TeacherB User”" + - runFlow: - file: "subflows/admin_add_class.yaml" + file: "subflows/add_class.yaml" env: CLASSNAME: "New Class" - assertVisible: id: "app_title" text: "New Class" + +- tapOn: "Add Teacher" +- tapOn: "TeacherB User" +- tapOn: "Change History" +- assertVisible: + id: "app_title" + text: "Change History" +- assertVisible: "Teacher added: “TeacherB User”" +- back + - tapOn: "Add Student" - tapOn: "Invite person" - assertVisible: @@ -199,6 +225,19 @@ onFlowComplete: - assertVisible: "Pending requests.*" - assertVisible: "Student User.*" - tapOn: "Accept Invite" +- assertNotVisible: "Pending requests.*" +- tapOn: "Change History" +- assertVisible: + id: "app_title" + text: "Change History" +- assertVisible: "Join request approved for “Student User”" +- assertVisible: "Teacher added: “TeacherB User”" +- tapOn: "TeacherB User" +- tapOn: "Change History" +- assertVisible: + id: "app_title" + text: "Change History" +- assertVisible: "Person name changed from “TeacherA User” to “TeacherB User”" # E) Teacher share invite link for parent device based signup - tapOn: "Add student" diff --git a/.maestro/flows/subflows/admin_add_app.yaml b/.maestro/flows/subflows/add_app.yaml similarity index 100% rename from .maestro/flows/subflows/admin_add_app.yaml rename to .maestro/flows/subflows/add_app.yaml diff --git a/.maestro/flows/subflows/admin_add_class.yaml b/.maestro/flows/subflows/add_class.yaml similarity index 100% rename from .maestro/flows/subflows/admin_add_class.yaml rename to .maestro/flows/subflows/add_class.yaml diff --git a/.maestro/flows/subflows/admin_add_student.yaml b/.maestro/flows/subflows/add_student.yaml similarity index 100% rename from .maestro/flows/subflows/admin_add_student.yaml rename to .maestro/flows/subflows/add_student.yaml diff --git a/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt b/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt index 9203819a4..ad08a25c4 100644 --- a/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt +++ b/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt @@ -235,6 +235,7 @@ import world.respect.shared.viewmodel.person.setusernameandpassword.CreateAccoun import world.respect.shared.viewmodel.person.setusernameandpassword.CreateAccountSetUserNameViewModel import world.respect.shared.viewmodel.schooldirectory.edit.SchoolDirectoryEditViewModel import world.respect.shared.viewmodel.schooldirectory.list.SchoolDirectoryListViewModel +import world.respect.shared.viewmodel.apps.changehistory.ChangeHistoryViewModel import world.respect.shared.domain.sharelink.LaunchSendEmailUseCase import world.respect.shared.domain.sharelink.LaunchShareLinkUseCase import world.respect.shared.domain.sharelink.LaunchSendSmsUseCase @@ -384,6 +385,7 @@ val appKoinModule = module { viewModelOf(::EnrollmentEditViewModel) viewModelOf(::InviteQrViewModel) viewModelOf(::CreateAccountSetPasswordViewModel) + viewModelOf(::ChangeHistoryViewModel) single { diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt index ad87b7675..0e9fa9afd 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt @@ -7,6 +7,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import world.respect.app.view.acknowledgement.AcknowledgementScreen +import world.respect.app.view.apps.changehistory.ChangeHistoryScreen import world.respect.app.view.apps.detail.AppsDetailScreen import world.respect.app.view.apps.enterlink.EnterLinkScreen import world.respect.app.view.apps.launcher.AppLauncherScreen @@ -73,6 +74,7 @@ import world.respect.shared.navigation.ClazzDetail import world.respect.shared.navigation.ClazzEdit import world.respect.shared.navigation.ClazzList import world.respect.shared.navigation.AcceptInvite +import world.respect.shared.navigation.ChangeHistory import world.respect.shared.navigation.CopyCode import world.respect.shared.navigation.CreateAccount import world.respect.shared.navigation.CreateAccountSetPassword @@ -655,6 +657,15 @@ fun AppNavHost( ) ) } + + composable { + ChangeHistoryScreen( + viewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController, + ) + ) + } } } diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/changehistory/ChangeHistoryScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/changehistory/ChangeHistoryScreen.kt new file mode 100644 index 000000000..7c95e5ed1 --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/changehistory/ChangeHistoryScreen.kt @@ -0,0 +1,79 @@ +package world.respect.app.view.apps.changehistory + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.datetime.TimeZone +import org.jetbrains.compose.resources.stringResource +import world.respect.datalayer.db.school.ext.fullName +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.change_format +import world.respect.shared.util.rememberFormattedDateTime +import world.respect.shared.viewmodel.apps.changehistory.ChangeHistoryUiState +import world.respect.shared.viewmodel.apps.changehistory.ChangeHistoryViewModel + +@Composable +fun ChangeHistoryScreen( + viewModel: ChangeHistoryViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + + ChangeHistoryScreen( + uiState = uiState + ) + +} + +@Composable +fun ChangeHistoryScreen( + uiState: ChangeHistoryUiState, +) { + val changeHistoryEntryWithWhoDid = uiState.changeHistoryEntryWithWhoDid ?: return + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + + changeHistoryEntryWithWhoDid.forEach { group -> + + group.changeHistoryEntry.forEach { entry -> + + entry.changes.forEach { change -> + + Text( + text = group.person.fullName(), + ) + val createdAtStr = rememberFormattedDateTime( + timeInMillis = entry.timestamp, + timeZoneId = TimeZone.currentSystemDefault().id, + ) + + Text(text = createdAtStr) + Text( + text = stringResource( + Res.string.change_format, + change.field.displayName, + change.oldVal?:"", + change.newVal + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + + } + } + } + } +} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/detail/PersonDetailScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/detail/PersonDetailScreen.kt index a55a55cfc..256135a91 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/detail/PersonDetailScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/detail/PersonDetailScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Replay import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -30,6 +31,7 @@ import world.respect.shared.generated.resources.email import world.respect.shared.generated.resources.family_members import world.respect.shared.generated.resources.gender import world.respect.shared.generated.resources.manage_account +import world.respect.shared.generated.resources.change_history import world.respect.shared.generated.resources.phone_number import world.respect.shared.generated.resources.role import world.respect.shared.generated.resources.username_label @@ -47,6 +49,7 @@ fun PersonDetailScreen( onClickManageAccount = viewModel::navigateToManageAccount, onClickCreateAccount = viewModel::onClickCreateAccount, onClickPhoneNumber = viewModel::onClickPhoneNumber, + onClickChangeHistoryButton = viewModel::onClickChangeHistoryButton, onClickFamilyMember = viewModel::onClickFamilyMember ) } @@ -56,6 +59,7 @@ fun PersonDetailScreen( uiState: PersonDetailUiState, onClickManageAccount:() -> Unit, onClickCreateAccount: () -> Unit, + onClickChangeHistoryButton: () -> Unit, onClickPhoneNumber: () -> Unit, onClickFamilyMember: (String) -> Unit, ) { @@ -80,6 +84,13 @@ fun PersonDetailScreen( onClick = onClickCreateAccount, ) } + if(uiState.changeHistoryButtonVisible){ + RespectQuickActionButton( + labelText = stringResource(Res.string.change_history), + imageVector = Icons.Default.Replay, + onClick = onClickChangeHistoryButton, + ) + } } HorizontalDivider() diff --git a/respect-datalayer-db/build.gradle.kts b/respect-datalayer-db/build.gradle.kts index 352a1d197..c376aae8c 100644 --- a/respect-datalayer-db/build.gradle.kts +++ b/respect-datalayer-db/build.gradle.kts @@ -35,6 +35,8 @@ kotlin { api(libs.androidx.paging.common) api(libs.androidx.room.paging) implementation(libs.napier) + implementation(libs.koin.core) + implementation(project.dependencies.platform(libs.koin.bom)) } diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabase.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabase.kt index 5f41ee84a..abe2d00ad 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabase.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabase.kt @@ -36,6 +36,7 @@ import world.respect.datalayer.db.school.daos.ReportEntityDao import world.respect.datalayer.db.realm.entities.IndicatorEntity import world.respect.datalayer.db.school.daos.AssignmentEntityDao import world.respect.datalayer.db.school.daos.AssignmentLearningResourceRefEntityDao +import world.respect.datalayer.db.school.daos.ChangeHistoryDao import world.respect.datalayer.db.school.daos.ClassEntityDao import world.respect.datalayer.db.school.daos.ClassPermissionEntityDao import world.respect.datalayer.db.school.daos.EnrollmentEntityDao @@ -57,6 +58,8 @@ import world.respect.datalayer.db.school.entities.ReportEntity import world.respect.datalayer.db.school.entities.SchoolAppEntity import world.respect.datalayer.db.school.entities.WriteQueueItemEntity import world.respect.datalayer.db.school.daos.SchoolPermissionGrantDao +import world.respect.datalayer.db.school.entities.ChangeHistoryChangeEntity +import world.respect.datalayer.db.school.entities.ChangeHistoryEntity import world.respect.datalayer.db.school.entities.ClassPermissionEntity import world.respect.datalayer.db.school.entities.PullSyncStatusEntity import world.respect.datalayer.db.school.entities.SchoolPermissionGrantEntity @@ -105,6 +108,8 @@ import world.respect.datalayer.school.model.Report OpdsGroupEntity::class, OpdsFeedEntity::class, OpdsFeedMetadataEntity::class, + ChangeHistoryEntity::class, + ChangeHistoryChangeEntity::class, ], version = 12, ) @@ -162,6 +167,8 @@ abstract class RespectSchoolDatabase: RoomDatabase() { abstract fun getOpdsGroupEntityDao(): OpdsGroupEntityDao + abstract fun getChangeHistoryDao(): ChangeHistoryDao + companion object { @@ -182,6 +189,7 @@ abstract class RespectSchoolDatabase: RoomDatabase() { ) } + } // The Room compiler generates the `actual` implementations. diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabaseMigrations.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabaseMigrations.kt index 55818d1aa..37285954d 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabaseMigrations.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/RespectSchoolDatabaseMigrations.kt @@ -19,9 +19,39 @@ val MIGRATION_11_12 = object: Migration(11, 12) { } } +val MIGRATE_12_13 = object : Migration(12, 13) { + + override fun migrate(connection: SQLiteConnection) { + + connection.execSQL(""" + CREATE TABLE IF NOT EXISTS `ChangeHistoryEntity` ( + `hGuid` TEXT NOT NULL, + `hGuidHash` INTEGER NOT NULL, + `hTable` TEXT NOT NULL, + `hTimestamp` INTEGER NOT NULL, + `hWhoGuid` TEXT NOT NULL, + `hWhoGuidHash` INTEGER NOT NULL, + `hTableGuid` TEXT NOT NULL, + PRIMARY KEY(`hGuidHash`) + ) + """.trimIndent()) + + connection.execSQL(""" + CREATE TABLE IF NOT EXISTS `ChangeHistoryChangeEntity` ( + `hcId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `hcHistoryGuidHash` INTEGER NOT NULL, + `hcField` TEXT NOT NULL, + `hcOldVal` TEXT, + `hcNewVal` TEXT NOT NULL + ) + """.trimIndent()) + } +} + + fun RoomDatabase.Builder.addCommonMigrations( ): RoomDatabase.Builder { - return this.addMigrations(MIGRATION_11_12) + return this.addMigrations(MIGRATION_11_12,MIGRATE_12_13) } diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt index 2ad81f42a..778afa9e6 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt @@ -7,6 +7,7 @@ import world.respect.datalayer.UidNumberMapper import world.respect.datalayer.db.school.opds.OpdsPublicationDataSourceDb import world.respect.datalayer.db.school.opds.OpdsFeedDataSourceDb import world.respect.datalayer.db.school.AssignmentDatasourceDb +import world.respect.datalayer.db.school.ChangeHistoryDataSourceDb import world.respect.datalayer.db.school.ClassDatasourceDb import world.respect.datalayer.db.school.EnrollmentDataSourceDb import world.respect.datalayer.db.school.GetAuthenticatedPersonUseCase @@ -20,6 +21,7 @@ import world.respect.datalayer.db.school.ReportDataSourceDb import world.respect.datalayer.db.school.SchoolAppDataSourceDb import world.respect.datalayer.db.school.SchoolPermissionGrantDataSourceDb import world.respect.datalayer.school.AssignmentDataSourceLocal +import world.respect.datalayer.school.ChangeHistoryLocal import world.respect.datalayer.school.ClassDataSourceLocal import world.respect.datalayer.school.DummySchoolConfigSettingsDataSource import world.respect.datalayer.school.EnrollmentDataSourceLocal @@ -77,7 +79,7 @@ class SchoolDataSourceDb( } override val personDataSource: PersonDataSourceLocal by lazy { - PersonDataSourceDb(schoolDb, uidNumberMapper, authenticatedUser, checkPersonPermissionUseCase) + PersonDataSourceDb(schoolDb, uidNumberMapper, authenticatedUser, checkPersonPermissionUseCase,changeHistoryDataSource) } override val personPasskeyDataSource: PersonPasskeyDataSourceLocal by lazy { @@ -117,6 +119,11 @@ class SchoolDataSourceDb( AssignmentDatasourceDb(schoolDb, uidNumberMapper, authenticatedUser) } + override val changeHistoryDataSource: ChangeHistoryLocal by lazy { + ChangeHistoryDataSourceDb(schoolDb,uidNumberMapper) + } + + override val opdsPublicationDataSource: OpdsPublicationDataSourceLocal by lazy { OpdsPublicationDataSourceDb( respectSchoolDatabase = schoolDb, diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/ChangeHistoryDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/ChangeHistoryDataSourceDb.kt new file mode 100644 index 000000000..71738e716 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/ChangeHistoryDataSourceDb.kt @@ -0,0 +1,92 @@ +package world.respect.datalayer.db.school + +import androidx.room.Transactor +import androidx.room.useWriterConnection +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.DataReadyState +import world.respect.datalayer.NoDataLoadedState +import world.respect.datalayer.UidNumberMapper +import world.respect.datalayer.db.RespectSchoolDatabase +import world.respect.datalayer.db.school.adapters.toEntities +import world.respect.datalayer.db.school.adapters.toModel +import world.respect.datalayer.school.ChangeHistoryDataSource +import world.respect.datalayer.school.ChangeHistoryLocal +import world.respect.datalayer.school.model.ChangeHistoryEntry +import world.respect.datalayer.shared.paging.IPagingSourceFactory +import world.respect.datalayer.shared.paging.map + +class ChangeHistoryDataSourceDb( + private val schoolDb: RespectSchoolDatabase, + private val uidNumberMapper: UidNumberMapper + ) : ChangeHistoryLocal { + + + + override suspend fun findByGuid( + loadParams: DataLoadParams, + guid: String + ): DataLoadState> { + val result = schoolDb.getChangeHistoryDao().findByGuid(guid) + return if (!result.isNullOrEmpty()) { + DataReadyState(result.map { it.toModel() }) + } else { + NoDataLoadedState.notFound() + } + } + + override fun findByGuidAsFlow( + loadParams: DataLoadParams, + guid: String + ): Flow> { + return schoolDb.getChangeHistoryDao() + .findByGuidAsFlow(guid).map { + it?.let { + DataReadyState(it.toModel()) + } ?: NoDataLoadedState.notFound() + } + } + + override fun listAsPagingSource( + dataLoadParams: DataLoadParams, + getListParams: ChangeHistoryDataSource.GetListParams + ): + IPagingSourceFactory { + + return IPagingSourceFactory { + schoolDb.getChangeHistoryDao() + .listAsPagingSource().map { + it.toModel() + } + } + } + + override suspend fun store(list: List) { + if(list.isEmpty()) + return + + schoolDb.useWriterConnection { con -> + con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + list.forEach { + schoolDb.getChangeHistoryDao().insertHistoryWithChanges( + history = it.toEntities(uidNumberMapper).changeHistoryEntity, + changes = it.toEntities(uidNumberMapper).changeEntities + ) + } + } + } + } + + override suspend fun updateLocal( + list: List, + forceOverwrite: Boolean + ) { + } + + override suspend fun findByUidList(uids: List): List { + TODO("Not yet implemented") + } + +} \ No newline at end of file diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/PersonDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/PersonDataSourceDb.kt index 5f7d33034..85f6921f6 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/PersonDataSourceDb.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/PersonDataSourceDb.kt @@ -12,10 +12,12 @@ import world.respect.datalayer.DataReadyState import world.respect.datalayer.NoDataLoadedState import world.respect.datalayer.UidNumberMapper import world.respect.datalayer.db.RespectSchoolDatabase +import world.respect.datalayer.db.school.adapters.generatePersonChanges import world.respect.datalayer.db.school.adapters.toEntities import world.respect.datalayer.db.school.adapters.toModel import world.respect.datalayer.db.school.adapters.toPersonEntities import world.respect.datalayer.exceptions.ForbiddenException +import world.respect.datalayer.school.ChangeHistoryLocal import world.respect.datalayer.school.PersonDataSource import world.respect.datalayer.school.PersonDataSourceLocal import world.respect.datalayer.school.domain.CheckPersonPermissionUseCase @@ -27,6 +29,7 @@ import world.respect.datalayer.shared.maxLastStoredOrNull import world.respect.datalayer.shared.paging.IPagingSourceFactory import world.respect.datalayer.shared.paging.map import world.respect.libutil.util.time.atStartOfDayInMillisUtc +import world.respect.libutil.util.time.systemTimeInMillis import kotlin.time.Clock class PersonDataSourceDb( @@ -34,7 +37,8 @@ class PersonDataSourceDb( private val uidNumberMapper: UidNumberMapper, private val authenticatedUser: AuthenticatedUserPrincipalId, private val checkPersonPermissionUseCase: CheckPersonPermissionUseCase, -): PersonDataSourceLocal { + private val changeHistoryDataSource: ChangeHistoryLocal, +): PersonDataSourceLocal { private suspend fun doUpsertPerson( @@ -87,7 +91,26 @@ class PersonDataSourceDb( ) { throw ForbiddenException("Authenticated user does not have permission to store ${personToStore.guid}") } + val timeNow = systemTimeInMillis() + val oldPerson = schoolDb.getPersonEntityDao().findByGuidNum( + guidHash = uidNumberMapper(personToStore.guid) + ) + if (oldPerson != null) { + val changeEntries = generatePersonChanges( + hGuid = personToStore.guid, + old = oldPerson.toPersonEntities().toModel(), + new = personToStore, + whoGuid = authenticatedUser.guid, + timestamp = timeNow, + hTableGuid = personToStore.guid + ) + + + if (changeEntries != null) { + changeHistoryDataSource.store(listOf(changeEntries)) + } + } //Check that roles have not been change doUpsertPerson(personToStore) } diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/adapters/ChangeHistoryAdapter.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/adapters/ChangeHistoryAdapter.kt new file mode 100644 index 000000000..7375e7134 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/adapters/ChangeHistoryAdapter.kt @@ -0,0 +1,101 @@ +package world.respect.datalayer.db.school.adapters + +import world.respect.datalayer.UidNumberMapper +import world.respect.datalayer.db.school.entities.ChangeHistoryChangeEntity +import world.respect.datalayer.db.school.entities.ChangeHistoryEntity +import world.respect.datalayer.db.school.entities.ChangeHistoryWithChanges +import world.respect.datalayer.school.model.ChangeHistoryChange +import world.respect.datalayer.school.model.ChangeHistoryEntry +import world.respect.datalayer.school.model.ChangeHistoryFieldEnum +import world.respect.datalayer.school.model.ChangeHistoryTableEnum +import world.respect.datalayer.school.model.Person +import world.respect.datalayer.school.model.findDifference + + +data class ChangeHistoryEntities( + val changeHistoryEntity: ChangeHistoryEntity, + val changeEntities: List +) + + +fun ChangeHistoryWithChanges.toModel(): ChangeHistoryEntry { + return ChangeHistoryEntry( + guid = history.hGuid, + table = history.hTable, + timestamp = history.hTimestamp, + whoGuid = history.hWhoGuid, + tableGuid = history.hTableGuid, + changes = changes.map { it.toModel() } + ) +} + + +fun ChangeHistoryChangeEntity.toModel(): ChangeHistoryChange { + return ChangeHistoryChange( + field = hcField, + oldVal = hcOldVal, + newVal = hcNewVal + ) +} + +fun ChangeHistoryEntry.toEntities( + uidNumberMapper: UidNumberMapper +): ChangeHistoryEntities { + + val guidHash = uidNumberMapper(guid) + + val historyEntity = ChangeHistoryEntity( + hGuid = guid, + hGuidHash = guidHash, + hTable = table, + hTimestamp = timestamp, + hWhoGuid = whoGuid, + hWhoGuidHash = uidNumberMapper(whoGuid), + hTableGuid = tableGuid + ) + + val changeEntities = changes.map { change -> + ChangeHistoryChangeEntity( + hcHistoryGuidHash = guidHash, + hcField = change.field, + hcOldVal = change.oldVal, + hcNewVal = change.newVal + ) + } + + return ChangeHistoryEntities( + changeHistoryEntity = historyEntity, + changeEntities = changeEntities + ) +} +fun generatePersonChanges( + hGuid: String, + old: Person?, + new: Person, + whoGuid: String, + timestamp: Long, + hTableGuid: String +): ChangeHistoryEntry? { + + val changes = mutableListOf() + + findDifference(ChangeHistoryFieldEnum.PERSON_GIVEN_NAME, old?.givenName, new.givenName, changes) + findDifference(ChangeHistoryFieldEnum.PERSON_FAMILY_NAME, old?.familyName, new.familyName, changes) + findDifference(ChangeHistoryFieldEnum.PERSON_MIDDLE_NAME, old?.middleName, new.middleName, changes) + findDifference(ChangeHistoryFieldEnum.PERSON_USERNAME, old?.username, new.username, changes) + findDifference(ChangeHistoryFieldEnum.PERSON_GENDER, old?.gender, new.gender, changes) + findDifference(ChangeHistoryFieldEnum.PERSON_EMAIL, old?.email, new.email, changes) + findDifference(ChangeHistoryFieldEnum.PERSON_PHONE_NUMBER, old?.phoneNumber, new.phoneNumber, changes) + findDifference(ChangeHistoryFieldEnum.PERSON_DATE_OF_BIRTH, old?.dateOfBirth, new.dateOfBirth, changes) + + if (changes.isEmpty()) return null + + return ChangeHistoryEntry( + guid = hGuid, + table = ChangeHistoryTableEnum.PERSON, + timestamp = timestamp, + whoGuid = whoGuid, + changes = changes, + tableGuid = hTableGuid + ) +} \ No newline at end of file diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/ChangeHistoryDao.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/ChangeHistoryDao.kt new file mode 100644 index 000000000..ef4a2362e --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/ChangeHistoryDao.kt @@ -0,0 +1,75 @@ +package world.respect.datalayer.db.school.daos + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import world.respect.datalayer.db.school.entities.ChangeHistoryChangeEntity +import world.respect.datalayer.db.school.entities.ChangeHistoryEntity +import world.respect.datalayer.db.school.entities.ChangeHistoryWithChanges +import world.respect.datalayer.school.model.ChangeHistoryTableEnum + +@Dao +interface ChangeHistoryDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertHistory( + entity: ChangeHistoryEntity + ) + + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertChanges( + entities: List + ) + + @Transaction + suspend fun insertHistoryWithChanges( + history: ChangeHistoryEntity, + changes: List + ) { + insertHistory(history) + insertChanges(changes) + } + + @Query(""" + SELECT * + FROM ChangeHistoryEntity + WHERE (:table IS NULL OR hTable = :table) + AND (:whoGuidHash = 0 OR hWhoGuidHash = :whoGuidHash) + ORDER BY hTimestamp DESC + """) + fun listAsPagingSource( + table: ChangeHistoryTableEnum?, + whoGuidHash: Long, + ): PagingSource + + @Query( + """ + SELECT * + FROM ChangeHistoryEntity + WHERE hTableGuid = :tableGuid + """ + ) + suspend fun findByGuid(tableGuid: String): List? + + + @Query(""" + SELECT * + FROM ChangeHistoryEntity + WHERE hGuid = :guid + """) + fun findByGuidAsFlow(guid: String): Flow + + + @Query(""" + SELECT * + FROM ChangeHistoryEntity + ORDER BY hGuidHash DESC + """) + fun listAsPagingSource(): PagingSource + +} \ No newline at end of file diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/ChangeHistoryChangeEntity.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/ChangeHistoryChangeEntity.kt new file mode 100644 index 000000000..9cc9ba7f5 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/ChangeHistoryChangeEntity.kt @@ -0,0 +1,24 @@ +package world.respect.datalayer.db.school.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import world.respect.datalayer.school.model.ChangeHistoryFieldEnum + +@Entity +data class ChangeHistoryChangeEntity( + + @PrimaryKey(autoGenerate = true) + val hcId: Long = 0, + + val hcHistoryGuidHash: Long, + + val hcField: ChangeHistoryFieldEnum, + + val hcOldVal: String?, + + val hcNewVal: String +){ + companion object{ + const val CHILD_COLUMN = "hcHistoryGuidHash" + } +} \ No newline at end of file diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/ChangeHistoryEntity.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/ChangeHistoryEntity.kt new file mode 100644 index 000000000..0ee0b6e28 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/ChangeHistoryEntity.kt @@ -0,0 +1,21 @@ +package world.respect.datalayer.db.school.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import world.respect.datalayer.school.model.ChangeHistoryTableEnum + +@Entity +data class ChangeHistoryEntity( + val hGuid: String, + @PrimaryKey + val hGuidHash: Long, + val hTable: ChangeHistoryTableEnum, + val hTableGuid: String, + val hTimestamp: Long, + val hWhoGuid: String, + val hWhoGuidHash: Long, +){ + companion object{ + const val PARENT_COLUMN = "hGuidHash" + } +} \ No newline at end of file diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/ChangeHistoryWithChanges.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/ChangeHistoryWithChanges.kt new file mode 100644 index 000000000..66f4e5fe4 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/ChangeHistoryWithChanges.kt @@ -0,0 +1,16 @@ +package world.respect.datalayer.db.school.entities + +import androidx.room.Embedded +import androidx.room.Relation + +data class ChangeHistoryWithChanges( + + @Embedded + val history: ChangeHistoryEntity, + + @Relation( + parentColumn = ChangeHistoryEntity.PARENT_COLUMN, + entityColumn = ChangeHistoryChangeEntity.CHILD_COLUMN + ) + val changes: List +) \ No newline at end of file diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/SchoolDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/SchoolDataSourceHttp.kt index 80b28b01b..ac26fa000 100644 --- a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/SchoolDataSourceHttp.kt +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/SchoolDataSourceHttp.kt @@ -8,6 +8,7 @@ import world.respect.datalayer.SchoolDataSource import world.respect.datalayer.http.school.opds.OpdsPublicationDataSourceHttp import world.respect.datalayer.http.school.opds.OpdsFeedDataSourceHttp import world.respect.datalayer.http.school.AssignmentDataSourceHttp +import world.respect.datalayer.http.school.ChangeHistoryDataSourceHttp import world.respect.datalayer.http.school.ClassDataSourceHttp import world.respect.datalayer.http.school.EnrollmentDataSourceHttp import world.respect.datalayer.http.school.InviteDataSourceHttp @@ -21,6 +22,7 @@ import world.respect.datalayer.networkvalidation.BaseDataSourceValidationHelper import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHelper import world.respect.datalayer.school.opds.OpdsPublicationDataSource import world.respect.datalayer.school.AssignmentDataSource +import world.respect.datalayer.school.ChangeHistoryDataSource import world.respect.datalayer.school.ClassDataSource import world.respect.datalayer.school.DummySchoolConfigSettingsDataSource import world.respect.datalayer.school.EnrollmentDataSource @@ -155,6 +157,16 @@ class SchoolDataSourceHttp( ) } + override val changeHistoryDataSource: ChangeHistoryDataSource by lazy { + ChangeHistoryDataSourceHttp( + schoolUrl = schoolUrl, + schoolDirectoryEntryDataSource = schoolDirectoryEntryDataSource, + httpClient = httpClient, + tokenProvider = tokenProvider, + validationHelper = validationHelper, + ) + } + override val opdsPublicationDataSource: OpdsPublicationDataSource by lazy { OpdsPublicationDataSourceHttp( httpClient = httpClient, diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/ChangeHistoryDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/ChangeHistoryDataSourceHttp.kt new file mode 100644 index 000000000..35748fba6 --- /dev/null +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/ChangeHistoryDataSourceHttp.kt @@ -0,0 +1,113 @@ +package world.respect.datalayer.http.school + +import io.ktor.client.HttpClient +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.contentType +import io.ktor.util.reflect.typeInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import world.respect.datalayer.AuthTokenProvider +import world.respect.datalayer.DataLayerParams +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.ext.firstOrNotLoaded +import world.respect.datalayer.ext.getAsDataLoadState +import world.respect.datalayer.ext.getDataLoadResultAsFlow +import world.respect.datalayer.ext.useTokenProvider +import world.respect.datalayer.ext.useValidationCacheControl +import world.respect.datalayer.http.ext.appendCommonListParams +import world.respect.datalayer.http.ext.appendIfNotNull +import world.respect.datalayer.http.ext.respectEndpointUrl +import world.respect.datalayer.http.shared.paging.OffsetLimitHttpPagingSource +import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHelper +import world.respect.datalayer.school.ChangeHistoryDataSource +import world.respect.datalayer.school.model.ChangeHistoryEntry +import world.respect.datalayer.schooldirectory.SchoolDirectoryEntryDataSource +import world.respect.datalayer.shared.paging.IPagingSourceFactory +import world.respect.datalayer.shared.params.GetListCommonParams + +class ChangeHistoryDataSourceHttp( + override val schoolUrl: Url, + override val schoolDirectoryEntryDataSource: SchoolDirectoryEntryDataSource, + private val httpClient: HttpClient, + private val tokenProvider: AuthTokenProvider, + private val validationHelper: ExtendedDataSourceValidationHelper?, +) : ChangeHistoryDataSource, SchoolUrlBasedDataSource { + + private suspend fun ChangeHistoryDataSource.GetListParams.urlWithParams(): Url { + return URLBuilder(respectEndpointUrl(ChangeHistoryDataSource.ENDPOINT_NAME)) + .apply { + parameters.appendCommonListParams(common) + parameters.appendIfNotNull(DataLayerParams.FILTER_BY_TABLE, filterByTable?.value) + parameters.appendIfNotNull(DataLayerParams.FILTER_BY_WHO_GUID, filterByWhoGuid) + } + .build() + } + + override suspend fun findByGuid( + loadParams: DataLoadParams, + guid: String + ): DataLoadState> { + return httpClient.getAsDataLoadState>( + ChangeHistoryDataSource.GetListParams( + common = GetListCommonParams(guid = guid) + ).urlWithParams() + ) { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + } + } + + override fun findByGuidAsFlow( + loadParams: DataLoadParams, + guid: String + ): Flow> { + return httpClient.getDataLoadResultAsFlow>( + urlFn = { + ChangeHistoryDataSource.GetListParams( + common = GetListCommonParams(guid = guid) + ).urlWithParams() + }, + dataLoadParams = loadParams, + ) { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + }.map { + it.firstOrNotLoaded() + } + } + + + override fun listAsPagingSource( + dataLoadParams: DataLoadParams, + getListParams: ChangeHistoryDataSource.GetListParams + ): IPagingSourceFactory { + return IPagingSourceFactory { + OffsetLimitHttpPagingSource( + baseUrlProvider = { getListParams.urlWithParams() }, + httpClient = httpClient, + validationHelper = validationHelper, + typeInfo = typeInfo>(), + requestBuilder = { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + }, + logPrefixExtra = { "ChangeHistoryDataSource params=$getListParams" } + ) + } + } + + override suspend fun store(list: List) { + httpClient.post( + respectEndpointUrl(ChangeHistoryDataSource.ENDPOINT_NAME) + ) { + useTokenProvider(tokenProvider) + contentType(ContentType.Application.Json) + setBody(list) + } + } +} diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/PersonDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/PersonDataSourceHttp.kt index febbf3288..ce7c1d655 100644 --- a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/PersonDataSourceHttp.kt +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/PersonDataSourceHttp.kt @@ -19,8 +19,8 @@ import world.respect.datalayer.ext.getAsDataLoadState import world.respect.datalayer.ext.getDataLoadResultAsFlow import world.respect.datalayer.ext.useTokenProvider import world.respect.datalayer.ext.useValidationCacheControl -import world.respect.datalayer.http.ext.appendIfNotNull import world.respect.datalayer.http.ext.appendCommonListParams +import world.respect.datalayer.http.ext.appendIfNotNull import world.respect.datalayer.http.ext.respectEndpointUrl import world.respect.datalayer.http.shared.paging.OffsetLimitHttpPagingSource import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHelper diff --git a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/SchoolDataSourceRepository.kt b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/SchoolDataSourceRepository.kt index 280de6156..174f34095 100644 --- a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/SchoolDataSourceRepository.kt +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/SchoolDataSourceRepository.kt @@ -6,6 +6,7 @@ import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHel import world.respect.datalayer.repository.opds.OpdsPublicationDataSourceRepository import world.respect.datalayer.repository.opds.OpdsFeedDataSourceRepository import world.respect.datalayer.repository.school.AssignmentDataSourceRepository +import world.respect.datalayer.repository.school.ChangeHistoryDataSourceRepository import world.respect.datalayer.repository.school.ClassDataSourceRepository import world.respect.datalayer.repository.school.EnrollmentDataSourceRepository import world.respect.datalayer.repository.school.PersonDataSourceRepository @@ -123,6 +124,14 @@ class SchoolDataSourceRepository( validationHelper = validationHelper ) } + override val changeHistoryDataSource: ChangeHistoryDataSourceRepository by lazy { + ChangeHistoryDataSourceRepository( + local = local.changeHistoryDataSource, + remote = remote.changeHistoryDataSource, + validationHelper = validationHelper, + remoteWriteQueue = remoteWriteQueue, + ) + } override val opdsPublicationDataSource: OpdsPublicationDataSource by lazy { OpdsPublicationDataSourceRepository( diff --git a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/ChangeHistoryDataSourceRepository.kt b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/ChangeHistoryDataSourceRepository.kt new file mode 100644 index 000000000..f58d5a9ae --- /dev/null +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/ChangeHistoryDataSourceRepository.kt @@ -0,0 +1,88 @@ +package world.respect.datalayer.repository.school + + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.ext.combineWithRemote +import world.respect.datalayer.ext.updateFromRemoteIfNeeded +import world.respect.datalayer.ext.updateFromRemoteListIfNeeded +import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHelper +import world.respect.datalayer.repository.shared.paging.RepositoryPagingSourceFactory +import world.respect.datalayer.repository.shared.paging.loadAndUpdateLocal2 +import world.respect.datalayer.school.ChangeHistoryDataSource +import world.respect.datalayer.school.ChangeHistoryLocal +import world.respect.datalayer.school.model.ChangeHistoryEntry +import world.respect.datalayer.school.writequeue.RemoteWriteQueue +import world.respect.datalayer.school.writequeue.WriteQueueItem +import world.respect.datalayer.shared.RepositoryModelDataSource +import world.respect.datalayer.shared.paging.IPagingSourceFactory +import world.respect.libutil.util.time.systemTimeInMillis + +class ChangeHistoryDataSourceRepository( + override val local: ChangeHistoryLocal, + override val remote: ChangeHistoryDataSource, + private val validationHelper: ExtendedDataSourceValidationHelper, + private val remoteWriteQueue: RemoteWriteQueue, +) : ChangeHistoryDataSource, RepositoryModelDataSource { + + override suspend fun findByGuid( + loadParams: DataLoadParams, + guid: String + ): DataLoadState>{ + local.updateFromRemoteListIfNeeded( + remoteLoad = remote.findByGuid(loadParams, guid), + validationHelper = validationHelper, + ) + + return local.findByGuid(loadParams, guid) + } + + override fun findByGuidAsFlow( + loadParams: DataLoadParams, + guid: String + ): Flow> { + return local.findByGuidAsFlow(loadParams, guid).combineWithRemote( + remoteFlow = remote.findByGuidAsFlow(loadParams, guid).onEach { + local.updateFromRemoteIfNeeded(it, validationHelper) + } + ) + } + + override fun listAsPagingSource( + dataLoadParams: DataLoadParams, + getListParams: ChangeHistoryDataSource.GetListParams + ): IPagingSourceFactory { + val remote = remote.listAsPagingSource( + dataLoadParams = dataLoadParams, + getListParams = getListParams.copy(common = getListParams.common.copy(includeDeleted = true)), + ).invoke() + + return RepositoryPagingSourceFactory( + local = local.listAsPagingSource(dataLoadParams, getListParams), + onRemoteLoad = { remoteLoadParams -> + remote.loadAndUpdateLocal2( + loadParams = remoteLoadParams, + onUpdateLocalFromRemote = local::updateLocal, + ) + }, + tag = { "ChangeHistoryDataSourceRepo(listParams=$getListParams)" } + ) + } + + + override suspend fun store(list: List) { + local.store(list) + val timeNow = systemTimeInMillis() + remoteWriteQueue.add( + list.map { + WriteQueueItem( + model = WriteQueueItem.Model.CHANGE_HISTORY, + uid = it.guid, + timeQueued = timeNow, + ) + } + ) + } +} diff --git a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/writequeue/DrainRemoteWriteQueueUseCase.kt b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/writequeue/DrainRemoteWriteQueueUseCase.kt index 283e02a20..2aea24224 100644 --- a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/writequeue/DrainRemoteWriteQueueUseCase.kt +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/writequeue/DrainRemoteWriteQueueUseCase.kt @@ -68,6 +68,10 @@ class DrainRemoteWriteQueueUseCase( WriteQueueItem.Model.INVITE -> { repository.inviteDataSource.sendToRemote(listOf(item)) } + WriteQueueItem.Model.CHANGE_HISTORY -> { + repository.changeHistoryDataSource.sendToRemote(listOf(item)) + } + WriteQueueItem.Model.OPDS_FEED -> { val dataLoad = repository.opdsFeedDataSource.local.getByUrl( diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/DataLayerParams.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/DataLayerParams.kt index 828361fb7..226fb786a 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/DataLayerParams.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/DataLayerParams.kt @@ -34,6 +34,8 @@ object DataLayerParams { const val INVITE_REQUIRED = "inviteRequired" const val INVITE_STATUS = "inviteStatus" + const val FILTER_BY_TABLE = "filterByTable" + const val FILTER_BY_WHO_GUID = "filterByWhoGuid" } \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSource.kt index d662ab637..db4df99a7 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSource.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSource.kt @@ -2,6 +2,7 @@ package world.respect.datalayer import world.respect.datalayer.school.opds.OpdsPublicationDataSource import world.respect.datalayer.school.AssignmentDataSource +import world.respect.datalayer.school.ChangeHistoryDataSource import world.respect.datalayer.school.ClassDataSource import world.respect.datalayer.school.EnrollmentDataSource import world.respect.datalayer.school.ReportDataSource @@ -54,4 +55,6 @@ interface SchoolDataSource { val schoolConfigSettingDataSource: SchoolConfigSettingDataSource + val changeHistoryDataSource: ChangeHistoryDataSource + } \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt index ea5276334..95f42162b 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt @@ -2,6 +2,7 @@ package world.respect.datalayer import world.respect.datalayer.school.opds.OpdsPublicationDataSourceLocal import world.respect.datalayer.school.AssignmentDataSourceLocal +import world.respect.datalayer.school.ChangeHistoryLocal import world.respect.datalayer.school.ClassDataSourceLocal import world.respect.datalayer.school.EnrollmentDataSourceLocal import world.respect.datalayer.school.InviteDataSourceLocal @@ -47,4 +48,6 @@ interface SchoolDataSourceLocal: SchoolDataSource { override val opdsFeedDataSource: OpdsFeedDataSourceLocal + + override val changeHistoryDataSource: ChangeHistoryLocal } \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ChangeHistoryDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ChangeHistoryDataSource.kt new file mode 100644 index 000000000..372642b90 --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ChangeHistoryDataSource.kt @@ -0,0 +1,59 @@ +package world.respect.datalayer.school + +import io.ktor.util.StringValues +import kotlinx.coroutines.flow.Flow +import world.respect.datalayer.DataLayerParams +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.school.model.ChangeHistoryEntry +import world.respect.datalayer.school.model.ChangeHistoryTableEnum +import world.respect.datalayer.shared.WritableDataSource +import world.respect.datalayer.shared.paging.IPagingSourceFactory +import world.respect.datalayer.shared.params.GetListCommonParams + +interface ChangeHistoryDataSource : WritableDataSource { + data class GetListParams( + val common: GetListCommonParams = GetListCommonParams(), + val filterByTable: ChangeHistoryTableEnum? = null, + val filterByWhoGuid: String? = null, + val sinceTimestamp: Long? = null, + ) { + + companion object { + + fun fromParams(stringValues: StringValues): GetListParams { + return GetListParams( + common = GetListCommonParams.fromParams(stringValues), + + filterByTable = stringValues[DataLayerParams.FILTER_BY_TABLE]?.let { + ChangeHistoryTableEnum.fromValue(it) + }, + + filterByWhoGuid = stringValues[DataLayerParams.FILTER_BY_WHO_GUID], + ) + } + } + } + + suspend fun findByGuid( + loadParams: DataLoadParams, + guid: String + ): DataLoadState> + + fun findByGuidAsFlow( + loadParams: DataLoadParams, + guid: String + ): Flow> + + fun listAsPagingSource( + dataLoadParams: DataLoadParams, + getListParams: GetListParams + ): IPagingSourceFactory + + companion object { + + const val ENDPOINT_NAME = "changehistory" + + + } +} \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ChangeHistoryLocal.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ChangeHistoryLocal.kt new file mode 100644 index 000000000..fc51e021f --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ChangeHistoryLocal.kt @@ -0,0 +1,6 @@ +package world.respect.datalayer.school + +import world.respect.datalayer.school.model.ChangeHistoryEntry +import world.respect.datalayer.shared.LocalModelDataSource + +interface ChangeHistoryLocal: ChangeHistoryDataSource, LocalModelDataSource diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/ChangeHistoryEntry.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/ChangeHistoryEntry.kt new file mode 100644 index 000000000..97c3d020e --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/ChangeHistoryEntry.kt @@ -0,0 +1,26 @@ +package world.respect.datalayer.school.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ChangeHistoryEntry( + val guid: String, + val tableGuid: String, + val table: ChangeHistoryTableEnum, + val timestamp: Long, + val whoGuid: String, + val changes: List +) + +@Serializable +data class ChangeHistoryEntryWithWhoDid( + val person: Person, + val changeHistoryEntry: List +) + +@Serializable +data class ChangeHistoryChange( + val field: ChangeHistoryFieldEnum, + val newVal: String, + val oldVal: String? +) \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/ChangeHistoryTableEnum.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/ChangeHistoryTableEnum.kt new file mode 100644 index 000000000..1ae765e9b --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/ChangeHistoryTableEnum.kt @@ -0,0 +1,48 @@ +package world.respect.datalayer.school.model + + +enum class ChangeHistoryTableEnum(val value: String) { + + PERSON("person"), + CLASS("class"), + ENROLLMENT("enrollment"); + + companion object { + fun fromValue(value: String) = + entries.first { it.value == value } + } +} +enum class ChangeHistoryFieldEnum( + val value: String, + val displayName: String +) { + + PERSON_GIVEN_NAME("pGivenName", "First Name"), + PERSON_FAMILY_NAME("pFamilyName", "Last Name"), + PERSON_MIDDLE_NAME("pMiddleName", "Middle Name"), + PERSON_USERNAME("pUsername", "Username"), + PERSON_GENDER("pGender", "Gender"), + PERSON_EMAIL("pEmail", "Email"), + PERSON_PHONE_NUMBER("pPhoneNumber", "Phone Number"), + PERSON_DATE_OF_BIRTH("pDateOfBirth", "Date of Birth"), + + CLASS_TITLE("cTitle", "Class Title"), + CLASS_DESCRIPTION("cDescription", "Class Description"), + CLASS_STATUS("cStatus", "Class Status"), + + ENROLLMENT_ROLE("eRole", "Role"), + ENROLLMENT_BEGIN_DATE("eBeginDate", "Start Date"), + ENROLLMENT_END_DATE("eEndDate", "End Date"), + ENROLLMENT_STATUS("eStatus", "Status"); + + companion object { + + fun fromValue(value: String): ChangeHistoryFieldEnum { + return entries.first { it.value == value } + } + + fun fromValueOrNull(value: String): ChangeHistoryFieldEnum? { + return entries.find { it.value == value } + } + } +} \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/findDifference.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/findDifference.kt new file mode 100644 index 000000000..4fee2a81d --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/findDifference.kt @@ -0,0 +1,21 @@ +package world.respect.datalayer.school.model + +fun findDifference( + field: ChangeHistoryFieldEnum, + oldVal: T?, + newVal: T?, + changes: MutableList +) { + val oldString = oldVal?.toString() + val newString = newVal?.toString() + + if (oldString != newString) { + changes.add( + ChangeHistoryChange( + field = field, + oldVal = oldString, + newVal = newString ?: "" + ) + ) + } +} \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/writequeue/WriteQueueItem.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/writequeue/WriteQueueItem.kt index aae2c9c6e..011034670 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/writequeue/WriteQueueItem.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/writequeue/WriteQueueItem.kt @@ -26,6 +26,7 @@ class WriteQueueItem( PERSON_QRBADGE(8), INVITE(9), OPDS_FEED(10), + CHANGE_HISTORY(10), ; diff --git a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml index 52b9cb015..25255ec4b 100644 --- a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml +++ b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml @@ -110,6 +110,7 @@ Email Phone number Create account + Change history Username QR Code Badge Manage account @@ -569,5 +570,5 @@ Supported by Spix Foundation. Select Host - + %1$s changed from "%2$s" to "%3$s" diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt index 2fd39609b..f653abcb7 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt @@ -12,6 +12,7 @@ import world.respect.datalayer.school.model.EnrollmentRoleEnum import world.respect.datalayer.school.model.Person import world.respect.shared.domain.account.invite.RespectRedeemInviteRequest import world.respect.datalayer.school.model.PersonRoleEnum +import world.respect.datalayer.school.model.ChangeHistoryTableEnum import world.respect.datalayer.school.model.report.ReportFilter import world.respect.shared.ext.NextAfterScan import world.respect.shared.viewmodel.curriculum.mapping.model.CurriculumMapping @@ -866,3 +867,27 @@ data class QrCode( data class CopyCode( val inviteCode:String?=null ): RespectAppRoute + +@Serializable +data class ChangeHistory( + val guid: String, + val table : String, +) : RespectAppRoute { + + @Transient + val tableValue = ChangeHistoryTableEnum.fromValue(table) + + + companion object { + fun create( + guidStr: String, + tableEnum: ChangeHistoryTableEnum, + ) : ChangeHistory { + return ChangeHistory( + guid = guidStr, + table = tableEnum.value, + ) + } + } + +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/changehistory/ChangeHistoryViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/changehistory/ChangeHistoryViewModel.kt new file mode 100644 index 000000000..a745b2be0 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/changehistory/ChangeHistoryViewModel.kt @@ -0,0 +1,104 @@ +package world.respect.shared.viewmodel.apps.changehistory + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinScopeComponent +import org.koin.core.component.inject +import org.koin.core.scope.Scope +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.model.ChangeHistoryEntryWithWhoDid +import world.respect.datalayer.school.model.ChangeHistoryTableEnum +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.change_history +import world.respect.shared.navigation.ChangeHistory +import world.respect.shared.util.ext.asUiText +import world.respect.shared.viewmodel.RespectViewModel +import world.respect.shared.viewmodel.app.appstate.FabUiState +import kotlin.getValue + + +data class ChangeHistoryUiState( + val guid: String = "", + val changeHistoryEntryWithWhoDid: List? = null, +) + +class ChangeHistoryViewModel( + savedStateHandle: SavedStateHandle, + accountManager: RespectAccountManager +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + private val schoolDataSource: SchoolDataSource by inject() + + override val scope: Scope = accountManager.requireActiveAccountScope() + + private val route: ChangeHistory = savedStateHandle.toRoute() + + private val _uiState = MutableStateFlow( + ChangeHistoryUiState( + guid = route.guid + ) + ) + + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + when (route.tableValue) { + ChangeHistoryTableEnum.PERSON -> { + + val changeHistoryList = schoolDataSource.changeHistoryDataSource + .findByGuid( + loadParams = DataLoadParams(), + guid = route.guid, + ).dataOrNull() ?: return@launch + + val whoGuids = changeHistoryList.groupBy { it.whoGuid } + + val resultList = whoGuids.mapNotNull { (whoGuid, entries) -> + + val person = schoolDataSource.personDataSource.findByGuid( + loadParams = DataLoadParams(), + guid = whoGuid, + ).dataOrNull() + + person?.let { + ChangeHistoryEntryWithWhoDid( + person = it, + changeHistoryEntry = entries + ) + } + } + + _uiState.update { prev -> + prev.copy( + changeHistoryEntryWithWhoDid = resultList + ) + } + } + + ChangeHistoryTableEnum.CLASS -> { + + } + + ChangeHistoryTableEnum.ENROLLMENT -> { + + } + } + + } + _appUiState.update { prev -> + prev.copy( + title = Res.string.change_history.asUiText(), + fabState = FabUiState() + ) + } + } + +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/detail/PersonDetailViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/detail/PersonDetailViewModel.kt index 677581a42..708129493 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/detail/PersonDetailViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/detail/PersonDetailViewModel.kt @@ -15,33 +15,35 @@ import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataLoadState import world.respect.datalayer.DataLoadingState import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.db.school.ext.fullName +import world.respect.datalayer.db.school.ext.isAdmin +import world.respect.datalayer.db.school.ext.isAdminOrTeacher import world.respect.datalayer.ext.dataOrNull import world.respect.datalayer.school.PersonDataSource +import world.respect.datalayer.school.domain.CheckPersonPermissionUseCase +import world.respect.datalayer.school.model.ChangeHistoryTableEnum import world.respect.datalayer.school.model.Person import world.respect.datalayer.shared.params.GetListCommonParams import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.domain.phonenumber.OnClickPhoneNumUseCase import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.edit +import world.respect.shared.navigation.ChangeHistory +import world.respect.shared.navigation.CreateAccountSetUsername import world.respect.shared.navigation.ManageAccount import world.respect.shared.navigation.NavCommand import world.respect.shared.navigation.PersonDetail import world.respect.shared.navigation.PersonEdit -import world.respect.shared.navigation.CreateAccountSetUsername import world.respect.shared.util.ext.asUiText -import world.respect.datalayer.db.school.ext.fullName -import world.respect.datalayer.db.school.ext.isAdmin -import world.respect.datalayer.db.school.ext.isAdminOrTeacher -import world.respect.datalayer.school.domain.CheckPersonPermissionUseCase import world.respect.shared.viewmodel.RespectViewModel import world.respect.shared.viewmodel.app.appstate.FabUiState -import kotlin.getValue data class PersonDetailUiState( val guid: String = "", val persons: DataLoadState> = DataLoadingState(), val manageAccountVisible: Boolean = false, val createAccountVisible: Boolean = false, + val changeHistoryButtonVisible: Boolean = false, ) { val person: Person? @@ -118,6 +120,7 @@ class PersonDetailViewModel( prev.copy( persons = persons, manageAccountVisible = hasAccountPermission && personVal?.username != null, + changeHistoryButtonVisible = activeAccount?.person?.isAdmin() == true, createAccountVisible = personVal != null && activeAccount?.person?.isAdminOrTeacher() == true && personVal.username == null, @@ -165,4 +168,15 @@ class PersonDetailViewModel( ) } + fun onClickChangeHistoryButton(){ + _navCommandFlow.tryEmit( + NavCommand.Navigate( + ChangeHistory( + guid = route.guid, + table = ChangeHistoryTableEnum.PERSON.value + ) + ) + ) + } + } \ No newline at end of file diff --git a/respect-server/src/main/kotlin/world/respect/server/Application.kt b/respect-server/src/main/kotlin/world/respect/server/Application.kt index a8b501773..d55be3ebf 100644 --- a/respect-server/src/main/kotlin/world/respect/server/Application.kt +++ b/respect-server/src/main/kotlin/world/respect/server/Application.kt @@ -38,6 +38,7 @@ import world.respect.datalayer.respect.model.SchoolDirectoryEntry import world.respect.libutil.ext.RESPECT_SCHOOL_LINK_SEGMENT import world.respect.libutil.util.throwable.ExceptionWithHttpStatusCode import world.respect.server.logging.LogbackAntiLog +import world.respect.server.routes.ChangeHistoryRoute import world.respect.server.routes.passkey.GetAllActivePasskeysRoute import world.respect.server.routes.passkey.RevokePasskeyRoute import world.respect.server.routes.passkey.VerifySignInWithPasskeyRoute @@ -251,6 +252,7 @@ fun Application.module() { EnrollmentRoute() AssignmentRoute() PersonQrBadgeRoute() + ChangeHistoryRoute() AddChildAccountRoute( addChildAccountUseCase = { it.requireAccountScope().get() } ) diff --git a/respect-server/src/main/kotlin/world/respect/server/routes/ChangeHistoryRoute.kt b/respect-server/src/main/kotlin/world/respect/server/routes/ChangeHistoryRoute.kt new file mode 100644 index 000000000..37514fecc --- /dev/null +++ b/respect-server/src/main/kotlin/world/respect/server/routes/ChangeHistoryRoute.kt @@ -0,0 +1,44 @@ +package world.respect.server.routes + + +import io.ktor.http.HttpHeaders +import io.ktor.server.application.ApplicationCall +import io.ktor.server.response.header +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.school.ChangeHistoryDataSource +import world.respect.server.util.ext.offsetLimitPagingLoadParams +import world.respect.server.util.ext.requireAccountScope +import world.respect.server.util.ext.respondOffsetLimitPaging + +@Suppress("FunctionName") +fun Route.ChangeHistoryRoute( + schoolDataSource: (ApplicationCall) -> SchoolDataSource = { call -> + call.requireAccountScope().get() + }, +) { + get(ChangeHistoryDataSource.ENDPOINT_NAME) { + + val schoolDataSource = schoolDataSource(call) + + call.response.header(HttpHeaders.Vary, HttpHeaders.Authorization) + + val getListParams = ChangeHistoryDataSource.GetListParams.fromParams( + call.request.queryParameters + ) + + val loadParams = call.request.queryParameters.offsetLimitPagingLoadParams() + + call.respondOffsetLimitPaging( + params = loadParams, + pagingSource = schoolDataSource.changeHistoryDataSource + .listAsPagingSource( + DataLoadParams(), + getListParams + ).invoke() + ) + } +} +