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..154863658 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,9 +101,28 @@ onFlowComplete: text: "Apps" - assertVisible: "Apps" - assertVisible: "Assignments" -- assertVisible: "People" +- tapOn: "People" +- tapOn: "TeacherA User" +- tapOn: + id: "floating_action_button" +- tapOn: "First names*" +- runFlow: + file: "subflows/erase_text.yaml" + env: + TEXT: "First names*" +- inputText: "TeacherB" +- tapOn: "Date of birth" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateC} +- 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: @@ -199,6 +218,23 @@ 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"' +- tapOn: "People" +- tapOn: "TeacherB User" +- tapOn: "Change History" +- assertVisible: + id: "app_title" + text: "Change History" +- assertVisible: 'Person name changed from "TeacherA User" to "TeacherB User"' +- back +- back +- tapOn: "Classes" +- tapOn: "New Class" # 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..9f0bc23b7 100644 --- a/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt +++ b/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt @@ -56,6 +56,8 @@ import world.respect.credentials.passkey.request.GetPasskeyProviderInfoUseCase import world.respect.credentials.password.SavePasswordUseCaseAndroidImpl import world.respect.datalayer.AuthTokenProvider import world.respect.datalayer.AuthenticatedUserPrincipalId +import world.respect.datalayer.ChangeHistoryProvider +import world.respect.datalayer.ChangeHistoryMarkSentToServer import world.respect.datalayer.RespectAppDataSource import world.respect.datalayer.SchoolDataSource import world.respect.datalayer.SchoolDataSourceLocal @@ -66,6 +68,8 @@ import world.respect.datalayer.db.RespectSchoolDatabase import world.respect.datalayer.db.SchoolDataSourceDb import world.respect.datalayer.db.addCommonMigrations import world.respect.datalayer.db.networkvalidation.ExtendedDataSourceValidationHelperImpl +import world.respect.datalayer.db.school.ChangeHistoryProviderDb +import world.respect.datalayer.db.school.ChangeHistoryMarkSentToServerDb import world.respect.datalayer.db.school.GetAuthenticatedPersonUseCase import world.respect.datalayer.db.school.domain.CheckPersonPermissionUseCaseDbImpl import world.respect.datalayer.db.school.writequeue.RemoteWriteQueueDbImpl @@ -235,6 +239,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 +389,7 @@ val appKoinModule = module { viewModelOf(::EnrollmentEditViewModel) viewModelOf(::InviteQrViewModel) viewModelOf(::CreateAccountSetPasswordViewModel) + viewModelOf(::ChangeHistoryViewModel) single { @@ -899,7 +905,17 @@ val appKoinModule = module { defaultAppCatalogUrl = RespectBuildConfig.RESPECT_DEFAULT_APPLIST, ) } - + scoped { + ChangeHistoryProviderDb( + schoolDb = get(), + ) + } + scoped { + ChangeHistoryMarkSentToServerDb( + schoolDb = get(), + uidNumberMapper = get(), + ) + } scoped { val schoolUrl = get() val localDs = get() @@ -916,7 +932,9 @@ val appKoinModule = module { defaultAppCatalogUrl = RespectBuildConfig.RESPECT_DEFAULT_APPLIST, opdsFeedValidationHelper = localDs.opdsFeedDataSource, opdsPublicationValidationHelper = localDs.opdsPublicationDataSource - .publicationNetworkValidationHelper + .publicationNetworkValidationHelper, + changeHistoryProvider = get(), + markSentToServer = get(), ), validationHelper = get(), remoteWriteQueue = get(), 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..3a6215402 --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/apps/changehistory/ChangeHistoryScreen.kt @@ -0,0 +1,98 @@ +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.datalayer.school.model.ChangeHistoryFieldEnum +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 = change.lastModified.toEpochMilliseconds(), + timeZoneId = TimeZone.currentSystemDefault().id, + ) + + Text(text = createdAtStr) + val text = when (change.field) { + + ChangeHistoryFieldEnum.JOIN_REQUEST_APPROVED -> + "${change.field.displayName} \"${change.newVal}\"" + + ChangeHistoryFieldEnum.JOIN_REQUEST_REJECTED -> + "${change.field.displayName} \"${change.oldVal}\"" + + ChangeHistoryFieldEnum.CLASS_TEACHER_ADDED, + ChangeHistoryFieldEnum.CLASS_STUDENT_ADDED -> + "${change.field.displayName}: \"${change.newVal}\"" + + ChangeHistoryFieldEnum.CLASS_TEACHER_REMOVED, + ChangeHistoryFieldEnum.CLASS_STUDENT_REMOVED -> + "${change.field.displayName}: \"${change.newVal}\"" + + else -> + stringResource( + Res.string.change_format, + change.field.displayName, + change.oldVal ?: "", + change.newVal + ) + } + + Text(text = text) + 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/clazz/detail/ClazzDetailScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/detail/ClazzDetailScreen.kt index 09f903975..7782cfd2f 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/detail/ClazzDetailScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/clazz/detail/ClazzDetailScreen.kt @@ -59,9 +59,13 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Replay +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import world.respect.app.components.RespectQuickActionButton +import world.respect.shared.generated.resources.change_history import world.respect.shared.generated.resources.manage_enrollments import world.respect.shared.generated.resources.more_options import world.respect.shared.generated.resources.remove_from_class @@ -83,6 +87,7 @@ fun ClazzDetailScreen( onTogglePendingSection = viewModel::onTogglePendingSection, onToggleTeachersSection = viewModel::onToggleTeachersSection, onToggleStudentsSection = viewModel::onToggleStudentsSection, + onClickChangeHistoryButton = viewModel::onClickChangeHistoryButton, onClickRemovePersonFromClass = viewModel::onClickRemovePersonFromClass, onClickManageEnrollments = viewModel::onClickManageEnrollments, onClickPerson = viewModel::onClickPerson, @@ -97,6 +102,7 @@ fun ClazzDetailScreen( onSelectChip: (String) -> Unit, onClickAcceptInvite: (Person) -> Unit, onClickDismissInvite: (Person) -> Unit, + onClickChangeHistoryButton: () -> Unit, onTogglePendingSection: () -> Unit, onToggleTeachersSection: () -> Unit, onToggleStudentsSection: () -> Unit, @@ -141,6 +147,16 @@ fun ClazzDetailScreen( } ) } + item { + HorizontalDivider() + RespectQuickActionButton( + labelText = stringResource(Res.string.change_history), + imageVector = Icons.Default.Replay, + onClick = onClickChangeHistoryButton, + ) + HorizontalDivider() + } + item { RespectListSortHeader( 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..3851f755c 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,11 @@ fun PersonDetailScreen( onClick = onClickCreateAccount, ) } + 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/schemas/world.respect.datalayer.db.RespectSchoolDatabase/13.json b/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/13.json new file mode 100644 index 000000000..7521c2a09 --- /dev/null +++ b/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/13.json @@ -0,0 +1,1838 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "a92de10409147409c00afbdef34ced32", + "entities": [ + { + "tableName": "SchoolAppEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`saUid` TEXT NOT NULL, `saUidNum` INTEGER NOT NULL, `saManifestUrl` TEXT NOT NULL, `saStatus` INTEGER NOT NULL, `saLastModified` INTEGER NOT NULL, `saStored` INTEGER NOT NULL, PRIMARY KEY(`saUidNum`))", + "fields": [ + { + "fieldPath": "saUid", + "columnName": "saUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "saUidNum", + "columnName": "saUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saManifestUrl", + "columnName": "saManifestUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "saStatus", + "columnName": "saStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saLastModified", + "columnName": "saLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saStored", + "columnName": "saStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "saUidNum" + ] + } + }, + { + "tableName": "PersonEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pGuid` TEXT NOT NULL, `pGuidHash` INTEGER NOT NULL, `pActive` INTEGER NOT NULL, `pStatus` INTEGER NOT NULL, `pLastModified` INTEGER NOT NULL, `pStored` INTEGER NOT NULL, `pMetadata` TEXT, `pUsername` TEXT, `pGivenName` TEXT NOT NULL, `pFamilyName` TEXT NOT NULL, `pMiddleName` TEXT, `pGender` INTEGER NOT NULL, `pDateOfBirth` INTEGER, `pEmail` TEXT, `pPhoneNumber` TEXT, PRIMARY KEY(`pGuidHash`))", + "fields": [ + { + "fieldPath": "pGuid", + "columnName": "pGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pGuidHash", + "columnName": "pGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pActive", + "columnName": "pActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pStatus", + "columnName": "pStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pLastModified", + "columnName": "pLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pStored", + "columnName": "pStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pMetadata", + "columnName": "pMetadata", + "affinity": "TEXT" + }, + { + "fieldPath": "pUsername", + "columnName": "pUsername", + "affinity": "TEXT" + }, + { + "fieldPath": "pGivenName", + "columnName": "pGivenName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pFamilyName", + "columnName": "pFamilyName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pMiddleName", + "columnName": "pMiddleName", + "affinity": "TEXT" + }, + { + "fieldPath": "pGender", + "columnName": "pGender", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pDateOfBirth", + "columnName": "pDateOfBirth", + "affinity": "INTEGER" + }, + { + "fieldPath": "pEmail", + "columnName": "pEmail", + "affinity": "TEXT" + }, + { + "fieldPath": "pPhoneNumber", + "columnName": "pPhoneNumber", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pGuidHash" + ] + } + }, + { + "tableName": "PersonRoleEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`prUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `prPersonGuidHash` INTEGER NOT NULL, `prIsPrimaryRole` INTEGER NOT NULL, `prRoleEnum` INTEGER NOT NULL, `prBeginDate` INTEGER, `prEndDate` INTEGER)", + "fields": [ + { + "fieldPath": "prUid", + "columnName": "prUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prPersonGuidHash", + "columnName": "prPersonGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prIsPrimaryRole", + "columnName": "prIsPrimaryRole", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prRoleEnum", + "columnName": "prRoleEnum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prBeginDate", + "columnName": "prBeginDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "prEndDate", + "columnName": "prEndDate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "prUid" + ] + } + }, + { + "tableName": "PersonRelatedPersonEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`prpUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `prpPersonUidNum` INTEGER NOT NULL, `prpOtherPersonUid` TEXT NOT NULL, `prpOtherPersonUidNum` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "prpUid", + "columnName": "prpUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prpPersonUidNum", + "columnName": "prpPersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prpOtherPersonUid", + "columnName": "prpOtherPersonUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prpOtherPersonUidNum", + "columnName": "prpOtherPersonUidNum", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "prpUid" + ] + } + }, + { + "tableName": "PersonPasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ppwGuidNum` INTEGER NOT NULL, `ppwGuid` TEXT NOT NULL, `authAlgorithm` TEXT NOT NULL, `authEncoded` TEXT NOT NULL, `authSalt` TEXT NOT NULL, `authIterations` INTEGER NOT NULL, `authKeyLen` INTEGER NOT NULL, `ppwLastModified` INTEGER NOT NULL, `ppwStored` INTEGER NOT NULL, PRIMARY KEY(`ppwGuidNum`))", + "fields": [ + { + "fieldPath": "ppwGuidNum", + "columnName": "ppwGuidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppwGuid", + "columnName": "ppwGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authAlgorithm", + "columnName": "authAlgorithm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authEncoded", + "columnName": "authEncoded", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authSalt", + "columnName": "authSalt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authIterations", + "columnName": "authIterations", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authKeyLen", + "columnName": "authKeyLen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppwLastModified", + "columnName": "ppwLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppwStored", + "columnName": "ppwStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ppwGuidNum" + ] + } + }, + { + "tableName": "PersonPasskeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ppPersonUidNum` INTEGER NOT NULL, `ppCredentialId` TEXT NOT NULL, `ppLastModified` INTEGER NOT NULL, `ppStored` INTEGER NOT NULL, `ppAttestationObj` TEXT, `ppClientDataJson` TEXT, `ppOriginString` TEXT, `ppChallengeString` TEXT, `ppPublicKey` TEXT, `isRevoked` INTEGER NOT NULL, `ppDeviceName` TEXT NOT NULL DEFAULT '', `ppTimeCreated` INTEGER NOT NULL DEFAULT 0, `ppAaguid` TEXT NOT NULL DEFAULT '', `ppProviderName` TEXT NOT NULL DEFAULT '', `ppIconLight` TEXT NOT NULL DEFAULT '', `ppIconDark` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`ppPersonUidNum`, `ppCredentialId`))", + "fields": [ + { + "fieldPath": "ppPersonUidNum", + "columnName": "ppPersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppCredentialId", + "columnName": "ppCredentialId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ppLastModified", + "columnName": "ppLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppStored", + "columnName": "ppStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppAttestationObj", + "columnName": "ppAttestationObj", + "affinity": "TEXT" + }, + { + "fieldPath": "ppClientDataJson", + "columnName": "ppClientDataJson", + "affinity": "TEXT" + }, + { + "fieldPath": "ppOriginString", + "columnName": "ppOriginString", + "affinity": "TEXT" + }, + { + "fieldPath": "ppChallengeString", + "columnName": "ppChallengeString", + "affinity": "TEXT" + }, + { + "fieldPath": "ppPublicKey", + "columnName": "ppPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "isRevoked", + "columnName": "isRevoked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppDeviceName", + "columnName": "ppDeviceName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "ppTimeCreated", + "columnName": "ppTimeCreated", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ppAaguid", + "columnName": "ppAaguid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "ppProviderName", + "columnName": "ppProviderName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "ppIconLight", + "columnName": "ppIconLight", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "ppIconDark", + "columnName": "ppIconDark", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ppPersonUidNum", + "ppCredentialId" + ] + } + }, + { + "tableName": "AuthTokenEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`atUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `atPGuidHash` INTEGER NOT NULL, `atPGuid` TEXT NOT NULL, `atCode` TEXT, `atToken` TEXT NOT NULL, `atTimeCreated` INTEGER NOT NULL, `atTtl` INTEGER NOT NULL, `atPlatform` TEXT, `atAndroidSdkInt` INTEGER, `atVersion` TEXT, `atManufacturer` TEXT, `atModel` TEXT, `atRam` INTEGER)", + "fields": [ + { + "fieldPath": "atUid", + "columnName": "atUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atPGuidHash", + "columnName": "atPGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atPGuid", + "columnName": "atPGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "atCode", + "columnName": "atCode", + "affinity": "TEXT" + }, + { + "fieldPath": "atToken", + "columnName": "atToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "atTimeCreated", + "columnName": "atTimeCreated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atTtl", + "columnName": "atTtl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atPlatform", + "columnName": "atPlatform", + "affinity": "TEXT" + }, + { + "fieldPath": "atAndroidSdkInt", + "columnName": "atAndroidSdkInt", + "affinity": "INTEGER" + }, + { + "fieldPath": "atVersion", + "columnName": "atVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "atManufacturer", + "columnName": "atManufacturer", + "affinity": "TEXT" + }, + { + "fieldPath": "atModel", + "columnName": "atModel", + "affinity": "TEXT" + }, + { + "fieldPath": "atRam", + "columnName": "atRam", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "atUid" + ] + } + }, + { + "tableName": "ReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rGuid` TEXT NOT NULL, `rOwnerGuid` TEXT NOT NULL, `rTitle` TEXT NOT NULL, `rOptions` TEXT NOT NULL, `rIsTemplate` INTEGER NOT NULL, `rActive` INTEGER NOT NULL, `rLastModified` INTEGER NOT NULL, `rStored` INTEGER NOT NULL, PRIMARY KEY(`rGuid`))", + "fields": [ + { + "fieldPath": "rGuid", + "columnName": "rGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rOwnerGuid", + "columnName": "rOwnerGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rTitle", + "columnName": "rTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rOptions", + "columnName": "rOptions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rIsTemplate", + "columnName": "rIsTemplate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rActive", + "columnName": "rActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rLastModified", + "columnName": "rLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rStored", + "columnName": "rStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "rGuid" + ] + } + }, + { + "tableName": "IndicatorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`indicatorId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `type` TEXT NOT NULL, `sql` TEXT NOT NULL, PRIMARY KEY(`indicatorId`))", + "fields": [ + { + "fieldPath": "indicatorId", + "columnName": "indicatorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sql", + "columnName": "sql", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "indicatorId" + ] + } + }, + { + "tableName": "ClassEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cGuid` TEXT NOT NULL, `cGuidHash` INTEGER NOT NULL, `cTitle` TEXT NOT NULL, `cStatus` INTEGER NOT NULL, `cDescription` TEXT, `cLastModified` INTEGER NOT NULL, `cStored` INTEGER NOT NULL, `cTeacherInviteGuid` TEXT, `cStudentInviteGuid` TEXT, PRIMARY KEY(`cGuidHash`))", + "fields": [ + { + "fieldPath": "cGuid", + "columnName": "cGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cGuidHash", + "columnName": "cGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cTitle", + "columnName": "cTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cStatus", + "columnName": "cStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cDescription", + "columnName": "cDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "cLastModified", + "columnName": "cLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cStored", + "columnName": "cStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cTeacherInviteGuid", + "columnName": "cTeacherInviteGuid", + "affinity": "TEXT" + }, + { + "fieldPath": "cStudentInviteGuid", + "columnName": "cStudentInviteGuid", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "cGuidHash" + ] + } + }, + { + "tableName": "ClassPermissionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cpeId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `cpeClassUidNum` INTEGER NOT NULL, `cpeToEnrollmentRole` INTEGER, `cpePermissions` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "cpeId", + "columnName": "cpeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cpeClassUidNum", + "columnName": "cpeClassUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cpeToEnrollmentRole", + "columnName": "cpeToEnrollmentRole", + "affinity": "INTEGER" + }, + { + "fieldPath": "cpePermissions", + "columnName": "cpePermissions", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cpeId" + ] + } + }, + { + "tableName": "EnrollmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eUid` TEXT NOT NULL, `eUidNum` INTEGER NOT NULL, `eStatus` INTEGER NOT NULL, `eLastModified` INTEGER NOT NULL, `eStored` INTEGER NOT NULL, `eMetadata` TEXT, `eClassUid` TEXT NOT NULL, `eClassUidNum` INTEGER NOT NULL, `ePersonUid` TEXT NOT NULL, `ePersonUidNum` INTEGER NOT NULL, `eRole` INTEGER NOT NULL, `eBeginDate` INTEGER, `eEndDate` INTEGER, `eRemovedAt` INTEGER, `eInviteCode` TEXT, `eApprovedByPersonUidNum` INTEGER NOT NULL, `eApprovedByPersonUid` TEXT, PRIMARY KEY(`eUidNum`))", + "fields": [ + { + "fieldPath": "eUid", + "columnName": "eUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eUidNum", + "columnName": "eUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eStatus", + "columnName": "eStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eLastModified", + "columnName": "eLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eStored", + "columnName": "eStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eMetadata", + "columnName": "eMetadata", + "affinity": "TEXT" + }, + { + "fieldPath": "eClassUid", + "columnName": "eClassUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eClassUidNum", + "columnName": "eClassUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ePersonUid", + "columnName": "ePersonUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ePersonUidNum", + "columnName": "ePersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eRole", + "columnName": "eRole", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eBeginDate", + "columnName": "eBeginDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "eEndDate", + "columnName": "eEndDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "eRemovedAt", + "columnName": "eRemovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "eInviteCode", + "columnName": "eInviteCode", + "affinity": "TEXT" + }, + { + "fieldPath": "eApprovedByPersonUidNum", + "columnName": "eApprovedByPersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eApprovedByPersonUid", + "columnName": "eApprovedByPersonUid", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eUidNum" + ] + } + }, + { + "tableName": "AssignmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`aeUid` TEXT NOT NULL, `aeUidNum` INTEGER NOT NULL, `aeTitle` TEXT NOT NULL, `aeDescription` TEXT NOT NULL, `aeClassUid` TEXT NOT NULL, `aeClassUidNum` INTEGER NOT NULL, `aeDeadline` INTEGER, `aeLastModified` INTEGER NOT NULL, `aeStored` INTEGER NOT NULL, PRIMARY KEY(`aeUidNum`))", + "fields": [ + { + "fieldPath": "aeUid", + "columnName": "aeUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aeUidNum", + "columnName": "aeUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "aeTitle", + "columnName": "aeTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aeDescription", + "columnName": "aeDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aeClassUid", + "columnName": "aeClassUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aeClassUidNum", + "columnName": "aeClassUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "aeDeadline", + "columnName": "aeDeadline", + "affinity": "INTEGER" + }, + { + "fieldPath": "aeLastModified", + "columnName": "aeLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "aeStored", + "columnName": "aeStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "aeUidNum" + ] + } + }, + { + "tableName": "AssignmentLearningResourceRefEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`alrrAeUidNum` INTEGER NOT NULL, `alrrLearningUnitManifestUrlHash` INTEGER NOT NULL, `alrrLearningUnitManifestUrl` TEXT NOT NULL, `alrrAppManifestUrl` TEXT NOT NULL, PRIMARY KEY(`alrrAeUidNum`, `alrrLearningUnitManifestUrlHash`))", + "fields": [ + { + "fieldPath": "alrrAeUidNum", + "columnName": "alrrAeUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alrrLearningUnitManifestUrlHash", + "columnName": "alrrLearningUnitManifestUrlHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alrrLearningUnitManifestUrl", + "columnName": "alrrLearningUnitManifestUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alrrAppManifestUrl", + "columnName": "alrrAppManifestUrl", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "alrrAeUidNum", + "alrrLearningUnitManifestUrlHash" + ] + } + }, + { + "tableName": "WriteQueueItemEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`wqiQueueItemId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `wqiModel` INTEGER NOT NULL, `wqiUid` TEXT NOT NULL, `wqiTimeQueued` INTEGER NOT NULL, `wqiAttemptCount` INTEGER NOT NULL, `wqiTimeWritten` INTEGER NOT NULL, `wqiAccountGuid` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "wqiQueueItemId", + "columnName": "wqiQueueItemId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiModel", + "columnName": "wqiModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiUid", + "columnName": "wqiUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wqiTimeQueued", + "columnName": "wqiTimeQueued", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiAttemptCount", + "columnName": "wqiAttemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiTimeWritten", + "columnName": "wqiTimeWritten", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiAccountGuid", + "columnName": "wqiAccountGuid", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "wqiQueueItemId" + ] + } + }, + { + "tableName": "SchoolPermissionGrantEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`spgUid` TEXT NOT NULL, `spgUidNum` INTEGER NOT NULL, `spgStatusEnum` INTEGER NOT NULL, `spgToRole` INTEGER NOT NULL, `spgPermissions` INTEGER NOT NULL, `spgLastModified` INTEGER NOT NULL, `spgStored` INTEGER NOT NULL, PRIMARY KEY(`spgUidNum`))", + "fields": [ + { + "fieldPath": "spgUid", + "columnName": "spgUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spgUidNum", + "columnName": "spgUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgStatusEnum", + "columnName": "spgStatusEnum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgToRole", + "columnName": "spgToRole", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgPermissions", + "columnName": "spgPermissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgLastModified", + "columnName": "spgLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgStored", + "columnName": "spgStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "spgUidNum" + ] + } + }, + { + "tableName": "PullSyncStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pssAccountPersonUid` TEXT NOT NULL, `pssAccountPersonUidNum` INTEGER NOT NULL, `pssTableId` INTEGER NOT NULL, `pssLastConsistentThrough` INTEGER NOT NULL, `pssPermissionsLastModified` INTEGER NOT NULL, PRIMARY KEY(`pssAccountPersonUid`, `pssTableId`))", + "fields": [ + { + "fieldPath": "pssAccountPersonUid", + "columnName": "pssAccountPersonUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pssAccountPersonUidNum", + "columnName": "pssAccountPersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pssTableId", + "columnName": "pssTableId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pssLastConsistentThrough", + "columnName": "pssLastConsistentThrough", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pssPermissionsLastModified", + "columnName": "pssPermissionsLastModified", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pssAccountPersonUid", + "pssTableId" + ] + } + }, + { + "tableName": "PersonQrBadgeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pqrGuidNum` INTEGER NOT NULL, `pqrGuid` TEXT NOT NULL, `pqrLastModified` INTEGER NOT NULL, `pqrStored` INTEGER NOT NULL, `pqrQrCodeUrl` TEXT, `pqrStatus` INTEGER NOT NULL, PRIMARY KEY(`pqrGuidNum`))", + "fields": [ + { + "fieldPath": "pqrGuidNum", + "columnName": "pqrGuidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pqrGuid", + "columnName": "pqrGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pqrLastModified", + "columnName": "pqrLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pqrStored", + "columnName": "pqrStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pqrQrCodeUrl", + "columnName": "pqrQrCodeUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "pqrStatus", + "columnName": "pqrStatus", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pqrGuidNum" + ] + }, + "indices": [ + { + "name": "index_PersonQrBadgeEntity_pqrQrCodeUrl", + "unique": false, + "columnNames": [ + "pqrQrCodeUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PersonQrBadgeEntity_pqrQrCodeUrl` ON `${TABLE_NAME}` (`pqrQrCodeUrl`)" + } + ] + }, + { + "tableName": "InviteEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`iGuid` TEXT NOT NULL, `iGuidHash` INTEGER NOT NULL, `iCode` TEXT NOT NULL, `iApprovalRequiredAfter` INTEGER NOT NULL, `iLastModified` INTEGER NOT NULL, `iStored` INTEGER NOT NULL, `iStatus` INTEGER NOT NULL, `iNewUserRole` INTEGER, `iNewUserFirstInvite` INTEGER NOT NULL, `iForFamilyOfGuid` TEXT, `iForFamilyOfGuidHash` INTEGER, `iForClassGuid` TEXT, `iForClassName` TEXT, `iInviteMode` INTEGER, `iSchoolName` TEXT, `iForClassGuidHash` INTEGER, `iForClassRole` INTEGER, PRIMARY KEY(`iGuidHash`))", + "fields": [ + { + "fieldPath": "iGuid", + "columnName": "iGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iGuidHash", + "columnName": "iGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iCode", + "columnName": "iCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iApprovalRequiredAfter", + "columnName": "iApprovalRequiredAfter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLastModified", + "columnName": "iLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iStored", + "columnName": "iStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iStatus", + "columnName": "iStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iNewUserRole", + "columnName": "iNewUserRole", + "affinity": "INTEGER" + }, + { + "fieldPath": "iNewUserFirstInvite", + "columnName": "iNewUserFirstInvite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iForFamilyOfGuid", + "columnName": "iForFamilyOfGuid", + "affinity": "TEXT" + }, + { + "fieldPath": "iForFamilyOfGuidHash", + "columnName": "iForFamilyOfGuidHash", + "affinity": "INTEGER" + }, + { + "fieldPath": "iForClassGuid", + "columnName": "iForClassGuid", + "affinity": "TEXT" + }, + { + "fieldPath": "iForClassName", + "columnName": "iForClassName", + "affinity": "TEXT" + }, + { + "fieldPath": "iInviteMode", + "columnName": "iInviteMode", + "affinity": "INTEGER" + }, + { + "fieldPath": "iSchoolName", + "columnName": "iSchoolName", + "affinity": "TEXT" + }, + { + "fieldPath": "iForClassGuidHash", + "columnName": "iForClassGuidHash", + "affinity": "INTEGER" + }, + { + "fieldPath": "iForClassRole", + "columnName": "iForClassRole", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "iGuidHash" + ] + } + }, + { + "tableName": "LangMapEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lmeId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lmeTopParentType` INTEGER NOT NULL, `lmeTopParentUid1` INTEGER NOT NULL, `lmeTopParentUid2` INTEGER NOT NULL, `lmePropType` INTEGER NOT NULL, `lmePropFk` INTEGER NOT NULL, `lmeLang` TEXT NOT NULL, `lmeRegion` TEXT, `lmeValue` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "lmeId", + "columnName": "lmeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmeTopParentType", + "columnName": "lmeTopParentType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmeTopParentUid1", + "columnName": "lmeTopParentUid1", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmeTopParentUid2", + "columnName": "lmeTopParentUid2", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmePropType", + "columnName": "lmePropType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmePropFk", + "columnName": "lmePropFk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmeLang", + "columnName": "lmeLang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lmeRegion", + "columnName": "lmeRegion", + "affinity": "TEXT" + }, + { + "fieldPath": "lmeValue", + "columnName": "lmeValue", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "lmeId" + ] + }, + "indices": [ + { + "name": "index_LangMapEntity_lmeTopParentType_lmeTopParentUid1_lmeTopParentUid2_lmePropType", + "unique": false, + "columnNames": [ + "lmeTopParentType", + "lmeTopParentUid1", + "lmeTopParentUid2", + "lmePropType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LangMapEntity_lmeTopParentType_lmeTopParentUid1_lmeTopParentUid2_lmePropType` ON `${TABLE_NAME}` (`lmeTopParentType`, `lmeTopParentUid1`, `lmeTopParentUid2`, `lmePropType`)" + } + ] + }, + { + "tableName": "ReadiumLinkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rleId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `rleOpdsParentType` INTEGER NOT NULL, `rleOpdsParentUid` INTEGER NOT NULL, `rlePropType` TEXT NOT NULL, `rlePropFk` INTEGER NOT NULL, `rleIndex` INTEGER NOT NULL, `rleHref` TEXT NOT NULL, `rleRel` TEXT, `rleType` TEXT, `rleTitle` TEXT, `rleTemplated` INTEGER, `rleProperties` TEXT, `rleHeight` INTEGER, `rleWidth` INTEGER, `rleSize` INTEGER, `rleBitrate` REAL, `rleDuration` REAL, `rleLanguage` TEXT)", + "fields": [ + { + "fieldPath": "rleId", + "columnName": "rleId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rleOpdsParentType", + "columnName": "rleOpdsParentType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rleOpdsParentUid", + "columnName": "rleOpdsParentUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rlePropType", + "columnName": "rlePropType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rlePropFk", + "columnName": "rlePropFk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rleIndex", + "columnName": "rleIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rleHref", + "columnName": "rleHref", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rleRel", + "columnName": "rleRel", + "affinity": "TEXT" + }, + { + "fieldPath": "rleType", + "columnName": "rleType", + "affinity": "TEXT" + }, + { + "fieldPath": "rleTitle", + "columnName": "rleTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "rleTemplated", + "columnName": "rleTemplated", + "affinity": "INTEGER" + }, + { + "fieldPath": "rleProperties", + "columnName": "rleProperties", + "affinity": "TEXT" + }, + { + "fieldPath": "rleHeight", + "columnName": "rleHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "rleWidth", + "columnName": "rleWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "rleSize", + "columnName": "rleSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "rleBitrate", + "columnName": "rleBitrate", + "affinity": "REAL" + }, + { + "fieldPath": "rleDuration", + "columnName": "rleDuration", + "affinity": "REAL" + }, + { + "fieldPath": "rleLanguage", + "columnName": "rleLanguage", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rleId" + ] + } + }, + { + "tableName": "OpdsPublicationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`opeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opeOfeUid` INTEGER NOT NULL, `opeOgeUid` INTEGER NOT NULL, `opeIndex` INTEGER NOT NULL, `opeUrl` TEXT, `opeUrlHash` INTEGER NOT NULL, `opeLastModified` INTEGER NOT NULL, `opeEtag` TEXT, `opeMdIdentifier` TEXT, `opeMdLanguage` TEXT, `opeMdType` TEXT, `opeMdDescription` TEXT, `opeMdNumberOfPages` INTEGER, `opeMdDuration` REAL)", + "fields": [ + { + "fieldPath": "opeUid", + "columnName": "opeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeOfeUid", + "columnName": "opeOfeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeOgeUid", + "columnName": "opeOgeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeIndex", + "columnName": "opeIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeUrl", + "columnName": "opeUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "opeUrlHash", + "columnName": "opeUrlHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeLastModified", + "columnName": "opeLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeEtag", + "columnName": "opeEtag", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdIdentifier", + "columnName": "opeMdIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdLanguage", + "columnName": "opeMdLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdType", + "columnName": "opeMdType", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdDescription", + "columnName": "opeMdDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdNumberOfPages", + "columnName": "opeMdNumberOfPages", + "affinity": "INTEGER" + }, + { + "fieldPath": "opeMdDuration", + "columnName": "opeMdDuration", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "opeUid" + ] + } + }, + { + "tableName": "ReadiumSubjectEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rseUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `rseStringValue` TEXT, `rseTopParentType` INTEGER NOT NULL, `rseTopParentUid` INTEGER NOT NULL, `rseSubjectSortAs` TEXT, `rseSubjectCode` TEXT, `rseSubjectScheme` TEXT, `rseIndex` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "rseUid", + "columnName": "rseUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rseStringValue", + "columnName": "rseStringValue", + "affinity": "TEXT" + }, + { + "fieldPath": "rseTopParentType", + "columnName": "rseTopParentType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rseTopParentUid", + "columnName": "rseTopParentUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rseSubjectSortAs", + "columnName": "rseSubjectSortAs", + "affinity": "TEXT" + }, + { + "fieldPath": "rseSubjectCode", + "columnName": "rseSubjectCode", + "affinity": "TEXT" + }, + { + "fieldPath": "rseSubjectScheme", + "columnName": "rseSubjectScheme", + "affinity": "TEXT" + }, + { + "fieldPath": "rseIndex", + "columnName": "rseIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rseUid" + ] + } + }, + { + "tableName": "OpdsFacetEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ofaeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ofaeOfeUid` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "ofaeUid", + "columnName": "ofaeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofaeOfeUid", + "columnName": "ofaeOfeUid", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "ofaeUid" + ] + } + }, + { + "tableName": "OpdsGroupEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ogeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ogeOfeUid` INTEGER NOT NULL, `ogeIndex` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "ogeUid", + "columnName": "ogeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ogeOfeUid", + "columnName": "ogeOfeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ogeIndex", + "columnName": "ogeIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "ogeUid" + ] + } + }, + { + "tableName": "OpdsFeedEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ofeUid` INTEGER NOT NULL, `ofeUrl` TEXT NOT NULL, `ofeUrlHash` INTEGER NOT NULL, `ofeLastModified` INTEGER NOT NULL, `ofeLastModifiedHeader` INTEGER NOT NULL, `ofeEtag` TEXT, `ofeStored` INTEGER NOT NULL, PRIMARY KEY(`ofeUid`))", + "fields": [ + { + "fieldPath": "ofeUid", + "columnName": "ofeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofeUrl", + "columnName": "ofeUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ofeUrlHash", + "columnName": "ofeUrlHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofeLastModified", + "columnName": "ofeLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofeLastModifiedHeader", + "columnName": "ofeLastModifiedHeader", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofeEtag", + "columnName": "ofeEtag", + "affinity": "TEXT" + }, + { + "fieldPath": "ofeStored", + "columnName": "ofeStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ofeUid" + ] + } + }, + { + "tableName": "OpdsFeedMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ofmeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ofmeOfeUid` INTEGER NOT NULL, `ofmePropType` INTEGER NOT NULL, `ofmePropFk` INTEGER NOT NULL, `ofmeIdentifier` TEXT, `ofmeType` TEXT, `ofmeTitle` TEXT NOT NULL, `ofmeSubtitle` TEXT, `ofmeModified` INTEGER, `ofmeDescription` TEXT, `ofmeItemsPerPage` INTEGER, `ofmeCurrentPage` INTEGER, `ofmeNumberOfItems` INTEGER)", + "fields": [ + { + "fieldPath": "ofmeUid", + "columnName": "ofmeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofmeOfeUid", + "columnName": "ofmeOfeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofmePropType", + "columnName": "ofmePropType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofmePropFk", + "columnName": "ofmePropFk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofmeIdentifier", + "columnName": "ofmeIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ofmeType", + "columnName": "ofmeType", + "affinity": "TEXT" + }, + { + "fieldPath": "ofmeTitle", + "columnName": "ofmeTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ofmeSubtitle", + "columnName": "ofmeSubtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "ofmeModified", + "columnName": "ofmeModified", + "affinity": "INTEGER" + }, + { + "fieldPath": "ofmeDescription", + "columnName": "ofmeDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "ofmeItemsPerPage", + "columnName": "ofmeItemsPerPage", + "affinity": "INTEGER" + }, + { + "fieldPath": "ofmeCurrentPage", + "columnName": "ofmeCurrentPage", + "affinity": "INTEGER" + }, + { + "fieldPath": "ofmeNumberOfItems", + "columnName": "ofmeNumberOfItems", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "ofmeUid" + ] + } + }, + { + "tableName": "ChangeHistoryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hGuid` TEXT NOT NULL, `hGuidHash` INTEGER NOT NULL, `hTable` TEXT NOT NULL, `hTableGuid` TEXT NOT NULL, `hTimestamp` INTEGER NOT NULL, `hWhoGuid` TEXT NOT NULL, `hWhoGuidHash` INTEGER NOT NULL, PRIMARY KEY(`hGuidHash`))", + "fields": [ + { + "fieldPath": "hGuid", + "columnName": "hGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hGuidHash", + "columnName": "hGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hTable", + "columnName": "hTable", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hTableGuid", + "columnName": "hTableGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hTimestamp", + "columnName": "hTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hWhoGuid", + "columnName": "hWhoGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hWhoGuidHash", + "columnName": "hWhoGuidHash", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hGuidHash" + ] + } + }, + { + "tableName": "ChangeHistoryChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hcId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hcHistoryGuidHash` INTEGER NOT NULL, `hcField` TEXT NOT NULL, `hcOldVal` TEXT, `hcNewVal` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "hcId", + "columnName": "hcId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hcHistoryGuidHash", + "columnName": "hcHistoryGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hcField", + "columnName": "hcField", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hcOldVal", + "columnName": "hcOldVal", + "affinity": "TEXT" + }, + { + "fieldPath": "hcNewVal", + "columnName": "hcNewVal", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "hcId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a92de10409147409c00afbdef34ced32')" + ] + } +} \ No newline at end of file diff --git a/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/14.json b/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/14.json new file mode 100644 index 000000000..373818283 --- /dev/null +++ b/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/14.json @@ -0,0 +1,1862 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "a22bc48539b22227055f09c7a3ac547e", + "entities": [ + { + "tableName": "SchoolAppEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`saUid` TEXT NOT NULL, `saUidNum` INTEGER NOT NULL, `saManifestUrl` TEXT NOT NULL, `saStatus` INTEGER NOT NULL, `saLastModified` INTEGER NOT NULL, `saStored` INTEGER NOT NULL, PRIMARY KEY(`saUidNum`))", + "fields": [ + { + "fieldPath": "saUid", + "columnName": "saUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "saUidNum", + "columnName": "saUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saManifestUrl", + "columnName": "saManifestUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "saStatus", + "columnName": "saStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saLastModified", + "columnName": "saLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saStored", + "columnName": "saStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "saUidNum" + ] + } + }, + { + "tableName": "PersonEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pGuid` TEXT NOT NULL, `pGuidHash` INTEGER NOT NULL, `pActive` INTEGER NOT NULL, `pStatus` INTEGER NOT NULL, `pLastModified` INTEGER NOT NULL, `pStored` INTEGER NOT NULL, `pMetadata` TEXT, `pUsername` TEXT, `pGivenName` TEXT NOT NULL, `pFamilyName` TEXT NOT NULL, `pMiddleName` TEXT, `pGender` INTEGER NOT NULL, `pDateOfBirth` INTEGER, `pEmail` TEXT, `pPhoneNumber` TEXT, PRIMARY KEY(`pGuidHash`))", + "fields": [ + { + "fieldPath": "pGuid", + "columnName": "pGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pGuidHash", + "columnName": "pGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pActive", + "columnName": "pActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pStatus", + "columnName": "pStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pLastModified", + "columnName": "pLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pStored", + "columnName": "pStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pMetadata", + "columnName": "pMetadata", + "affinity": "TEXT" + }, + { + "fieldPath": "pUsername", + "columnName": "pUsername", + "affinity": "TEXT" + }, + { + "fieldPath": "pGivenName", + "columnName": "pGivenName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pFamilyName", + "columnName": "pFamilyName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pMiddleName", + "columnName": "pMiddleName", + "affinity": "TEXT" + }, + { + "fieldPath": "pGender", + "columnName": "pGender", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pDateOfBirth", + "columnName": "pDateOfBirth", + "affinity": "INTEGER" + }, + { + "fieldPath": "pEmail", + "columnName": "pEmail", + "affinity": "TEXT" + }, + { + "fieldPath": "pPhoneNumber", + "columnName": "pPhoneNumber", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pGuidHash" + ] + } + }, + { + "tableName": "PersonRoleEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`prUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `prPersonGuidHash` INTEGER NOT NULL, `prIsPrimaryRole` INTEGER NOT NULL, `prRoleEnum` INTEGER NOT NULL, `prBeginDate` INTEGER, `prEndDate` INTEGER)", + "fields": [ + { + "fieldPath": "prUid", + "columnName": "prUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prPersonGuidHash", + "columnName": "prPersonGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prIsPrimaryRole", + "columnName": "prIsPrimaryRole", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prRoleEnum", + "columnName": "prRoleEnum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prBeginDate", + "columnName": "prBeginDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "prEndDate", + "columnName": "prEndDate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "prUid" + ] + } + }, + { + "tableName": "PersonRelatedPersonEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`prpUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `prpPersonUidNum` INTEGER NOT NULL, `prpOtherPersonUid` TEXT NOT NULL, `prpOtherPersonUidNum` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "prpUid", + "columnName": "prpUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prpPersonUidNum", + "columnName": "prpPersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prpOtherPersonUid", + "columnName": "prpOtherPersonUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prpOtherPersonUidNum", + "columnName": "prpOtherPersonUidNum", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "prpUid" + ] + } + }, + { + "tableName": "PersonPasswordEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ppwGuidNum` INTEGER NOT NULL, `ppwGuid` TEXT NOT NULL, `authAlgorithm` TEXT NOT NULL, `authEncoded` TEXT NOT NULL, `authSalt` TEXT NOT NULL, `authIterations` INTEGER NOT NULL, `authKeyLen` INTEGER NOT NULL, `ppwLastModified` INTEGER NOT NULL, `ppwStored` INTEGER NOT NULL, PRIMARY KEY(`ppwGuidNum`))", + "fields": [ + { + "fieldPath": "ppwGuidNum", + "columnName": "ppwGuidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppwGuid", + "columnName": "ppwGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authAlgorithm", + "columnName": "authAlgorithm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authEncoded", + "columnName": "authEncoded", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authSalt", + "columnName": "authSalt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authIterations", + "columnName": "authIterations", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authKeyLen", + "columnName": "authKeyLen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppwLastModified", + "columnName": "ppwLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppwStored", + "columnName": "ppwStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ppwGuidNum" + ] + } + }, + { + "tableName": "PersonPasskeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ppPersonUidNum` INTEGER NOT NULL, `ppCredentialId` TEXT NOT NULL, `ppLastModified` INTEGER NOT NULL, `ppStored` INTEGER NOT NULL, `ppAttestationObj` TEXT, `ppClientDataJson` TEXT, `ppOriginString` TEXT, `ppChallengeString` TEXT, `ppPublicKey` TEXT, `isRevoked` INTEGER NOT NULL, `ppDeviceName` TEXT NOT NULL DEFAULT '', `ppTimeCreated` INTEGER NOT NULL DEFAULT 0, `ppAaguid` TEXT NOT NULL DEFAULT '', `ppProviderName` TEXT NOT NULL DEFAULT '', `ppIconLight` TEXT NOT NULL DEFAULT '', `ppIconDark` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`ppPersonUidNum`, `ppCredentialId`))", + "fields": [ + { + "fieldPath": "ppPersonUidNum", + "columnName": "ppPersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppCredentialId", + "columnName": "ppCredentialId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ppLastModified", + "columnName": "ppLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppStored", + "columnName": "ppStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppAttestationObj", + "columnName": "ppAttestationObj", + "affinity": "TEXT" + }, + { + "fieldPath": "ppClientDataJson", + "columnName": "ppClientDataJson", + "affinity": "TEXT" + }, + { + "fieldPath": "ppOriginString", + "columnName": "ppOriginString", + "affinity": "TEXT" + }, + { + "fieldPath": "ppChallengeString", + "columnName": "ppChallengeString", + "affinity": "TEXT" + }, + { + "fieldPath": "ppPublicKey", + "columnName": "ppPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "isRevoked", + "columnName": "isRevoked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ppDeviceName", + "columnName": "ppDeviceName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "ppTimeCreated", + "columnName": "ppTimeCreated", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ppAaguid", + "columnName": "ppAaguid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "ppProviderName", + "columnName": "ppProviderName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "ppIconLight", + "columnName": "ppIconLight", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "ppIconDark", + "columnName": "ppIconDark", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ppPersonUidNum", + "ppCredentialId" + ] + } + }, + { + "tableName": "AuthTokenEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`atUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `atPGuidHash` INTEGER NOT NULL, `atPGuid` TEXT NOT NULL, `atCode` TEXT, `atToken` TEXT NOT NULL, `atTimeCreated` INTEGER NOT NULL, `atTtl` INTEGER NOT NULL, `atPlatform` TEXT, `atAndroidSdkInt` INTEGER, `atVersion` TEXT, `atManufacturer` TEXT, `atModel` TEXT, `atRam` INTEGER)", + "fields": [ + { + "fieldPath": "atUid", + "columnName": "atUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atPGuidHash", + "columnName": "atPGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atPGuid", + "columnName": "atPGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "atCode", + "columnName": "atCode", + "affinity": "TEXT" + }, + { + "fieldPath": "atToken", + "columnName": "atToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "atTimeCreated", + "columnName": "atTimeCreated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atTtl", + "columnName": "atTtl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atPlatform", + "columnName": "atPlatform", + "affinity": "TEXT" + }, + { + "fieldPath": "atAndroidSdkInt", + "columnName": "atAndroidSdkInt", + "affinity": "INTEGER" + }, + { + "fieldPath": "atVersion", + "columnName": "atVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "atManufacturer", + "columnName": "atManufacturer", + "affinity": "TEXT" + }, + { + "fieldPath": "atModel", + "columnName": "atModel", + "affinity": "TEXT" + }, + { + "fieldPath": "atRam", + "columnName": "atRam", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "atUid" + ] + } + }, + { + "tableName": "ReportEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rGuid` TEXT NOT NULL, `rOwnerGuid` TEXT NOT NULL, `rTitle` TEXT NOT NULL, `rOptions` TEXT NOT NULL, `rIsTemplate` INTEGER NOT NULL, `rActive` INTEGER NOT NULL, `rLastModified` INTEGER NOT NULL, `rStored` INTEGER NOT NULL, PRIMARY KEY(`rGuid`))", + "fields": [ + { + "fieldPath": "rGuid", + "columnName": "rGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rOwnerGuid", + "columnName": "rOwnerGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rTitle", + "columnName": "rTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rOptions", + "columnName": "rOptions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rIsTemplate", + "columnName": "rIsTemplate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rActive", + "columnName": "rActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rLastModified", + "columnName": "rLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rStored", + "columnName": "rStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "rGuid" + ] + } + }, + { + "tableName": "IndicatorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`indicatorId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `type` TEXT NOT NULL, `sql` TEXT NOT NULL, PRIMARY KEY(`indicatorId`))", + "fields": [ + { + "fieldPath": "indicatorId", + "columnName": "indicatorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sql", + "columnName": "sql", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "indicatorId" + ] + } + }, + { + "tableName": "ClassEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cGuid` TEXT NOT NULL, `cGuidHash` INTEGER NOT NULL, `cTitle` TEXT NOT NULL, `cStatus` INTEGER NOT NULL, `cDescription` TEXT, `cLastModified` INTEGER NOT NULL, `cStored` INTEGER NOT NULL, `cTeacherInviteGuid` TEXT, `cStudentInviteGuid` TEXT, PRIMARY KEY(`cGuidHash`))", + "fields": [ + { + "fieldPath": "cGuid", + "columnName": "cGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cGuidHash", + "columnName": "cGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cTitle", + "columnName": "cTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cStatus", + "columnName": "cStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cDescription", + "columnName": "cDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "cLastModified", + "columnName": "cLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cStored", + "columnName": "cStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cTeacherInviteGuid", + "columnName": "cTeacherInviteGuid", + "affinity": "TEXT" + }, + { + "fieldPath": "cStudentInviteGuid", + "columnName": "cStudentInviteGuid", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "cGuidHash" + ] + } + }, + { + "tableName": "ClassPermissionEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cpeId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `cpeClassUidNum` INTEGER NOT NULL, `cpeToEnrollmentRole` INTEGER, `cpePermissions` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "cpeId", + "columnName": "cpeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cpeClassUidNum", + "columnName": "cpeClassUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cpeToEnrollmentRole", + "columnName": "cpeToEnrollmentRole", + "affinity": "INTEGER" + }, + { + "fieldPath": "cpePermissions", + "columnName": "cpePermissions", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "cpeId" + ] + } + }, + { + "tableName": "EnrollmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eUid` TEXT NOT NULL, `eUidNum` INTEGER NOT NULL, `eStatus` INTEGER NOT NULL, `eLastModified` INTEGER NOT NULL, `eStored` INTEGER NOT NULL, `eMetadata` TEXT, `eClassUid` TEXT NOT NULL, `eClassUidNum` INTEGER NOT NULL, `ePersonUid` TEXT NOT NULL, `ePersonUidNum` INTEGER NOT NULL, `eRole` INTEGER NOT NULL, `eBeginDate` INTEGER, `eEndDate` INTEGER, `eRemovedAt` INTEGER, `eInviteCode` TEXT, `eApprovedByPersonUidNum` INTEGER NOT NULL, `eApprovedByPersonUid` TEXT, PRIMARY KEY(`eUidNum`))", + "fields": [ + { + "fieldPath": "eUid", + "columnName": "eUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eUidNum", + "columnName": "eUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eStatus", + "columnName": "eStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eLastModified", + "columnName": "eLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eStored", + "columnName": "eStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eMetadata", + "columnName": "eMetadata", + "affinity": "TEXT" + }, + { + "fieldPath": "eClassUid", + "columnName": "eClassUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eClassUidNum", + "columnName": "eClassUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ePersonUid", + "columnName": "ePersonUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ePersonUidNum", + "columnName": "ePersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eRole", + "columnName": "eRole", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eBeginDate", + "columnName": "eBeginDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "eEndDate", + "columnName": "eEndDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "eRemovedAt", + "columnName": "eRemovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "eInviteCode", + "columnName": "eInviteCode", + "affinity": "TEXT" + }, + { + "fieldPath": "eApprovedByPersonUidNum", + "columnName": "eApprovedByPersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eApprovedByPersonUid", + "columnName": "eApprovedByPersonUid", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eUidNum" + ] + } + }, + { + "tableName": "AssignmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`aeUid` TEXT NOT NULL, `aeUidNum` INTEGER NOT NULL, `aeTitle` TEXT NOT NULL, `aeDescription` TEXT NOT NULL, `aeClassUid` TEXT NOT NULL, `aeClassUidNum` INTEGER NOT NULL, `aeDeadline` INTEGER, `aeLastModified` INTEGER NOT NULL, `aeStored` INTEGER NOT NULL, PRIMARY KEY(`aeUidNum`))", + "fields": [ + { + "fieldPath": "aeUid", + "columnName": "aeUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aeUidNum", + "columnName": "aeUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "aeTitle", + "columnName": "aeTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aeDescription", + "columnName": "aeDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aeClassUid", + "columnName": "aeClassUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aeClassUidNum", + "columnName": "aeClassUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "aeDeadline", + "columnName": "aeDeadline", + "affinity": "INTEGER" + }, + { + "fieldPath": "aeLastModified", + "columnName": "aeLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "aeStored", + "columnName": "aeStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "aeUidNum" + ] + } + }, + { + "tableName": "AssignmentLearningResourceRefEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`alrrAeUidNum` INTEGER NOT NULL, `alrrLearningUnitManifestUrlHash` INTEGER NOT NULL, `alrrLearningUnitManifestUrl` TEXT NOT NULL, `alrrAppManifestUrl` TEXT NOT NULL, PRIMARY KEY(`alrrAeUidNum`, `alrrLearningUnitManifestUrlHash`))", + "fields": [ + { + "fieldPath": "alrrAeUidNum", + "columnName": "alrrAeUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alrrLearningUnitManifestUrlHash", + "columnName": "alrrLearningUnitManifestUrlHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alrrLearningUnitManifestUrl", + "columnName": "alrrLearningUnitManifestUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alrrAppManifestUrl", + "columnName": "alrrAppManifestUrl", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "alrrAeUidNum", + "alrrLearningUnitManifestUrlHash" + ] + } + }, + { + "tableName": "WriteQueueItemEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`wqiQueueItemId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `wqiModel` INTEGER NOT NULL, `wqiUid` TEXT NOT NULL, `wqiTimeQueued` INTEGER NOT NULL, `wqiAttemptCount` INTEGER NOT NULL, `wqiTimeWritten` INTEGER NOT NULL, `wqiAccountGuid` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "wqiQueueItemId", + "columnName": "wqiQueueItemId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiModel", + "columnName": "wqiModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiUid", + "columnName": "wqiUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wqiTimeQueued", + "columnName": "wqiTimeQueued", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiAttemptCount", + "columnName": "wqiAttemptCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiTimeWritten", + "columnName": "wqiTimeWritten", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wqiAccountGuid", + "columnName": "wqiAccountGuid", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "wqiQueueItemId" + ] + } + }, + { + "tableName": "SchoolPermissionGrantEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`spgUid` TEXT NOT NULL, `spgUidNum` INTEGER NOT NULL, `spgStatusEnum` INTEGER NOT NULL, `spgToRole` INTEGER NOT NULL, `spgPermissions` INTEGER NOT NULL, `spgLastModified` INTEGER NOT NULL, `spgStored` INTEGER NOT NULL, PRIMARY KEY(`spgUidNum`))", + "fields": [ + { + "fieldPath": "spgUid", + "columnName": "spgUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spgUidNum", + "columnName": "spgUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgStatusEnum", + "columnName": "spgStatusEnum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgToRole", + "columnName": "spgToRole", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgPermissions", + "columnName": "spgPermissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgLastModified", + "columnName": "spgLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spgStored", + "columnName": "spgStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "spgUidNum" + ] + } + }, + { + "tableName": "PullSyncStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pssAccountPersonUid` TEXT NOT NULL, `pssAccountPersonUidNum` INTEGER NOT NULL, `pssTableId` INTEGER NOT NULL, `pssLastConsistentThrough` INTEGER NOT NULL, `pssPermissionsLastModified` INTEGER NOT NULL, PRIMARY KEY(`pssAccountPersonUid`, `pssTableId`))", + "fields": [ + { + "fieldPath": "pssAccountPersonUid", + "columnName": "pssAccountPersonUid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pssAccountPersonUidNum", + "columnName": "pssAccountPersonUidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pssTableId", + "columnName": "pssTableId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pssLastConsistentThrough", + "columnName": "pssLastConsistentThrough", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pssPermissionsLastModified", + "columnName": "pssPermissionsLastModified", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pssAccountPersonUid", + "pssTableId" + ] + } + }, + { + "tableName": "PersonQrBadgeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pqrGuidNum` INTEGER NOT NULL, `pqrGuid` TEXT NOT NULL, `pqrLastModified` INTEGER NOT NULL, `pqrStored` INTEGER NOT NULL, `pqrQrCodeUrl` TEXT, `pqrStatus` INTEGER NOT NULL, PRIMARY KEY(`pqrGuidNum`))", + "fields": [ + { + "fieldPath": "pqrGuidNum", + "columnName": "pqrGuidNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pqrGuid", + "columnName": "pqrGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pqrLastModified", + "columnName": "pqrLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pqrStored", + "columnName": "pqrStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pqrQrCodeUrl", + "columnName": "pqrQrCodeUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "pqrStatus", + "columnName": "pqrStatus", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pqrGuidNum" + ] + }, + "indices": [ + { + "name": "index_PersonQrBadgeEntity_pqrQrCodeUrl", + "unique": false, + "columnNames": [ + "pqrQrCodeUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PersonQrBadgeEntity_pqrQrCodeUrl` ON `${TABLE_NAME}` (`pqrQrCodeUrl`)" + } + ] + }, + { + "tableName": "InviteEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`iGuid` TEXT NOT NULL, `iGuidHash` INTEGER NOT NULL, `iCode` TEXT NOT NULL, `iApprovalRequiredAfter` INTEGER NOT NULL, `iLastModified` INTEGER NOT NULL, `iStored` INTEGER NOT NULL, `iStatus` INTEGER NOT NULL, `iNewUserRole` INTEGER, `iNewUserFirstInvite` INTEGER NOT NULL, `iForFamilyOfGuid` TEXT, `iForFamilyOfGuidHash` INTEGER, `iForClassGuid` TEXT, `iForClassName` TEXT, `iInviteMode` INTEGER, `iSchoolName` TEXT, `iForClassGuidHash` INTEGER, `iForClassRole` INTEGER, PRIMARY KEY(`iGuidHash`))", + "fields": [ + { + "fieldPath": "iGuid", + "columnName": "iGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iGuidHash", + "columnName": "iGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iCode", + "columnName": "iCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iApprovalRequiredAfter", + "columnName": "iApprovalRequiredAfter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLastModified", + "columnName": "iLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iStored", + "columnName": "iStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iStatus", + "columnName": "iStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iNewUserRole", + "columnName": "iNewUserRole", + "affinity": "INTEGER" + }, + { + "fieldPath": "iNewUserFirstInvite", + "columnName": "iNewUserFirstInvite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iForFamilyOfGuid", + "columnName": "iForFamilyOfGuid", + "affinity": "TEXT" + }, + { + "fieldPath": "iForFamilyOfGuidHash", + "columnName": "iForFamilyOfGuidHash", + "affinity": "INTEGER" + }, + { + "fieldPath": "iForClassGuid", + "columnName": "iForClassGuid", + "affinity": "TEXT" + }, + { + "fieldPath": "iForClassName", + "columnName": "iForClassName", + "affinity": "TEXT" + }, + { + "fieldPath": "iInviteMode", + "columnName": "iInviteMode", + "affinity": "INTEGER" + }, + { + "fieldPath": "iSchoolName", + "columnName": "iSchoolName", + "affinity": "TEXT" + }, + { + "fieldPath": "iForClassGuidHash", + "columnName": "iForClassGuidHash", + "affinity": "INTEGER" + }, + { + "fieldPath": "iForClassRole", + "columnName": "iForClassRole", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "iGuidHash" + ] + } + }, + { + "tableName": "LangMapEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lmeId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lmeTopParentType` INTEGER NOT NULL, `lmeTopParentUid1` INTEGER NOT NULL, `lmeTopParentUid2` INTEGER NOT NULL, `lmePropType` INTEGER NOT NULL, `lmePropFk` INTEGER NOT NULL, `lmeLang` TEXT NOT NULL, `lmeRegion` TEXT, `lmeValue` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "lmeId", + "columnName": "lmeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmeTopParentType", + "columnName": "lmeTopParentType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmeTopParentUid1", + "columnName": "lmeTopParentUid1", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmeTopParentUid2", + "columnName": "lmeTopParentUid2", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmePropType", + "columnName": "lmePropType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmePropFk", + "columnName": "lmePropFk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lmeLang", + "columnName": "lmeLang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lmeRegion", + "columnName": "lmeRegion", + "affinity": "TEXT" + }, + { + "fieldPath": "lmeValue", + "columnName": "lmeValue", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "lmeId" + ] + }, + "indices": [ + { + "name": "index_LangMapEntity_lmeTopParentType_lmeTopParentUid1_lmeTopParentUid2_lmePropType", + "unique": false, + "columnNames": [ + "lmeTopParentType", + "lmeTopParentUid1", + "lmeTopParentUid2", + "lmePropType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_LangMapEntity_lmeTopParentType_lmeTopParentUid1_lmeTopParentUid2_lmePropType` ON `${TABLE_NAME}` (`lmeTopParentType`, `lmeTopParentUid1`, `lmeTopParentUid2`, `lmePropType`)" + } + ] + }, + { + "tableName": "ReadiumLinkEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rleId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `rleOpdsParentType` INTEGER NOT NULL, `rleOpdsParentUid` INTEGER NOT NULL, `rlePropType` TEXT NOT NULL, `rlePropFk` INTEGER NOT NULL, `rleIndex` INTEGER NOT NULL, `rleHref` TEXT NOT NULL, `rleRel` TEXT, `rleType` TEXT, `rleTitle` TEXT, `rleTemplated` INTEGER, `rleProperties` TEXT, `rleHeight` INTEGER, `rleWidth` INTEGER, `rleSize` INTEGER, `rleBitrate` REAL, `rleDuration` REAL, `rleLanguage` TEXT)", + "fields": [ + { + "fieldPath": "rleId", + "columnName": "rleId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rleOpdsParentType", + "columnName": "rleOpdsParentType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rleOpdsParentUid", + "columnName": "rleOpdsParentUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rlePropType", + "columnName": "rlePropType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rlePropFk", + "columnName": "rlePropFk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rleIndex", + "columnName": "rleIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rleHref", + "columnName": "rleHref", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rleRel", + "columnName": "rleRel", + "affinity": "TEXT" + }, + { + "fieldPath": "rleType", + "columnName": "rleType", + "affinity": "TEXT" + }, + { + "fieldPath": "rleTitle", + "columnName": "rleTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "rleTemplated", + "columnName": "rleTemplated", + "affinity": "INTEGER" + }, + { + "fieldPath": "rleProperties", + "columnName": "rleProperties", + "affinity": "TEXT" + }, + { + "fieldPath": "rleHeight", + "columnName": "rleHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "rleWidth", + "columnName": "rleWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "rleSize", + "columnName": "rleSize", + "affinity": "INTEGER" + }, + { + "fieldPath": "rleBitrate", + "columnName": "rleBitrate", + "affinity": "REAL" + }, + { + "fieldPath": "rleDuration", + "columnName": "rleDuration", + "affinity": "REAL" + }, + { + "fieldPath": "rleLanguage", + "columnName": "rleLanguage", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rleId" + ] + } + }, + { + "tableName": "OpdsPublicationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`opeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `opeOfeUid` INTEGER NOT NULL, `opeOgeUid` INTEGER NOT NULL, `opeIndex` INTEGER NOT NULL, `opeUrl` TEXT, `opeUrlHash` INTEGER NOT NULL, `opeLastModified` INTEGER NOT NULL, `opeEtag` TEXT, `opeMdIdentifier` TEXT, `opeMdLanguage` TEXT, `opeMdType` TEXT, `opeMdDescription` TEXT, `opeMdNumberOfPages` INTEGER, `opeMdDuration` REAL)", + "fields": [ + { + "fieldPath": "opeUid", + "columnName": "opeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeOfeUid", + "columnName": "opeOfeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeOgeUid", + "columnName": "opeOgeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeIndex", + "columnName": "opeIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeUrl", + "columnName": "opeUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "opeUrlHash", + "columnName": "opeUrlHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeLastModified", + "columnName": "opeLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "opeEtag", + "columnName": "opeEtag", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdIdentifier", + "columnName": "opeMdIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdLanguage", + "columnName": "opeMdLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdType", + "columnName": "opeMdType", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdDescription", + "columnName": "opeMdDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "opeMdNumberOfPages", + "columnName": "opeMdNumberOfPages", + "affinity": "INTEGER" + }, + { + "fieldPath": "opeMdDuration", + "columnName": "opeMdDuration", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "opeUid" + ] + } + }, + { + "tableName": "ReadiumSubjectEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rseUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `rseStringValue` TEXT, `rseTopParentType` INTEGER NOT NULL, `rseTopParentUid` INTEGER NOT NULL, `rseSubjectSortAs` TEXT, `rseSubjectCode` TEXT, `rseSubjectScheme` TEXT, `rseIndex` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "rseUid", + "columnName": "rseUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rseStringValue", + "columnName": "rseStringValue", + "affinity": "TEXT" + }, + { + "fieldPath": "rseTopParentType", + "columnName": "rseTopParentType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rseTopParentUid", + "columnName": "rseTopParentUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rseSubjectSortAs", + "columnName": "rseSubjectSortAs", + "affinity": "TEXT" + }, + { + "fieldPath": "rseSubjectCode", + "columnName": "rseSubjectCode", + "affinity": "TEXT" + }, + { + "fieldPath": "rseSubjectScheme", + "columnName": "rseSubjectScheme", + "affinity": "TEXT" + }, + { + "fieldPath": "rseIndex", + "columnName": "rseIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rseUid" + ] + } + }, + { + "tableName": "OpdsFacetEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ofaeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ofaeOfeUid` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "ofaeUid", + "columnName": "ofaeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofaeOfeUid", + "columnName": "ofaeOfeUid", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "ofaeUid" + ] + } + }, + { + "tableName": "OpdsGroupEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ogeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ogeOfeUid` INTEGER NOT NULL, `ogeIndex` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "ogeUid", + "columnName": "ogeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ogeOfeUid", + "columnName": "ogeOfeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ogeIndex", + "columnName": "ogeIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "ogeUid" + ] + } + }, + { + "tableName": "OpdsFeedEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ofeUid` INTEGER NOT NULL, `ofeUrl` TEXT NOT NULL, `ofeUrlHash` INTEGER NOT NULL, `ofeLastModified` INTEGER NOT NULL, `ofeLastModifiedHeader` INTEGER NOT NULL, `ofeEtag` TEXT, `ofeStored` INTEGER NOT NULL, PRIMARY KEY(`ofeUid`))", + "fields": [ + { + "fieldPath": "ofeUid", + "columnName": "ofeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofeUrl", + "columnName": "ofeUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ofeUrlHash", + "columnName": "ofeUrlHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofeLastModified", + "columnName": "ofeLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofeLastModifiedHeader", + "columnName": "ofeLastModifiedHeader", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofeEtag", + "columnName": "ofeEtag", + "affinity": "TEXT" + }, + { + "fieldPath": "ofeStored", + "columnName": "ofeStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ofeUid" + ] + } + }, + { + "tableName": "OpdsFeedMetadataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ofmeUid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `ofmeOfeUid` INTEGER NOT NULL, `ofmePropType` INTEGER NOT NULL, `ofmePropFk` INTEGER NOT NULL, `ofmeIdentifier` TEXT, `ofmeType` TEXT, `ofmeTitle` TEXT NOT NULL, `ofmeSubtitle` TEXT, `ofmeModified` INTEGER, `ofmeDescription` TEXT, `ofmeItemsPerPage` INTEGER, `ofmeCurrentPage` INTEGER, `ofmeNumberOfItems` INTEGER)", + "fields": [ + { + "fieldPath": "ofmeUid", + "columnName": "ofmeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofmeOfeUid", + "columnName": "ofmeOfeUid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofmePropType", + "columnName": "ofmePropType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofmePropFk", + "columnName": "ofmePropFk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ofmeIdentifier", + "columnName": "ofmeIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ofmeType", + "columnName": "ofmeType", + "affinity": "TEXT" + }, + { + "fieldPath": "ofmeTitle", + "columnName": "ofmeTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ofmeSubtitle", + "columnName": "ofmeSubtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "ofmeModified", + "columnName": "ofmeModified", + "affinity": "INTEGER" + }, + { + "fieldPath": "ofmeDescription", + "columnName": "ofmeDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "ofmeItemsPerPage", + "columnName": "ofmeItemsPerPage", + "affinity": "INTEGER" + }, + { + "fieldPath": "ofmeCurrentPage", + "columnName": "ofmeCurrentPage", + "affinity": "INTEGER" + }, + { + "fieldPath": "ofmeNumberOfItems", + "columnName": "ofmeNumberOfItems", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "ofmeUid" + ] + } + }, + { + "tableName": "ChangeHistoryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hGuid` TEXT NOT NULL, `hGuidHash` INTEGER NOT NULL, `hTable` TEXT NOT NULL, `hTableGuid` TEXT NOT NULL, `hWhoGuid` TEXT NOT NULL, `hWhoGuidHash` INTEGER NOT NULL, `hLastModified` INTEGER NOT NULL, `hStored` INTEGER NOT NULL, PRIMARY KEY(`hGuidHash`))", + "fields": [ + { + "fieldPath": "hGuid", + "columnName": "hGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hGuidHash", + "columnName": "hGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hTable", + "columnName": "hTable", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hTableGuid", + "columnName": "hTableGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hWhoGuid", + "columnName": "hWhoGuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hWhoGuidHash", + "columnName": "hWhoGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hLastModified", + "columnName": "hLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hStored", + "columnName": "hStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hGuidHash" + ] + } + }, + { + "tableName": "ChangeHistoryChangeEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`hcId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hcHistoryGuidHash` INTEGER NOT NULL, `hcField` TEXT NOT NULL, `hcOldVal` TEXT, `hcNewVal` TEXT NOT NULL, `hcSynced` INTEGER NOT NULL, `hcLastModified` INTEGER NOT NULL, `hcStored` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "hcId", + "columnName": "hcId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hcHistoryGuidHash", + "columnName": "hcHistoryGuidHash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hcField", + "columnName": "hcField", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hcOldVal", + "columnName": "hcOldVal", + "affinity": "TEXT" + }, + { + "fieldPath": "hcNewVal", + "columnName": "hcNewVal", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hcSynced", + "columnName": "hcSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hcLastModified", + "columnName": "hcLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hcStored", + "columnName": "hcStored", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "hcId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a22bc48539b22227055f09c7a3ac547e')" + ] + } +} \ No newline at end of file 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..233a35bab 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,10 +58,13 @@ 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 import world.respect.datalayer.school.model.Assignment +import world.respect.datalayer.school.model.ChangeHistoryEntry import world.respect.datalayer.school.model.Clazz import world.respect.datalayer.school.model.Enrollment import world.respect.datalayer.school.model.Indicator @@ -105,8 +109,10 @@ import world.respect.datalayer.school.model.Report OpdsGroupEntity::class, OpdsFeedEntity::class, OpdsFeedMetadataEntity::class, + ChangeHistoryEntity::class, + ChangeHistoryChangeEntity::class, ], - version = 12, + version = 14, ) @TypeConverters(SharedConverters::class, SchoolTypeConverters::class, OpdsTypeConverters::class) @ConstructedBy(RespectSchoolDatabaseConstructor::class) @@ -162,6 +168,8 @@ abstract class RespectSchoolDatabase: RoomDatabase() { abstract fun getOpdsGroupEntityDao(): OpdsGroupEntityDao + abstract fun getChangeHistoryDao(): ChangeHistoryDao + companion object { @@ -179,9 +187,11 @@ abstract class RespectSchoolDatabase: RoomDatabase() { OpdsFacetEntity.TABLE_ID, OpdsGroupEntity.TABLE_ID, OpdsFeedEntity.TABLE_ID, + ChangeHistoryEntry.TABLE_ID, ) } + } // 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..a12338d90 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,50 @@ 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, + `hWhoGuid` TEXT NOT NULL, + `hWhoGuidHash` INTEGER NOT NULL, + `hLastModified` INTEGER NOT NULL, + `hStored` 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, + `hcLastModified` INTEGER NOT NULL, + `hcStored` INTEGER NOT NULL, + `hcNewVal` TEXT NOT NULL + ) + """.trimIndent()) + } +} + +val MIGRATE_13_14 = object : Migration(13, 14) { + + override fun migrate(connection: SQLiteConnection) { + connection.execSQL(""" + ALTER TABLE `ChangeHistoryEntity` ADD COLUMN `hSynced` INTEGER NOT NULL DEFAULT 0 + """.trimIndent()) + + } +} fun RoomDatabase.Builder.addCommonMigrations( ): RoomDatabase.Builder { - return this.addMigrations(MIGRATION_11_12) + return this.addMigrations(MIGRATION_11_12,MIGRATE_12_13,MIGRATE_13_14) } 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..cf9de3231 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,14 @@ class SchoolDataSourceDb( } override val personDataSource: PersonDataSourceLocal by lazy { - PersonDataSourceDb(schoolDb, uidNumberMapper, authenticatedUser, checkPersonPermissionUseCase) + PersonDataSourceDb( + schoolDb, + uidNumberMapper, + authenticatedUser, + checkPersonPermissionUseCase, + changeHistoryDataSource, + primaryKeyGenerator + ) } override val personPasskeyDataSource: PersonPasskeyDataSourceLocal by lazy { @@ -102,7 +111,7 @@ class SchoolDataSourceDb( } override val classDataSource: ClassDataSourceLocal by lazy { - ClassDatasourceDb(schoolDb, uidNumberMapper, authenticatedUser) + ClassDatasourceDb(schoolDb, uidNumberMapper, authenticatedUser,changeHistoryDataSource,primaryKeyGenerator) } override val inviteDataSource: InviteDataSourceLocal by lazy { @@ -110,13 +119,18 @@ class SchoolDataSourceDb( } override val enrollmentDataSource: EnrollmentDataSourceLocal by lazy { - EnrollmentDataSourceDb(schoolDb, uidNumberMapper, authenticatedUser) + EnrollmentDataSourceDb(schoolDb, uidNumberMapper, authenticatedUser,changeHistoryDataSource,primaryKeyGenerator) } override val assignmentDataSource: AssignmentDataSourceLocal by lazy { 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..f3be820c0 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/ChangeHistoryDataSourceDb.kt @@ -0,0 +1,148 @@ +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.ChangeHistoryEntities +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 +import kotlin.time.Clock + +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( + guid: String + ): Flow>> { + return schoolDb.getChangeHistoryDao() + .findByGuidAsFlow(guid) + .map { list -> + if (list.isNotEmpty()) { + DataReadyState(list.map { it.toModel() }) + } else { + NoDataLoadedState.notFound() + } + } + } + + override suspend fun list( + loadParams: DataLoadParams, + params: ChangeHistoryDataSource.GetListParams + ): DataLoadState> { + val result = schoolDb.getChangeHistoryDao().findByGuid(params.common.guid?:"") + return if (!result.isNullOrEmpty()) { + DataReadyState(result.map { it.toModel() }) + } else { + NoDataLoadedState.notFound() + } + } + + override fun listAsPagingSource( + dataLoadParams: DataLoadParams, + getListParams: ChangeHistoryDataSource.GetListParams + ): + IPagingSourceFactory { + + return IPagingSourceFactory { + schoolDb.getChangeHistoryDao() + .listAsPagingSource().map { + it.toModel() + } + } + } + + override suspend fun markSentToServer( + changeHistoryEntries: List + ) { + if (changeHistoryEntries.isEmpty()) return + + val historyGuidHashes = changeHistoryEntries + .map { uidNumberMapper(it.guid) } + + if (historyGuidHashes.isEmpty()) return + + schoolDb.getChangeHistoryDao().markByHistoryGuids(historyGuidHashes) + } + + override suspend fun store(list: List) { + if(list.isEmpty()) + return + + schoolDb.useWriterConnection { con -> + con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + list.forEach { + val entities = it.toEntities(uidNumberMapper) + schoolDb.getChangeHistoryDao().insertHistoryWithChanges( + history = entities.changeHistoryEntity, + changes = entities.changeEntities + ) + } + } + } + } + + override suspend fun updateLocal( + list: List, + forceOverwrite: Boolean + ) { + schoolDb.useWriterConnection { con -> + con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + val now = Clock.System.now() + + val toUpdate: List = list + .filter { + forceOverwrite || ( + schoolDb.getChangeHistoryDao().getLastModifiedByUidNum( + uidNum = uidNumberMapper(it.guid) + ) ?: 0L + ) < it.lastModified.toEpochMilliseconds() + } + .map { entry -> + entry.copy(stored = now).toEntities(uidNumberMapper) + } + + val dao = schoolDb.getChangeHistoryDao() + + toUpdate.forEach { item -> + dao.insertHistoryWithChanges( + history = item.changeHistoryEntity, + changes = item.changeEntities + ) + } + } + } + } + + 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/ChangeHistoryMarkSentToServerDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/ChangeHistoryMarkSentToServerDb.kt new file mode 100644 index 000000000..b46ca7c44 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/ChangeHistoryMarkSentToServerDb.kt @@ -0,0 +1,23 @@ +package world.respect.datalayer.db.school + +import world.respect.datalayer.ChangeHistoryMarkSentToServer +import world.respect.datalayer.UidNumberMapper +import world.respect.datalayer.db.RespectSchoolDatabase +import world.respect.datalayer.school.model.ChangeHistoryEntry + +class ChangeHistoryMarkSentToServerDb( + private val schoolDb: RespectSchoolDatabase, + private val uidNumberMapper: UidNumberMapper +) : ChangeHistoryMarkSentToServer { + override suspend fun markSentToServer(changeHistoryEntry: List) { + + if (changeHistoryEntry.isEmpty()) return + + val historyGuidHashes = changeHistoryEntry + .map { uidNumberMapper(it.guid) } + + if (historyGuidHashes.isEmpty()) return + + schoolDb.getChangeHistoryDao().markByHistoryGuids(historyGuidHashes) + } +} \ No newline at end of file diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/ChangeHistoryProviderDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/ChangeHistoryProviderDb.kt new file mode 100644 index 000000000..5e465d001 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/ChangeHistoryProviderDb.kt @@ -0,0 +1,24 @@ +package world.respect.datalayer.db.school + +import world.respect.datalayer.ChangeHistoryProvider +import world.respect.datalayer.db.RespectSchoolDatabase +import world.respect.datalayer.db.school.adapters.toModel +import world.respect.datalayer.school.model.ChangeHistoryEntry +import world.respect.datalayer.school.model.ChangeHistoryTableEnum + +class ChangeHistoryProviderDb( + private val schoolDb: RespectSchoolDatabase, +) : ChangeHistoryProvider { + + override suspend fun getPendingChangeHistoryEntries( + tableId: ChangeHistoryTableEnum, + uids: List + ): List { + + return schoolDb.getChangeHistoryDao().getByTableAndUids( + tableEnum = tableId, + uids = uids + ).map { it.toModel() } + } +} + diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/ClassDatasourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/ClassDatasourceDb.kt index 1051c75fd..0aed34d38 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/ClassDatasourceDb.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/ClassDatasourceDb.kt @@ -12,17 +12,20 @@ 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.generateClassChanges import world.respect.datalayer.db.school.adapters.toClassEntities import world.respect.datalayer.db.school.adapters.toEntities import world.respect.datalayer.db.school.adapters.toModel import world.respect.datalayer.exceptions.ForbiddenException +import world.respect.datalayer.school.ChangeHistoryLocal import world.respect.datalayer.school.ClassDataSource import world.respect.datalayer.school.ClassDataSourceLocal import world.respect.datalayer.school.model.Clazz import world.respect.datalayer.school.model.PermissionFlags import world.respect.datalayer.shared.paging.IPagingSourceFactory import world.respect.datalayer.shared.paging.map -import kotlin.collections.map +import world.respect.lib.primarykeygen.PrimaryKeyGenerator +import world.respect.libutil.util.time.systemTimeInMillis import kotlin.time.Clock class ClassDatasourceDb( @@ -30,7 +33,9 @@ class ClassDatasourceDb( private val uidNumberMapper: UidNumberMapper, @Suppress("unused") private val authenticatedUser: AuthenticatedUserPrincipalId, -) : ClassDataSourceLocal { + private val changeHistoryDataSource: ChangeHistoryLocal, + private val primaryKeyGenerator: PrimaryKeyGenerator, + ) : ClassDataSourceLocal { private suspend fun doUpsertClass( @@ -119,7 +124,26 @@ class ClassDatasourceDb( if(!lastModAndPermissionInDb.hasPermission) throw ForbiddenException() + val timeNow = systemTimeInMillis() + + val oldClazz = schoolDb.getClassEntityDao().findByGuid( + guidHash = uidNumberMapper(clazz.guid) + ) + + if (oldClazz != null) { + val changeEntries = generateClassChanges( + primaryKeyGenerator = primaryKeyGenerator, + old = oldClazz.toClassEntities().toModel(), + new = clazz, + whoGuid = authenticatedUser.guid, + timestamp = timeNow, + hTableGuid = clazz.guid + ) + if (changeEntries != null) { + changeHistoryDataSource.store(listOf(changeEntries)) + } + } doUpsertClass(clazz) } } diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/EnrollmentDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/EnrollmentDataSourceDb.kt index 0f8a4af86..b082aaec9 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/EnrollmentDataSourceDb.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/EnrollmentDataSourceDb.kt @@ -12,8 +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.generateEnrollmentChanges 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.db.school.ext.fullName +import world.respect.datalayer.school.ChangeHistoryLocal import world.respect.datalayer.school.EnrollmentDataSource import world.respect.datalayer.school.EnrollmentDataSourceLocal import world.respect.datalayer.school.model.Enrollment @@ -23,6 +27,7 @@ import world.respect.datalayer.shared.maxLastModifiedOrNull import world.respect.datalayer.shared.maxLastStoredOrNull import world.respect.datalayer.shared.paging.IPagingSourceFactory import world.respect.datalayer.shared.paging.map +import world.respect.lib.primarykeygen.PrimaryKeyGenerator import world.respect.libutil.util.throwable.ForbiddenException import world.respect.libutil.util.time.atStartOfDayInMillisUtc import kotlin.collections.map @@ -34,6 +39,8 @@ class EnrollmentDataSourceDb( private val uidNumberMapper: UidNumberMapper, @Suppress("unused") private val authenticatedUser: AuthenticatedUserPrincipalId, + private val changeHistoryDataSource: ChangeHistoryLocal, + private val primaryKeyGenerator: PrimaryKeyGenerator, ) : EnrollmentDataSourceLocal { private val logPrefix: String by lazy { @@ -126,27 +133,55 @@ class EnrollmentDataSourceDb( } override suspend fun store(list: List) { - if(list.isEmpty()) - return + if (list.isEmpty()) return schoolDb.useWriterConnection { con -> con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { val now = Clock.System.now() + list.forEach { enrollment -> - val classPermissionResult = schoolDb.getClassEntityDao().getLastModifiedAndHasPermission( - authenticatedPersonUidNum = uidNumberMapper(authenticatedUser.guid), - classUidNum = uidNumberMapper(enrollment.classUid), - requiredPermission = when(enrollment.role) { - EnrollmentRoleEnum.STUDENT, EnrollmentRoleEnum.PENDING_STUDENT -> { - PermissionFlags.CLASS_WRITE_STUDENT_ENROLLMENT + + val classPermissionResult = schoolDb.getClassEntityDao() + .getLastModifiedAndHasPermission( + authenticatedPersonUidNum = uidNumberMapper(authenticatedUser.guid), + classUidNum = uidNumberMapper(enrollment.classUid), + requiredPermission = when (enrollment.role) { + EnrollmentRoleEnum.STUDENT, + EnrollmentRoleEnum.PENDING_STUDENT -> + PermissionFlags.CLASS_WRITE_STUDENT_ENROLLMENT + else -> + PermissionFlags.CLASS_WRITE_TEACHER_ENROLLMENT } - else -> PermissionFlags.CLASS_WRITE_TEACHER_ENROLLMENT - } + ) + + if (!enrollment.role.requiresApproval && !classPermissionResult.hasPermission) { + throw ForbiddenException( + "$logPrefix no permission to enrol in class ${enrollment.classUid}" + ) + } + + val existing = schoolDb.getEnrollmentEntityDao().findByGuid( + uidNum = uidNumberMapper(enrollment.uid) + )?.toModel() + + val personName = schoolDb.getPersonEntityDao() + .findByGuidNum(uidNumberMapper(enrollment.personUid)) + ?.toPersonEntities() + ?.toModel()?.fullName() + + val changeEntry = generateEnrollmentChanges( + hGuid = primaryKeyGenerator.nextId(Enrollment.TABLE_ID).toString(), + old = existing, + new = enrollment, + whoGuid = authenticatedUser.guid, + timestamp = now.toEpochMilliseconds(), + hTableGuid = enrollment.classUid, + personName = personName ) - if(!enrollment.role.requiresApproval && !classPermissionResult.hasPermission) - throw ForbiddenException("$logPrefix no permission to enrol in " + - "class ${enrollment.classUid}") + if (changeEntry != null) { + changeHistoryDataSource.store(listOf(changeEntry)) + } schoolDb.getEnrollmentEntityDao().upsert( listOf( @@ -159,7 +194,6 @@ class EnrollmentDataSourceDb( } } } - override suspend fun updateLocal( list: List, forceOverwrite: Boolean 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..27d7b557c 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,21 +12,26 @@ 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 import world.respect.datalayer.school.ext.primaryRole +import world.respect.datalayer.school.model.ChangeHistoryEntry import world.respect.datalayer.school.model.Person import world.respect.datalayer.school.model.composites.PersonListDetails import world.respect.datalayer.shared.maxLastModifiedOrNull import world.respect.datalayer.shared.maxLastStoredOrNull import world.respect.datalayer.shared.paging.IPagingSourceFactory import world.respect.datalayer.shared.paging.map +import world.respect.lib.primarykeygen.PrimaryKeyGenerator import world.respect.libutil.util.time.atStartOfDayInMillisUtc +import world.respect.libutil.util.time.systemTimeInMillis import kotlin.time.Clock class PersonDataSourceDb( @@ -34,7 +39,9 @@ class PersonDataSourceDb( private val uidNumberMapper: UidNumberMapper, private val authenticatedUser: AuthenticatedUserPrincipalId, private val checkPersonPermissionUseCase: CheckPersonPermissionUseCase, -): PersonDataSourceLocal { + private val changeHistoryDataSource: ChangeHistoryLocal, + private val primaryKeyGenerator: PrimaryKeyGenerator +): PersonDataSourceLocal { private suspend fun doUpsertPerson( @@ -87,7 +94,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( + primaryKeyGenerator = primaryKeyGenerator, + 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) } @@ -241,4 +267,6 @@ class PersonDataSourceDb( } } + + } \ No newline at end of file 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..4513d3c3a --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/adapters/ChangeHistoryAdapter.kt @@ -0,0 +1,252 @@ +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.db.school.ext.fullName +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.Clazz +import world.respect.datalayer.school.model.Enrollment +import world.respect.datalayer.school.model.EnrollmentRoleEnum +import world.respect.datalayer.school.model.Person +import world.respect.datalayer.school.model.StatusEnum +import world.respect.datalayer.school.model.findDifference +import world.respect.lib.primarykeygen.PrimaryKeyGenerator +import kotlin.time.Instant + + +data class ChangeHistoryEntities( + val changeHistoryEntity: ChangeHistoryEntity, + val changeEntities: List +) + + +fun ChangeHistoryWithChanges.toModel(): ChangeHistoryEntry { + return ChangeHistoryEntry( + guid = history.hGuid, + table = history.hTable, + whoGuid = history.hWhoGuid, + tableGuid = history.hTableGuid, + lastModified = history.hLastModified, + stored = history.hStored, + changes = changes.map { it.toModel() } + ) +} + + +fun ChangeHistoryChangeEntity.toModel(): ChangeHistoryChange { + return ChangeHistoryChange( + id = hcId, + field = hcField, + oldVal = hcOldVal, + newVal = hcNewVal, + synced = hcSynced, + lastModified = hcLastModified, + stored = hcStored + ) +} + +fun ChangeHistoryEntry.toEntities( + uidNumberMapper: UidNumberMapper +): ChangeHistoryEntities { + + val guidHash = uidNumberMapper(guid) + + val historyEntity = ChangeHistoryEntity( + hGuid = guid, + hGuidHash = guidHash, + hTable = table, + hWhoGuid = whoGuid, + hLastModified = lastModified, + hWhoGuidHash = uidNumberMapper(whoGuid), + hTableGuid = tableGuid + ) + + val changeEntities = changes.map { change -> + ChangeHistoryChangeEntity( + hcId = change.id , + hcHistoryGuidHash = guidHash, + hcField = change.field, + hcOldVal = change.oldVal, + hcLastModified= change.lastModified, + hcNewVal = change.newVal, + hcSynced = change.synced + ) + } + + return ChangeHistoryEntities( + changeHistoryEntity = historyEntity, + changeEntities = changeEntities + ) +} +fun generateClassChanges( + primaryKeyGenerator: PrimaryKeyGenerator, + old: Clazz?, + new: Clazz, + whoGuid: String, + timestamp: Long, + hTableGuid: String +): ChangeHistoryEntry? { + val now = Instant.fromEpochMilliseconds(timestamp) + + val changes = mutableListOf() + + + findDifference(ChangeHistoryFieldEnum.CLASS_TITLE, old?.title, new.title,changes) + findDifference(ChangeHistoryFieldEnum.CLASS_DESCRIPTION, old?.description, new.description,changes) + findDifference(ChangeHistoryFieldEnum.CLASS_STATUS, old?.status, new.status,changes) + + findDifference(ChangeHistoryFieldEnum.CLASS_STATUS, old?.status, new.status, changes) + + if (changes.isEmpty()) return null + + return ChangeHistoryEntry( + guid = primaryKeyGenerator.nextId(Clazz.TABLE_ID).toString(), + table = ChangeHistoryTableEnum.CLASS, + tableGuid = hTableGuid, + whoGuid = whoGuid, + changes = changes, + lastModified = now, + stored = now + ) +} + + +private fun roleChangeField( + oldRole: EnrollmentRoleEnum?, + newRole: EnrollmentRoleEnum +): ChangeHistoryFieldEnum? { + return when (oldRole) { + EnrollmentRoleEnum.PENDING_STUDENT if newRole == EnrollmentRoleEnum.STUDENT -> + ChangeHistoryFieldEnum.JOIN_REQUEST_APPROVED + EnrollmentRoleEnum.PENDING_TEACHER if newRole == EnrollmentRoleEnum.TEACHER -> + ChangeHistoryFieldEnum.JOIN_REQUEST_APPROVED + else -> null + } +} + +fun generateEnrollmentChanges( + hGuid: String, + old: Enrollment?, + new: Enrollment, + whoGuid: String, + timestamp: Long, + hTableGuid: String, + personName: String? +): ChangeHistoryEntry? { + val now = Instant.fromEpochMilliseconds(timestamp) + + val changes = mutableListOf() + val name = personName ?: new.personUid + + if (old == null) { + changes += ChangeHistoryChange( + field = when (new.role) { + EnrollmentRoleEnum.TEACHER -> ChangeHistoryFieldEnum.CLASS_TEACHER_ADDED + EnrollmentRoleEnum.STUDENT -> ChangeHistoryFieldEnum.CLASS_STUDENT_ADDED + else -> ChangeHistoryFieldEnum.CLASS_STUDENT_ADDED + }, + oldVal = null, + newVal = name, + lastModified = now, + stored = now + ) + } else { + + if (new.status == StatusEnum.TO_BE_DELETED && old.status == StatusEnum.ACTIVE) { + changes += ChangeHistoryChange( + field = when (new.role) { + EnrollmentRoleEnum.TEACHER -> ChangeHistoryFieldEnum.CLASS_TEACHER_REMOVED + EnrollmentRoleEnum.STUDENT -> ChangeHistoryFieldEnum.CLASS_STUDENT_REMOVED + else -> ChangeHistoryFieldEnum.CLASS_STUDENT_REMOVED + }, + oldVal = "", + newVal = name, + lastModified = now, + stored = now + ) + + return ChangeHistoryEntry( + guid = hGuid, + table = ChangeHistoryTableEnum.CLASS, + tableGuid = hTableGuid, + whoGuid = whoGuid, + changes = changes, + lastModified = now, + stored = now + ) + } + + roleChangeField(old.role, new.role)?.let { + changes += ChangeHistoryChange( + field = it, + oldVal = name, + newVal = name, + lastModified = now, + stored = now + ) + + return ChangeHistoryEntry( + guid = hGuid, + table = ChangeHistoryTableEnum.CLASS, + tableGuid = hTableGuid, + whoGuid = whoGuid, + changes = changes, + lastModified = now, + stored = now + ) + } + + findDifference(ChangeHistoryFieldEnum.ENROLLMENT_ROLE, old.role, new.role, changes,now) + findDifference(ChangeHistoryFieldEnum.ENROLLMENT_BEGIN_DATE, old.beginDate, new.beginDate, changes) + findDifference(ChangeHistoryFieldEnum.ENROLLMENT_END_DATE, old.endDate, new.endDate, changes) + findDifference(ChangeHistoryFieldEnum.ENROLLMENT_STATUS, old.status, new.status, changes) + } + + if (changes.isEmpty()) return null + + return ChangeHistoryEntry( + guid = hGuid, + table = ChangeHistoryTableEnum.ENROLLMENT, + tableGuid = hTableGuid, + whoGuid = whoGuid, + changes = changes, + lastModified = now, + stored = now + ) +} + +fun generatePersonChanges( + primaryKeyGenerator: PrimaryKeyGenerator, + old: Person?, + new: Person, + whoGuid: String, + timestamp: Long, + hTableGuid: String +): ChangeHistoryEntry? { + val now = Instant.fromEpochMilliseconds(timestamp) + val changes = mutableListOf() + + findDifference(ChangeHistoryFieldEnum.PERSON_GIVEN_NAME, old?.fullName(), new.fullName(), 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 = primaryKeyGenerator.nextId(ChangeHistoryEntry.TABLE_ID).toString(), + table = ChangeHistoryTableEnum.PERSON, + whoGuid = whoGuid, + changes = changes, + tableGuid = hTableGuid, + lastModified = now, + stored = now + ) +} \ 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..2d3ea8ad1 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/ChangeHistoryDao.kt @@ -0,0 +1,125 @@ +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 ChangeHistoryEntity.hLastModified + FROM ChangeHistoryEntity + WHERE ChangeHistoryEntity.hGuidHash = :uidNum + """) + suspend fun getLastModifiedByUidNum(uidNum: Long): Long? + + @Query(""" + SELECT * + FROM ChangeHistoryEntity + WHERE hTable = :tableEnum + AND hTableGuid IN (:uids) + """) + suspend fun getByTableAndUids( + tableEnum: ChangeHistoryTableEnum, + uids: List + ): List + + @Query(""" + SELECT * + FROM ChangeHistoryEntity + WHERE hTable = :tableEnum + AND hTableGuid IN (:uids) + AND hGuidHash IN ( + SELECT hcHistoryGuidHash + FROM ChangeHistoryChangeEntity + WHERE hcSynced = 0 + ) + """) + suspend fun getParentsWithUnsyncedChanges( + tableEnum: ChangeHistoryTableEnum, + uids: List + ): List + @Query(""" + SELECT * + FROM ChangeHistoryEntity + WHERE (:table IS NULL OR hTable = :table) + AND (:whoGuidHash = 0 OR hWhoGuidHash = :whoGuidHash) + ORDER BY hLastModified DESC + """) + fun listAsPagingSource( + table: ChangeHistoryTableEnum?, + whoGuidHash: Long, + ): PagingSource + @Query(""" + SELECT * + FROM ChangeHistoryChangeEntity + WHERE hcHistoryGuidHash IN (:historyIds) + AND hcSynced = 0 + """) + suspend fun getUnsyncedChanges( + historyIds: List + ): List + + @Transaction + @Query( + """ + SELECT * + FROM ChangeHistoryEntity + WHERE hTableGuid = :tableGuid + """ + ) + suspend fun findByGuid(tableGuid: String): List? + + @Query(""" + SELECT * + FROM ChangeHistoryEntity + WHERE hTableGuid = :guid + ORDER BY hLastModified DESC + """) + fun findByGuidAsFlow(guid: String): Flow> + + + @Query(""" + UPDATE ChangeHistoryChangeEntity + SET hcSynced = 1 + WHERE hcHistoryGuidHash IN (:historyGuids) + """) + suspend fun markByHistoryGuids(historyGuids: List) + + @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..2368686dc --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/ChangeHistoryChangeEntity.kt @@ -0,0 +1,30 @@ +package world.respect.datalayer.db.school.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import world.respect.datalayer.school.model.ChangeHistoryFieldEnum +import kotlin.time.Clock +import kotlin.time.Instant + +@Entity +data class ChangeHistoryChangeEntity( + + @PrimaryKey(autoGenerate = true) + val hcId: Long = 0, + + val hcHistoryGuidHash: Long, + + val hcField: ChangeHistoryFieldEnum, + + val hcOldVal: String?, + + val hcNewVal: String, + + val hcSynced: Boolean = false, + val hcLastModified: Instant = Clock.System.now(), + val hcStored: Instant = Clock.System.now(), +){ + 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..c5eabc7e2 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/ChangeHistoryEntity.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.ChangeHistoryTableEnum +import kotlin.time.Clock +import kotlin.time.Instant + +@Entity +data class ChangeHistoryEntity( + val hGuid: String, + @PrimaryKey + val hGuidHash: Long, + val hTable: ChangeHistoryTableEnum, + val hTableGuid: String, + val hWhoGuid: String, + val hWhoGuidHash: Long, + val hLastModified: Instant = Clock.System.now(), + val hStored: Instant = Clock.System.now(), +){ + 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..9fd78a2bf 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 @@ -4,10 +4,13 @@ import io.ktor.client.HttpClient import io.ktor.http.Url import kotlinx.serialization.json.Json import world.respect.datalayer.AuthTokenProvider +import world.respect.datalayer.ChangeHistoryMarkSentToServer +import world.respect.datalayer.ChangeHistoryProvider 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 +24,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 @@ -47,6 +51,8 @@ class SchoolDataSourceHttp( private val defaultAppCatalogUrl: String?, private val opdsFeedValidationHelper: BaseDataSourceValidationHelper? = null, private val opdsPublicationValidationHelper: BaseDataSourceValidationHelper? = null, + private val changeHistoryProvider: ChangeHistoryProvider, + private val markSentToServer: ChangeHistoryMarkSentToServer, ) : SchoolDataSource { override val schoolAppDataSource: SchoolAppDataSource by lazy { @@ -76,6 +82,8 @@ class SchoolDataSourceHttp( httpClient = httpClient, tokenProvider = tokenProvider, validationHelper = validationHelper, + changeHistoryProvider = changeHistoryProvider, + markSentToServer = markSentToServer, ) } @@ -112,6 +120,8 @@ class SchoolDataSourceHttp( httpClient = httpClient, tokenProvider = tokenProvider, validationHelper = validationHelper, + changeHistoryProvider = changeHistoryProvider, + markSentToServer = markSentToServer, ) } @@ -132,6 +142,8 @@ class SchoolDataSourceHttp( httpClient = httpClient, tokenProvider = tokenProvider, validationHelper = validationHelper, + changeHistoryProvider = changeHistoryProvider, + markSentToServer = markSentToServer, ) } @@ -155,6 +167,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..168e64e34 --- /dev/null +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/ChangeHistoryDataSourceHttp.kt @@ -0,0 +1,116 @@ +package world.respect.datalayer.http.school + +import io.ktor.client.HttpClient +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.util.reflect.typeInfo +import kotlinx.coroutines.flow.Flow +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.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( + guid: String + ): Flow>> { + + return httpClient.getDataLoadResultAsFlow>( + urlFn = { + ChangeHistoryDataSource.GetListParams( + common = GetListCommonParams(guid = guid) + ).urlWithParams() + }, + dataLoadParams = DataLoadParams() + ) { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + } + } + + override suspend fun list( + loadParams: DataLoadParams, + params: ChangeHistoryDataSource.GetListParams + ): DataLoadState> { + return httpClient.getAsDataLoadState( + url = params.urlWithParams() + ) { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + } + } + + + 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 markSentToServer(changeHistoryEntries: List) { + TODO("Not yet implemented") + } + + override suspend fun store(list: List) { + + } +} diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/ClassDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/ClassDataSourceHttp.kt index 5e725fcf0..08006e2e5 100644 --- a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/ClassDataSourceHttp.kt +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/ClassDataSourceHttp.kt @@ -11,6 +11,8 @@ 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.ChangeHistoryMarkSentToServer +import world.respect.datalayer.ChangeHistoryProvider import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataLoadState import world.respect.datalayer.ext.firstOrNotLoaded @@ -25,6 +27,7 @@ import world.respect.datalayer.http.shared.paging.OffsetLimitHttpPagingSource import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHelper import world.respect.datalayer.school.ClassDataSource import world.respect.datalayer.school.ClassDataSource.Companion.PARAM_NAME_INVITE_CODE +import world.respect.datalayer.school.model.ChangeHistoryTableEnum import world.respect.datalayer.school.model.Clazz import world.respect.datalayer.schooldirectory.SchoolDirectoryEntryDataSource import world.respect.datalayer.shared.paging.IPagingSourceFactory @@ -36,7 +39,9 @@ class ClassDataSourceHttp( private val httpClient: HttpClient, private val tokenProvider: AuthTokenProvider, private val validationHelper: ExtendedDataSourceValidationHelper?, -) : ClassDataSource, SchoolUrlBasedDataSource { + private val changeHistoryProvider: ChangeHistoryProvider, + private val markSentToServer: ChangeHistoryMarkSentToServer + ) : ClassDataSource, SchoolUrlBasedDataSource { private suspend fun ClassDataSource.GetListParams.urlWithParams(): Url { return URLBuilder(respectEndpointUrl(ClassDataSource.ENDPOINT_NAME)) @@ -108,12 +113,28 @@ class ClassDataSourceHttp( } override suspend fun store(list: List) { + val changeHistories = changeHistoryProvider.getPendingChangeHistoryEntries( + tableId = ChangeHistoryTableEnum.CLASS, + uids = list.map { it.guid } + ) httpClient.post( - respectEndpointUrl(ClassDataSource.ENDPOINT_NAME) + url = respectEndpointUrl(ClassDataSource.ENDPOINT_NAME) ) { useTokenProvider(tokenProvider) contentType(ContentType.Application.Json) - setBody(list) + + if (changeHistories.isEmpty()) { + setBody(list) + } else { + setBody>( + DataAndChangeHistory( + data = list, + changeHistories = changeHistories + ) + ) + } } + markSentToServer.markSentToServer(changeHistories) + } } \ No newline at end of file diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/DataAndChangeHistory.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/DataAndChangeHistory.kt new file mode 100644 index 000000000..83a64de02 --- /dev/null +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/DataAndChangeHistory.kt @@ -0,0 +1,9 @@ +package world.respect.datalayer.http.school + +import kotlinx.serialization.Serializable +import world.respect.datalayer.school.model.ChangeHistoryEntry +@Serializable +data class DataAndChangeHistory( + val data: List, + val changeHistories: List +) \ No newline at end of file diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/EnrollmentDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/EnrollmentDataSourceHttp.kt index 44e5f74a0..4f269dc40 100644 --- a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/EnrollmentDataSourceHttp.kt +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/EnrollmentDataSourceHttp.kt @@ -1,8 +1,6 @@ package world.respect.datalayer.http.school -import io.github.aakira.napier.Napier import io.ktor.client.HttpClient -import io.ktor.client.request.delete import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType @@ -13,6 +11,8 @@ 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.ChangeHistoryMarkSentToServer +import world.respect.datalayer.ChangeHistoryProvider import world.respect.datalayer.DataLayerParams import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataLoadState @@ -27,9 +27,9 @@ 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.EnrollmentDataSource +import world.respect.datalayer.school.model.ChangeHistoryTableEnum import world.respect.datalayer.school.model.Enrollment import world.respect.datalayer.schooldirectory.SchoolDirectoryEntryDataSource -import world.respect.datalayer.shared.DataLayerTags.TAG_DATALAYER import world.respect.datalayer.shared.paging.IPagingSourceFactory import world.respect.datalayer.shared.params.GetListCommonParams @@ -39,6 +39,8 @@ class EnrollmentDataSourceHttp( private val httpClient: HttpClient, private val tokenProvider: AuthTokenProvider, private val validationHelper: ExtendedDataSourceValidationHelper?, + private val changeHistoryProvider: ChangeHistoryProvider, + private val markSentToServer: ChangeHistoryMarkSentToServer, ): EnrollmentDataSource, SchoolUrlBasedDataSource { private suspend fun EnrollmentDataSource.GetListParams.urlWithParams(): Url { @@ -122,15 +124,29 @@ class EnrollmentDataSourceHttp( } override suspend fun store(list: List) { - val url = respectEndpointUrl(EnrollmentDataSource.ENDPOINT_NAME) - val response = httpClient.post(url) { + + val changeHistories = changeHistoryProvider.getPendingChangeHistoryEntries( + tableId = ChangeHistoryTableEnum.ENROLLMENT, + uids = list.map { it.uid } + ) + httpClient.post( + url = respectEndpointUrl(EnrollmentDataSource.ENDPOINT_NAME) + ) { useTokenProvider(tokenProvider) contentType(ContentType.Application.Json) - setBody(list) - } - Napier.d(tag = TAG_DATALAYER) { - "EnrollmentDataSourceHttp: posted ${list.size} items(${list.joinToString { it.uid }}) to $url (status=${response.status.value}" + if (changeHistories.isEmpty()) { + setBody(list) + } else { + setBody>( + DataAndChangeHistory( + data = list, + changeHistories = changeHistories + ) + ) + } } + markSentToServer.markSentToServer(changeHistories) + } } \ No newline at end of file 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..6e839bf8d 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 @@ -11,6 +11,8 @@ 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.ChangeHistoryMarkSentToServer +import world.respect.datalayer.ChangeHistoryProvider import world.respect.datalayer.DataLayerParams import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataLoadState @@ -19,13 +21,14 @@ 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 import world.respect.datalayer.school.PersonDataSource import world.respect.datalayer.school.adapters.asListDetails +import world.respect.datalayer.school.model.ChangeHistoryTableEnum import world.respect.datalayer.school.model.Person import world.respect.datalayer.school.model.composites.PersonListDetails import world.respect.datalayer.schooldirectory.SchoolDirectoryEntryDataSource @@ -39,6 +42,8 @@ class PersonDataSourceHttp( private val httpClient: HttpClient, private val tokenProvider: AuthTokenProvider, private val validationHelper: ExtendedDataSourceValidationHelper?, + private val changeHistoryProvider: ChangeHistoryProvider, + private val markSentToServer: ChangeHistoryMarkSentToServer, ) : PersonDataSource, SchoolUrlBasedDataSource { private suspend fun PersonDataSource.GetListParams.urlWithParams(): Url { @@ -151,13 +156,31 @@ class PersonDataSourceHttp( } } + + override suspend fun store(list: List) { + val changeHistories = changeHistoryProvider.getPendingChangeHistoryEntries( + tableId = ChangeHistoryTableEnum.PERSON, + uids = list.map { it.guid } + ) httpClient.post( url = respectEndpointUrl(PersonDataSource.ENDPOINT_NAME) ) { useTokenProvider(tokenProvider) contentType(ContentType.Application.Json) - setBody(list) + + if (changeHistories.isEmpty()) { + setBody(list) + } else { + setBody>( + DataAndChangeHistory( + data = list, + changeHistories = changeHistories + ) + ) + } } + markSentToServer.markSentToServer(changeHistories) + } } \ No newline at end of file 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..3737a9de8 --- /dev/null +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/ChangeHistoryDataSourceRepository.kt @@ -0,0 +1,99 @@ +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.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( + guid: String + ): Flow>> { + return local.findByGuidAsFlow(guid).combineWithRemote( + remoteFlow = remote.findByGuidAsFlow(guid).onEach { + local.updateFromRemoteListIfNeeded(it, validationHelper) + } + ) + } + override suspend fun list( + loadParams: DataLoadParams, + params: ChangeHistoryDataSource.GetListParams + ): DataLoadState> { + local.updateFromRemoteListIfNeeded( + remote.list(loadParams, params), validationHelper + ) + return local.list(loadParams, params) + } + 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 markSentToServer(changeHistoryEntries: List) { + local.markSentToServer(changeHistoryEntries) + } + + + 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/PersonDataSourceRepository.kt b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/PersonDataSourceRepository.kt index 2d125c57e..5d0344481 100644 --- a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/PersonDataSourceRepository.kt +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/PersonDataSourceRepository.kt @@ -12,6 +12,7 @@ import world.respect.datalayer.repository.shared.paging.RepositoryPagingSourceFa import world.respect.datalayer.repository.shared.paging.loadAndUpdateLocal2 import world.respect.datalayer.school.PersonDataSource import world.respect.datalayer.school.PersonDataSourceLocal +import world.respect.datalayer.school.model.ChangeHistoryEntry import world.respect.datalayer.school.model.Person import world.respect.datalayer.school.model.composites.PersonListDetails import world.respect.datalayer.school.writequeue.RemoteWriteQueue 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..ecc5a9f5b 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 @@ -5,6 +5,7 @@ import io.ktor.http.Url import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataReadyState import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.ext.dataOrNull import world.respect.datalayer.repository.SchoolDataSourceRepository import world.respect.datalayer.school.writequeue.RemoteWriteQueue import world.respect.datalayer.school.writequeue.WriteQueueItem @@ -68,6 +69,9 @@ class DrainRemoteWriteQueueUseCase( WriteQueueItem.Model.INVITE -> { repository.inviteDataSource.sendToRemote(listOf(item)) } + WriteQueueItem.Model.CHANGE_HISTORY -> { + } + WriteQueueItem.Model.OPDS_FEED -> { val dataLoad = repository.opdsFeedDataSource.local.getByUrl( diff --git a/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/PersonRepositoryIntegrationTest.kt b/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/PersonRepositoryIntegrationTest.kt index c73fadc13..8b979d296 100644 --- a/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/PersonRepositoryIntegrationTest.kt +++ b/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/PersonRepositoryIntegrationTest.kt @@ -7,8 +7,11 @@ import io.github.aakira.napier.Napier import io.ktor.server.routing.route import kotlinx.coroutines.flow.filter import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json import org.junit.Rule import org.junit.rules.TemporaryFolder +import org.koin.core.context.startKoin +import org.koin.dsl.module import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataReadyState import world.respect.datalayer.NoDataLoadedState @@ -299,7 +302,16 @@ class PersonRepositoryIntegrationTest { } server.start() - + startKoin { + modules(module { + single { + Json { + encodeDefaults = false + ignoreUnknownKeys = true + } + } + }) + } clients.first().insertServerAdminAndDefaultGrants() clients.first().schoolDataSource.personDataSource.store( diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/ChangeHistoryMarkSentToServer.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/ChangeHistoryMarkSentToServer.kt new file mode 100644 index 000000000..654b796ff --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/ChangeHistoryMarkSentToServer.kt @@ -0,0 +1,9 @@ +package world.respect.datalayer + +import world.respect.datalayer.school.model.ChangeHistoryEntry + +interface ChangeHistoryMarkSentToServer { + suspend fun markSentToServer( + changeHistoryEntry: List + ) +} \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/ChangeHistoryProvider.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/ChangeHistoryProvider.kt new file mode 100644 index 000000000..155ea4e90 --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/ChangeHistoryProvider.kt @@ -0,0 +1,12 @@ +package world.respect.datalayer + +import world.respect.datalayer.school.model.ChangeHistoryEntry +import world.respect.datalayer.school.model.ChangeHistoryTableEnum + +interface ChangeHistoryProvider { + suspend fun getPendingChangeHistoryEntries( + tableId: ChangeHistoryTableEnum, + uids: List + ): List +} + 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..e157229ee --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ChangeHistoryDataSource.kt @@ -0,0 +1,67 @@ +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( + guid: String + ): Flow>> + + suspend fun list( + loadParams: DataLoadParams = DataLoadParams(), + params: GetListParams = GetListParams(), + ): DataLoadState> + + fun listAsPagingSource( + dataLoadParams: DataLoadParams, + getListParams: GetListParams + ): IPagingSourceFactory + + suspend fun markSentToServer( + changeHistoryEntries : List + ) + + 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/PersonDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/PersonDataSource.kt index 2410bf85a..8822fdd92 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/PersonDataSource.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/PersonDataSource.kt @@ -6,6 +6,7 @@ import kotlinx.datetime.LocalDate 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.EnrollmentRoleEnum import world.respect.datalayer.school.model.Person import world.respect.datalayer.school.model.PersonRoleEnum @@ -94,7 +95,6 @@ interface PersonDataSource: WritableDataSource { listParams: GetListParams, ): IPagingSourceFactory - /** * Persists the list to the DataSource. The underlying DataSource WILL set the stored time on * the data. It WILL NOT set the last-modified time (this should be done by the ViewModel or diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/domain/CheckPersonPermissionUseCase.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/domain/CheckPersonPermissionUseCase.kt index 97241d78f..6f251f008 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/domain/CheckPersonPermissionUseCase.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/domain/CheckPersonPermissionUseCase.kt @@ -24,7 +24,6 @@ interface CheckPersonPermissionUseCase { ) companion object { - val WRITE_PERMISSIONS = PermissionsRequiredByRole( roleAdminPermissionRequired = PermissionFlags.SYSTEM_ADMIN, roleTeacherPermissionRequired = PermissionFlags.PERSON_TEACHER_WRITE, 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..c96000751 --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/ChangeHistoryEntry.kt @@ -0,0 +1,43 @@ +package world.respect.datalayer.school.model + +import kotlinx.serialization.Serializable +import world.respect.datalayer.shared.ModelWithTimes +import world.respect.lib.serializers.InstantAsISO8601 +import kotlin.time.Clock +import kotlin.time.Instant + +@Serializable +data class ChangeHistoryEntry( + val guid: String, + val tableGuid: String, + val table: ChangeHistoryTableEnum, + val whoGuid: String, + val changes: List, + override val lastModified: InstantAsISO8601 , + override val stored: InstantAsISO8601 , +): ModelWithTimes { + + companion object { + + const val TABLE_ID = 31 + + } +} + + +@Serializable +data class ChangeHistoryEntryWithWhoDid( + val person: Person, + val changeHistoryEntry: List +) + +@Serializable +data class ChangeHistoryChange( + val id : Long =0, + val field: ChangeHistoryFieldEnum, + val newVal: String, + val oldVal: String?, + val synced : Boolean = false, + override val lastModified: InstantAsISO8601 , + override val stored: InstantAsISO8601 , +): ModelWithTimes \ 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..e4b3a5541 --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/ChangeHistoryTableEnum.kt @@ -0,0 +1,53 @@ +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", "Person name"), + PERSON_FAMILY_NAME("pFamilyName", "Person name"), + PERSON_MIDDLE_NAME("pMiddleName", "Person 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_STATUS("cStatus", "Class Status"), + CLASS_TITLE("cTitle", "Class Title"), + CLASS_DESCRIPTION("cDescription", "Class Description"), + CLASS_TEACHER_ADDED("cTeacherAdded", "Teacher Added"), + CLASS_TEACHER_REMOVED("cTeacherRemoved", "Teacher Removed"), + CLASS_STUDENT_ADDED("cStudentAdded", "Student Added"), + CLASS_STUDENT_REMOVED("cStudentRemoved", "Student Removed"), + JOIN_REQUEST_APPROVED("joinRequestApproved", "Join request approved for"), + JOIN_REQUEST_REJECTED("joinRequestRejected", "Join request rejected for"), + 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/PersonWithHistoryRequest.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonWithHistoryRequest.kt new file mode 100644 index 000000000..abd6fb944 --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonWithHistoryRequest.kt @@ -0,0 +1,9 @@ +package world.respect.datalayer.school.model + +import kotlinx.serialization.Serializable + +@Serializable +data class PersonWithHistoryRequest( + val person: List, + val changeHistory: List +) \ 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..f515d62d8 --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/findDifference.kt @@ -0,0 +1,27 @@ +package world.respect.datalayer.school.model + +import kotlin.time.Clock +import kotlin.time.Instant + +fun findDifference( + field: ChangeHistoryFieldEnum, + oldVal: T?, + newVal: T?, + changes: MutableList, + now: Instant = Clock.System.now(), +) { + val oldString = oldVal?.toString() + val newString = newVal?.toString() + + if (oldString != newString) { + changes.add( + ChangeHistoryChange( + field = field, + oldVal = oldString, + newVal = newString ?: "", + lastModified = now, + stored = now + ) + ) + } +} \ 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..e4b5b3ce8 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/changehistory/ChangeHistoryViewModel.kt @@ -0,0 +1,98 @@ +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.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.shareIn +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 + + +data class ChangeHistoryUiState( + val guid: String = "", + val changeHistoryEntryWithWhoDid: List? = null, +) + +class ChangeHistoryViewModel( + savedStateHandle: SavedStateHandle, + accountManager: RespectAccountManager +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + + override val scope: Scope = accountManager.requireActiveAccountScope() + + private val schoolDataSource: SchoolDataSource by inject() + + private val route: ChangeHistory = savedStateHandle.toRoute() + + private val _uiState = MutableStateFlow( + ChangeHistoryUiState( + guid = route.guid + ) + ) + + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + + val changeHistoryFlow = + schoolDataSource.changeHistoryDataSource + .findByGuidAsFlow(route.guid) + .shareIn(viewModelScope, SharingStarted.Lazily, replay = 1) + + changeHistoryFlow.collect { state -> + + val changeHistoryList = state.dataOrNull().orEmpty() + + val groupedByWho = changeHistoryList.groupBy { it.whoGuid } + + val resultList = groupedByWho.mapNotNull { (whoGuid, entries) -> + + val person = + schoolDataSource.personDataSource.findByGuid( + loadParams = DataLoadParams(), + guid = whoGuid + ).dataOrNull() + + person?.let { + ChangeHistoryEntryWithWhoDid( + person = it, + changeHistoryEntry = entries + ) + } + } + + _uiState.update { previous -> + previous.copy( + changeHistoryEntryWithWhoDid = resultList + ) + } + } + } + _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/clazz/detail/ClazzDetailViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/detail/ClazzDetailViewModel.kt index fb9550376..dc664b75a 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/detail/ClazzDetailViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/clazz/detail/ClazzDetailViewModel.kt @@ -17,6 +17,7 @@ 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.isAdmin import world.respect.datalayer.ext.dataOrNull import world.respect.datalayer.school.EnrollmentDataSource import world.respect.datalayer.school.PersonDataSource @@ -52,9 +53,12 @@ import world.respect.shared.util.SortOrderOption import world.respect.shared.util.exception.getUiTextOrGeneric import world.respect.shared.util.ext.asUiText import world.respect.datalayer.db.school.ext.isAdminOrTeacher +import world.respect.datalayer.school.domain.CheckPersonPermissionUseCase +import world.respect.datalayer.school.model.ChangeHistoryTableEnum import world.respect.datalayer.school.model.ClassInvite import world.respect.datalayer.school.model.ClassInviteModeEnum import world.respect.datalayer.school.writequeue.EnqueueRunPullSyncUseCase +import world.respect.shared.navigation.ChangeHistory import world.respect.shared.viewmodel.RespectViewModel import world.respect.shared.viewmodel.app.appstate.FabUiState import world.respect.shared.viewmodel.app.appstate.Snack @@ -84,7 +88,7 @@ data class ClazzDetailUiState( val inviteCodePrefix: String? = null, val showAddStudent: Boolean = false, val showAddTeacher: Boolean = false, -) + ) class ClazzDetailViewModel( savedStateHandle: SavedStateHandle, @@ -107,6 +111,8 @@ class ClazzDetailViewModel( private val route: ClazzDetail = savedStateHandle.toRoute() + private val checkPersonPermissionUseCase: CheckPersonPermissionUseCase by inject() + private fun pagingSourceByRole(role: EnrollmentRoleEnum): PagingSourceFactoryHolder { return PagingSourceFactoryHolder { schoolDataSource.personDataSource.listAsPagingSource( @@ -291,7 +297,16 @@ class ClazzDetailViewModel( fun onToggleStudentsSection() { _uiState.update { it.copy(isStudentsExpanded = !it.isStudentsExpanded) } } - + fun onClickChangeHistoryButton(){ + _navCommandFlow.tryEmit( + NavCommand.Navigate( + ChangeHistory( + guid = route.guid, + table = ChangeHistoryTableEnum.CLASS.value + ) + ) + ) + } fun onClickEdit() { _navCommandFlow.tryEmit( NavCommand.Navigate(ClazzEdit(route.guid)) 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..22c5c6311 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,27 +15,28 @@ 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 = "", @@ -165,4 +166,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-lib-test/src/jvmMain/kotlin/world/respect/lib/test/clientservertest/ClientServerDatasourceTest.kt b/respect-lib-test/src/jvmMain/kotlin/world/respect/lib/test/clientservertest/ClientServerDatasourceTest.kt index 628e2eebb..7decb6125 100644 --- a/respect-lib-test/src/jvmMain/kotlin/world/respect/lib/test/clientservertest/ClientServerDatasourceTest.kt +++ b/respect-lib-test/src/jvmMain/kotlin/world/respect/lib/test/clientservertest/ClientServerDatasourceTest.kt @@ -31,6 +31,8 @@ import world.respect.datalayer.db.RespectAppDatabase import world.respect.datalayer.db.RespectSchoolDatabase import world.respect.datalayer.db.SchoolDataSourceDb import world.respect.datalayer.db.networkvalidation.ExtendedDataSourceValidationHelperImpl +import world.respect.datalayer.db.school.ChangeHistoryMarkSentToServerDb +import world.respect.datalayer.db.school.ChangeHistoryProviderDb import world.respect.datalayer.db.school.domain.AddDefaultSchoolPermissionGrantsUseCase import world.respect.datalayer.db.school.domain.CheckPersonPermissionUseCaseDbImpl import world.respect.datalayer.db.school.writequeue.RemoteWriteQueueDbImpl @@ -235,14 +237,18 @@ class ClientServerDataSourceTestBuilder internal constructor( ) val token = "secret" + val changeHistoryProvider = ChangeHistoryProviderDb(schoolDb) + val markSentToServer = ChangeHistoryMarkSentToServerDb(schoolDb, uidNumberMapper =XXHashUidNumberMapper(stringHasher) ) val schoolDataSourceRemote = SchoolDataSourceHttp( schoolUrl = schoolUrl, schoolDirectoryEntryDataSource = clientAppDataSource.schoolDirectoryEntryDataSource, httpClient = httpClient, - tokenProvider = { AuthToken(token, systemTimeInMillis(), 3600) }, + tokenProvider = { AuthToken(token, systemTimeInMillis(), 3600) }, validationHelper = clientValidationHelper, defaultAppCatalogUrl = null, json = json, + changeHistoryProvider = changeHistoryProvider, + markSentToServer = markSentToServer, ) val drainQueueSignal = Channel(capacity = Channel.UNLIMITED) 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 539c3633f..f73d7dd7b 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 @@ -253,6 +254,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() + ) + } +} + diff --git a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/ClassRoute.kt b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/ClassRoute.kt index 1c0f353aa..2ec04f7e6 100644 --- a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/ClassRoute.kt +++ b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/ClassRoute.kt @@ -1,18 +1,21 @@ package world.respect.server.routes.school.respect import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall -import io.ktor.server.request.receive import io.ktor.server.response.header -import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.get import io.ktor.server.routing.post +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import org.koin.ktor.ext.inject import world.respect.datalayer.DataLoadParams import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.http.school.DataAndChangeHistory import world.respect.datalayer.school.ClassDataSource +import world.respect.datalayer.school.model.Clazz import world.respect.server.util.ext.offsetLimitPagingLoadParams +import world.respect.server.util.ext.receiveDataAndChangeHistory import world.respect.server.util.ext.requireAccountScope import world.respect.server.util.ext.respondOffsetLimitPaging @@ -22,6 +25,8 @@ fun Route.ClassRoute( call.requireAccountScope().get() }, ) { + val json:Json by inject() + get(ClassDataSource.ENDPOINT_NAME) { call.response.header(HttpHeaders.Vary, HttpHeaders.Authorization) call.respondOffsetLimitPaging( @@ -36,10 +41,20 @@ fun Route.ClassRoute( } post(ClassDataSource.ENDPOINT_NAME) { - schoolDataSource(call).classDataSource.store( - list = call.receive() + val schoolDataSource = schoolDataSource(call) + + val request = call.receiveDataAndChangeHistory( + json, + ListSerializer(Clazz.serializer()), + DataAndChangeHistory.serializer(Clazz.serializer()) ) - call.respond(HttpStatusCode.NoContent) + + schoolDataSource.classDataSource.store(request.data) + + if (request.changeHistories.isNotEmpty()) { + schoolDataSource.changeHistoryDataSource.store(request.changeHistories) + schoolDataSource.changeHistoryDataSource.markSentToServer(request.changeHistories) + } } } \ No newline at end of file diff --git a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/EnrollmentRoute.kt b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/EnrollmentRoute.kt index e3db23789..e18bce205 100644 --- a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/EnrollmentRoute.kt +++ b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/EnrollmentRoute.kt @@ -1,19 +1,22 @@ package world.respect.server.routes.school.respect import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall -import io.ktor.server.request.receive import io.ktor.server.response.header -import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.get import io.ktor.server.routing.post +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import org.koin.ktor.ext.inject import world.respect.datalayer.DataLoadParams import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.http.school.DataAndChangeHistory import world.respect.datalayer.school.EnrollmentDataSource import world.respect.datalayer.school.domain.GetPermissionLastModifiedUseCase +import world.respect.datalayer.school.model.Enrollment import world.respect.server.util.ext.offsetLimitPagingLoadParams +import world.respect.server.util.ext.receiveDataAndChangeHistory import world.respect.server.util.ext.requireAccountScope import world.respect.server.util.ext.respondOffsetLimitPaging @@ -25,6 +28,8 @@ fun Route.EnrollmentRoute( call.requireAccountScope().get() } ) { + val json:Json by inject() + get(EnrollmentDataSource.ENDPOINT_NAME) { call.response.header(HttpHeaders.Vary, HttpHeaders.Authorization) call.respondOffsetLimitPaging( @@ -38,9 +43,19 @@ fun Route.EnrollmentRoute( } post(EnrollmentDataSource.ENDPOINT_NAME) { - schoolDataSource(call).enrollmentDataSource.store( - list = call.receive() + val schoolDataSource = schoolDataSource(call) + + val request = call.receiveDataAndChangeHistory( + json, + ListSerializer(Enrollment.serializer()), + DataAndChangeHistory.serializer(Enrollment.serializer()) ) - call.respond(HttpStatusCode.NoContent) + + schoolDataSource.enrollmentDataSource.store(request.data) + + if (request.changeHistories.isNotEmpty()) { + schoolDataSource.changeHistoryDataSource.store(request.changeHistories) + schoolDataSource.changeHistoryDataSource.markSentToServer(request.changeHistories) + } } } \ No newline at end of file diff --git a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/PersonRoute.kt b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/PersonRoute.kt index 18440a078..8d68cf4ec 100644 --- a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/PersonRoute.kt +++ b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/PersonRoute.kt @@ -3,17 +3,21 @@ package world.respect.server.routes.school.respect import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall -import io.ktor.server.request.receive import io.ktor.server.response.header import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.get import io.ktor.server.routing.post +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import org.koin.ktor.ext.inject import world.respect.datalayer.DataLoadParams import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.http.school.DataAndChangeHistory import world.respect.datalayer.school.PersonDataSource import world.respect.datalayer.school.model.Person import world.respect.server.util.ext.offsetLimitPagingLoadParams +import world.respect.server.util.ext.receiveDataAndChangeHistory import world.respect.server.util.ext.requireAccountScope import world.respect.server.util.ext.respondOffsetLimitPaging @@ -23,6 +27,8 @@ fun Route.PersonRoute( call.requireAccountScope().get() }, ) { + val json:Json by inject() + get(PersonDataSource.ENDPOINT_NAME) { val schoolDataSource = schoolDataSource(call) call.response.header(HttpHeaders.Vary, HttpHeaders.Authorization) @@ -42,8 +48,20 @@ fun Route.PersonRoute( post(PersonDataSource.ENDPOINT_NAME) { val schoolDataSource = schoolDataSource(call) - val persons: List = call.receive() - schoolDataSource.personDataSource.store(persons) + + val request = call.receiveDataAndChangeHistory( + json, + ListSerializer(Person.serializer()), + DataAndChangeHistory.serializer(Person.serializer()) + ) + + schoolDataSource.personDataSource.store(request.data) + + if (request.changeHistories.isNotEmpty()) { + schoolDataSource.changeHistoryDataSource.store(request.changeHistories) + schoolDataSource.changeHistoryDataSource.markSentToServer(request.changeHistories) + } + call.respond(HttpStatusCode.NoContent) } diff --git a/respect-server/src/main/kotlin/world/respect/server/util/ext/ReceiveDataAndChangeHistory.kt b/respect-server/src/main/kotlin/world/respect/server/util/ext/ReceiveDataAndChangeHistory.kt new file mode 100644 index 000000000..350e560c1 --- /dev/null +++ b/respect-server/src/main/kotlin/world/respect/server/util/ext/ReceiveDataAndChangeHistory.kt @@ -0,0 +1,36 @@ +package world.respect.server.util.ext + +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.receive +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import world.respect.datalayer.http.school.DataAndChangeHistory + +suspend fun ApplicationCall.receiveDataAndChangeHistory( + json: Json, + listDeserializer: DeserializationStrategy>, + wrapperDeserializer: DeserializationStrategy> +): DataAndChangeHistory { + + return when (val incoming = receive()) { + + is JsonArray -> { + val data = json.decodeFromJsonElement(listDeserializer, incoming) + DataAndChangeHistory( + data = data, + changeHistories = emptyList() + ) + } + + is JsonObject -> { + json.decodeFromJsonElement(wrapperDeserializer, incoming) + } + + else -> { + throw IllegalArgumentException("Invalid request format") + } + } +} \ No newline at end of file