From 65680a0e34c6d0c57d11a71016ec55b640aac691 Mon Sep 17 00:00:00 2001 From: Mike Dawson Date: Thu, 19 Mar 2026 12:13:39 +0400 Subject: [PATCH 01/10] School config work in progress. --- .../13.json | 1803 +++++++++++++++++ .../datalayer/db/RespectSchoolDatabase.kt | 7 +- .../db/RespectSchoolDatabaseMigrations.kt | 13 +- .../datalayer/db/SchoolDataSourceDb.kt | 8 +- .../school/SchoolConfigSettingDataSourceDb.kt | 60 + .../adapters/SchoolConfigSettingAdapter.kt | 32 + .../daos/SchoolConfigSettingEntityDao.kt | 22 + .../entities/SchoolConfigSettingEntity.kt | 19 + respect-datalayer/AGENTS.md | 2 +- .../datalayer/SchoolDataSourceLocal.kt | 3 + .../SchoolConfigSettingDataSourceLocal.kt | 6 + .../datalayer/school/ext/PersonRoleEnumExt.kt | 13 + .../datalayer/school/model/PersonRoleEnum.kt | 12 +- .../school/model/SchoolConfigSetting.kt | 7 +- 14 files changed, 1996 insertions(+), 11 deletions(-) create mode 100644 respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/13.json create mode 100644 respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt create mode 100644 respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/adapters/SchoolConfigSettingAdapter.kt create mode 100644 respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt create mode 100644 respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/SchoolConfigSettingEntity.kt create mode 100644 respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSourceLocal.kt 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..a54f87a8e --- /dev/null +++ b/respect-datalayer-db/schemas/world.respect.datalayer.db.RespectSchoolDatabase/13.json @@ -0,0 +1,1803 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "9a618b8d4ebbb7449220f872676e2ecd", + "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": "SchoolConfigSettingEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scsKey` TEXT NOT NULL, `scsValue` TEXT NOT NULL, `scsStatus` INTEGER NOT NULL, `scsLastModified` INTEGER NOT NULL, `scsStored` INTEGER NOT NULL, `scsCanReadFlags` INTEGER NOT NULL, `scsAnonCanRead` INTEGER NOT NULL, `scsCanWriteFlags` INTEGER NOT NULL, PRIMARY KEY(`scsKey`))", + "fields": [ + { + "fieldPath": "scsKey", + "columnName": "scsKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scsValue", + "columnName": "scsValue", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scsStatus", + "columnName": "scsStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scsLastModified", + "columnName": "scsLastModified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scsStored", + "columnName": "scsStored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scsCanReadFlags", + "columnName": "scsCanReadFlags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scsAnonCanRead", + "columnName": "scsAnonCanRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scsCanWriteFlags", + "columnName": "scsCanWriteFlags", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "scsKey" + ] + } + } + ], + "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, '9a618b8d4ebbb7449220f872676e2ecd')" + ] + } +} \ 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..8a491d2c6 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 @@ -44,6 +44,7 @@ import world.respect.datalayer.db.school.daos.PersonQrBadgeEntityDao import world.respect.datalayer.db.school.daos.PersonRelatedPersonEntityDao import world.respect.datalayer.db.school.daos.PullSyncStatusEntityDao import world.respect.datalayer.db.school.daos.SchoolAppEntityDao +import world.respect.datalayer.db.school.daos.SchoolConfigSettingEntityDao import world.respect.datalayer.db.school.daos.WriteQueueItemEntityDao import world.respect.datalayer.db.school.entities.AssignmentEntity import world.respect.datalayer.db.school.entities.AssignmentLearningResourceRefEntity @@ -59,6 +60,7 @@ import world.respect.datalayer.db.school.entities.WriteQueueItemEntity import world.respect.datalayer.db.school.daos.SchoolPermissionGrantDao import world.respect.datalayer.db.school.entities.ClassPermissionEntity import world.respect.datalayer.db.school.entities.PullSyncStatusEntity +import world.respect.datalayer.db.school.entities.SchoolConfigSettingEntity import world.respect.datalayer.db.school.entities.SchoolPermissionGrantEntity import world.respect.datalayer.school.model.Assignment import world.respect.datalayer.school.model.Clazz @@ -105,8 +107,10 @@ import world.respect.datalayer.school.model.Report OpdsGroupEntity::class, OpdsFeedEntity::class, OpdsFeedMetadataEntity::class, + + SchoolConfigSettingEntity::class, ], - version = 12, + version = 13, ) @TypeConverters(SharedConverters::class, SchoolTypeConverters::class, OpdsTypeConverters::class) @ConstructedBy(RespectSchoolDatabaseConstructor::class) @@ -162,6 +166,7 @@ abstract class RespectSchoolDatabase: RoomDatabase() { abstract fun getOpdsGroupEntityDao(): OpdsGroupEntityDao + abstract fun getSchoolConfigSettingEntityDao(): SchoolConfigSettingEntityDao companion object { 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..0d0ec033e 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,20 @@ val MIGRATION_11_12 = object: Migration(11, 12) { } } +val MIGRATION_12_13 = object: Migration(12, 13) { + override fun migrate(connection: SQLiteConnection) { + //HERE: IMPORTANT: Need to run an update to change the flags in database on + //existing fields including permission grants. + connection.execSQL("CREATE TABLE IF NOT EXISTS `SchoolConfigSettingEntity` (`scsKey` TEXT NOT NULL, `scsValue` TEXT NOT NULL, `scsStatus` INTEGER NOT NULL, `scsLastModified` INTEGER NOT NULL, `scsStored` INTEGER NOT NULL, `scsCanReadFlags` INTEGER NOT NULL, `scsAnonCanRead` INTEGER NOT NULL, `scsCanWriteFlags` INTEGER NOT NULL, PRIMARY KEY(`scsKey`))") + } +} + fun RoomDatabase.Builder.addCommonMigrations( ): RoomDatabase.Builder { - return this.addMigrations(MIGRATION_11_12) + return this.addMigrations( + MIGRATION_11_12, + MIGRATION_12_13, + ) } diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/SchoolDataSourceDb.kt index 2ad81f42a..503854c9d 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 @@ -18,6 +18,7 @@ import world.respect.datalayer.db.school.PersonPasswordDataSourceDb import world.respect.datalayer.db.school.PersonQrBadgeDataSourceDb import world.respect.datalayer.db.school.ReportDataSourceDb import world.respect.datalayer.db.school.SchoolAppDataSourceDb +import world.respect.datalayer.db.school.SchoolConfigSettingDataSourceDb import world.respect.datalayer.db.school.SchoolPermissionGrantDataSourceDb import world.respect.datalayer.school.AssignmentDataSourceLocal import world.respect.datalayer.school.ClassDataSourceLocal @@ -136,9 +137,10 @@ class SchoolDataSourceDb( ) } - override val schoolConfigSettingDataSource: SchoolConfigSettingDataSource by lazy { - DummySchoolConfigSettingsDataSource( - defaultAppCatalogUrl = defaultAppCatalogUrl, + override val schoolConfigSettingDataSource by lazy { + SchoolConfigSettingDataSourceDb( + schoolDb = schoolDb, + authenticatedUser = authenticatedUser, ) } } \ No newline at end of file diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt new file mode 100644 index 000000000..d13832cc0 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt @@ -0,0 +1,60 @@ +package world.respect.datalayer.db.school + +import kotlinx.coroutines.flow.Flow +import world.respect.datalayer.AuthenticatedUserPrincipalId +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.db.RespectSchoolDatabase +import world.respect.datalayer.school.SchoolConfigSettingDataSource +import world.respect.datalayer.school.SchoolConfigSettingDataSourceLocal +import world.respect.datalayer.school.model.SchoolConfigSetting +import world.respect.datalayer.shared.paging.IPagingSourceFactory + +class SchoolConfigSettingDataSourceDb( + private val schoolDb: RespectSchoolDatabase, + private val authenticatedUser: AuthenticatedUserPrincipalId, +) : SchoolConfigSettingDataSourceLocal { + + override suspend fun findByGuid( + params: DataLoadParams, + guid: String + ): DataLoadState { + TODO("Not yet implemented") + } + + override fun listAsFlow( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): Flow>> { + TODO("Not yet implemented") + } + + override fun listAsPagingSource( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): IPagingSourceFactory { + TODO("Not yet implemented") + } + + override suspend fun list( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): DataLoadState> { + TODO("Not yet implemented") + } + + override suspend fun store(list: List) { + TODO("Not yet implemented") + } + + override suspend fun updateLocal( + list: List, + forceOverwrite: Boolean + ) { + TODO("Not yet implemented") + } + + 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/adapters/SchoolConfigSettingAdapter.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/adapters/SchoolConfigSettingAdapter.kt new file mode 100644 index 000000000..1bc8415e7 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/adapters/SchoolConfigSettingAdapter.kt @@ -0,0 +1,32 @@ +package world.respect.datalayer.db.school.adapters + +import world.respect.datalayer.db.school.entities.SchoolConfigSettingEntity +import world.respect.datalayer.school.ext.foldToFlag +import world.respect.datalayer.school.model.PersonRoleEnum +import world.respect.datalayer.school.model.SchoolConfigSetting + + +fun SchoolConfigSetting.asEntity(): SchoolConfigSettingEntity { + return SchoolConfigSettingEntity( + scsKey = key, + scsValue = value, + scsStatus = status, + scsLastModified = lastModified, + scsStored = stored, + scsCanReadFlags = canRead.foldToFlag(), + scsCanWriteFlags = canWrite.foldToFlag(), + scsAnonCanRead = canRead.contains(null), + ) +} + +fun SchoolConfigSettingEntity.asModel(): SchoolConfigSetting { + return SchoolConfigSetting( + key = scsKey, + value = scsValue, + status = scsStatus, + lastModified = scsLastModified, + stored = scsStored, + canRead = PersonRoleEnum.unfoldFromFlag(scsCanReadFlags), + canWrite = PersonRoleEnum.unfoldFromFlag(scsCanWriteFlags) + ) +} diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt new file mode 100644 index 000000000..deae28cdf --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt @@ -0,0 +1,22 @@ +package world.respect.datalayer.db.school.daos + +import androidx.room.Dao +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import world.respect.datalayer.db.school.entities.SchoolConfigSettingEntity + +@Dao +interface SchoolConfigSettingEntityDao { + + @Query(""" + SELECT SchoolConfigSettingEntity.* + FROM SchoolConfigSettingEntity + WHERE ((:key IS NULL) OR scsKey = :key) + AND ((:since = 0) OR (scsStored > :since)) + """) + fun listAsFlow( + key: String? = null, + since: Long = 0, + ): Flow> + +} \ No newline at end of file diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/SchoolConfigSettingEntity.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/SchoolConfigSettingEntity.kt new file mode 100644 index 000000000..69373e060 --- /dev/null +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/entities/SchoolConfigSettingEntity.kt @@ -0,0 +1,19 @@ +package world.respect.datalayer.db.school.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import world.respect.datalayer.school.model.StatusEnum +import kotlin.time.Instant + +@Entity +data class SchoolConfigSettingEntity( + @PrimaryKey + val scsKey: String, + val scsValue: String, + val scsStatus: StatusEnum, + val scsLastModified: Instant, + val scsStored: Instant, + val scsCanReadFlags: Int, + val scsAnonCanRead: Boolean, + val scsCanWriteFlags: Int, +) \ No newline at end of file diff --git a/respect-datalayer/AGENTS.md b/respect-datalayer/AGENTS.md index 1c5bad234..06dd5d32f 100644 --- a/respect-datalayer/AGENTS.md +++ b/respect-datalayer/AGENTS.md @@ -1,5 +1,5 @@ * The datalayer is split into two parts: SchoolDataSource for school-level data (users, student - progress, etc) and RespectAppDataSource for app-wide data. + progress, etc) and RespectAppDataSource for app-wide data (e.g. school directories). * Any writable model class should implement the ```ModelWithTimes``` interface such that it can be synced * Models normally have a string uid (required as this UID may come from an external system). 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..51e75d950 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/SchoolDataSourceLocal.kt @@ -11,6 +11,7 @@ import world.respect.datalayer.school.PersonPasswordDataSourceLocal import world.respect.datalayer.school.PersonQrCodeBadgeDataSourceLocal import world.respect.datalayer.school.ReportDataSourceLocal import world.respect.datalayer.school.SchoolAppDataSourceLocal +import world.respect.datalayer.school.SchoolConfigSettingDataSourceLocal import world.respect.datalayer.school.SchoolPermissionGrantDataSourceLocal import world.respect.datalayer.school.opds.OpdsFeedDataSourceLocal @@ -47,4 +48,6 @@ interface SchoolDataSourceLocal: SchoolDataSource { override val opdsFeedDataSource: OpdsFeedDataSourceLocal + override val schoolConfigSettingDataSource: SchoolConfigSettingDataSourceLocal + } \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSourceLocal.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSourceLocal.kt new file mode 100644 index 000000000..0476860f9 --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSourceLocal.kt @@ -0,0 +1,6 @@ +package world.respect.datalayer.school + +import world.respect.datalayer.school.model.SchoolConfigSetting +import world.respect.datalayer.shared.LocalModelDataSource + +interface SchoolConfigSettingDataSourceLocal: SchoolConfigSettingDataSource, LocalModelDataSource \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/PersonRoleEnumExt.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/PersonRoleEnumExt.kt index 12624a0bb..933be653e 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/PersonRoleEnumExt.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/PersonRoleEnumExt.kt @@ -21,3 +21,16 @@ val PersonRoleEnum.writePermissionFlag: Long val PersonRoleEnum.newUserInviteUid: String get() = "$TYPE_NEW_USER:${this.value}" + +/** + * Fold the list of PersonRoleEnum into a single flag + */ +fun List.foldToFlag(): Int { + return this.fold(0) { acc, enum -> + if(enum != null) { + acc.or(enum.flag) + }else { + acc + } + } +} diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonRoleEnum.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonRoleEnum.kt index 101529320..ac40607bb 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonRoleEnum.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonRoleEnum.kt @@ -12,9 +12,9 @@ import kotlinx.serialization.encoding.Encoder enum class PersonRoleEnum(val value: String, val flag: Int) { SITE_ADMINISTRATOR("siteAdministrator", 1), STUDENT("student", 2), - SYSTEM_ADMINISTRATOR("systemAdministrator", 3), - TEACHER("teacher", 4), - PARENT("parent", 5); + SYSTEM_ADMINISTRATOR("systemAdministrator", 4), + TEACHER("teacher", 8), + PARENT("parent", 16); companion object { @@ -37,6 +37,12 @@ enum class PersonRoleEnum(val value: String, val flag: Int) { return entries.first { it.flag == flag } } + fun unfoldFromFlag(flag: Int): List { + return entries.filter { enum -> + flag.and(enum.flag) == flag + } + } + } } diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/SchoolConfigSetting.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/SchoolConfigSetting.kt index f5a3ee544..a583b4752 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/SchoolConfigSetting.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/SchoolConfigSetting.kt @@ -9,8 +9,10 @@ import kotlin.time.Clock * Provides a way to store key-value configuration settings for the school itself e.g. the list of * URLs of app catalogs, single sign-on settings, etc). * - * Config settings can only be written by the admin. Storing each key-value pair as its own entity - * allows for granular per-setting control over read permissions. + * Storing each key-value pair as its own entity allows for granular per-setting control over read + * and write permissions. + * + * @property canRead */ @Serializable data class SchoolConfigSetting( @@ -20,6 +22,7 @@ data class SchoolConfigSetting( override val lastModified: InstantAsISO8601 = Clock.System.now(), override val stored: InstantAsISO8601 = Clock.System.now(), val canRead: List = listOf(), + val canWrite: List = listOf(), ) : ModelWithTimes { companion object { From 684eeff3a646fdd5c4e45a87f9e8b0c0f829a7ae Mon Sep 17 00:00:00 2001 From: Anugraha Date: Mon, 23 Mar 2026 16:46:43 +0530 Subject: [PATCH 02/10] implement SchoolConfigSettingDataSource for db, http, and repository --- .../datalayer/db/SchoolDataSourceDb.kt | 8 +- .../school/SchoolConfigSettingDataSourceDb.kt | 74 +++++++++-- .../daos/SchoolConfigSettingEntityDao.kt | 62 +++++++++- .../datalayer/http/SchoolDataSourceHttp.kt | 10 +- .../SchoolConfigSettingDataSourceHttp.kt | 117 ++++++++++++++++++ .../repository/SchoolDataSourceRepository.kt | 10 +- ...SchoolConfigSettingDataSourceRepository.kt | 92 ++++++++++++++ .../respect/datalayer/DataLayerParams.kt | 2 + .../DummySchoolConfigSettingsDataSource.kt | 66 ---------- .../school/SchoolConfigSettingDataSource.kt | 4 +- .../school/writequeue/WriteQueueItem.kt | 1 + .../world/respect/server/Application.kt | 2 + .../respect/SchoolConfigSettingRoute.kt | 45 +++++++ 13 files changed, 407 insertions(+), 86 deletions(-) create mode 100644 respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt create mode 100644 respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingDataSourceRepository.kt delete mode 100644 respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/DummySchoolConfigSettingsDataSource.kt create mode 100644 respect-server/src/main/kotlin/world/respect/server/routes/school/respect/SchoolConfigSettingRoute.kt 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 503854c9d..583fa216b 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 @@ -4,8 +4,6 @@ import kotlinx.serialization.json.Json import world.respect.datalayer.AuthenticatedUserPrincipalId import world.respect.datalayer.SchoolDataSourceLocal 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.ClassDatasourceDb import world.respect.datalayer.db.school.EnrollmentDataSourceDb @@ -20,9 +18,10 @@ import world.respect.datalayer.db.school.ReportDataSourceDb import world.respect.datalayer.db.school.SchoolAppDataSourceDb import world.respect.datalayer.db.school.SchoolConfigSettingDataSourceDb import world.respect.datalayer.db.school.SchoolPermissionGrantDataSourceDb +import world.respect.datalayer.db.school.opds.OpdsFeedDataSourceDb +import world.respect.datalayer.db.school.opds.OpdsPublicationDataSourceDb import world.respect.datalayer.school.AssignmentDataSourceLocal import world.respect.datalayer.school.ClassDataSourceLocal -import world.respect.datalayer.school.DummySchoolConfigSettingsDataSource import world.respect.datalayer.school.EnrollmentDataSourceLocal import world.respect.datalayer.school.IndicatorDataSource import world.respect.datalayer.school.InviteDataSourceLocal @@ -32,11 +31,10 @@ import world.respect.datalayer.school.PersonPasswordDataSourceLocal import world.respect.datalayer.school.PersonQrCodeBadgeDataSourceLocal import world.respect.datalayer.school.ReportDataSourceLocal import world.respect.datalayer.school.SchoolAppDataSourceLocal -import world.respect.datalayer.school.SchoolConfigSettingDataSource import world.respect.datalayer.school.SchoolPermissionGrantDataSourceLocal import world.respect.datalayer.school.domain.CheckPersonPermissionUseCase -import world.respect.datalayer.school.opds.OpdsPublicationDataSourceLocal import world.respect.datalayer.school.opds.OpdsFeedDataSourceLocal +import world.respect.datalayer.school.opds.OpdsPublicationDataSourceLocal import world.respect.lib.primarykeygen.PrimaryKeyGenerator /** diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt index d13832cc0..781fcb690 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt @@ -1,14 +1,26 @@ 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.AuthenticatedUserPrincipalId +import world.respect.datalayer.DataLoadMetaInfo import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataLoadState +import world.respect.datalayer.DataReadyState +import world.respect.datalayer.NoDataLoadedState import world.respect.datalayer.db.RespectSchoolDatabase +import world.respect.datalayer.db.school.adapters.asEntity +import world.respect.datalayer.db.school.adapters.asModel import world.respect.datalayer.school.SchoolConfigSettingDataSource import world.respect.datalayer.school.SchoolConfigSettingDataSourceLocal import world.respect.datalayer.school.model.SchoolConfigSetting +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 kotlin.time.Clock class SchoolConfigSettingDataSourceDb( private val schoolDb: RespectSchoolDatabase, @@ -19,42 +31,88 @@ class SchoolConfigSettingDataSourceDb( params: DataLoadParams, guid: String ): DataLoadState { - TODO("Not yet implemented") + return schoolDb.getSchoolConfigSettingEntityDao().findByKey(guid) + ?.asModel()?.let { DataReadyState(it) } ?: NoDataLoadedState.notFound() } override fun listAsFlow( loadParams: DataLoadParams, params: SchoolConfigSettingDataSource.GetListParams ): Flow>> { - TODO("Not yet implemented") + return schoolDb.getSchoolConfigSettingEntityDao().listAsFlow( + key = params.key, + since = params.common.since?.toEpochMilliseconds() ?: 0 + ).map { list -> + DataReadyState( + data = list.map { it.asModel() } + ) + } } override fun listAsPagingSource( loadParams: DataLoadParams, params: SchoolConfigSettingDataSource.GetListParams ): IPagingSourceFactory { - TODO("Not yet implemented") + return IPagingSourceFactory { + schoolDb.getSchoolConfigSettingEntityDao().listAsPagingSource( + key = params.key, + since = params.common.since?.toEpochMilliseconds() ?: 0 + ).map(tag = { "SchoolConfigSettingDataSourceDb/listAsPagingSource(params=$params)" }) { + it.asModel() + } + } } override suspend fun list( loadParams: DataLoadParams, params: SchoolConfigSettingDataSource.GetListParams ): DataLoadState> { - TODO("Not yet implemented") + val queryTime = Clock.System.now() + val data = schoolDb.getSchoolConfigSettingEntityDao().list( + key = params.key, + since = params.common.since?.toEpochMilliseconds() ?: 0 + ).map { it.asModel() } + + return DataReadyState( + data = data, + metaInfo = DataLoadMetaInfo( + lastModified = data.maxLastModifiedOrNull()?.toEpochMilliseconds() ?: -1, + lastStored = data.maxLastStoredOrNull()?.toEpochMilliseconds() ?: -1, + consistentThrough = queryTime, + ) + ) } override suspend fun store(list: List) { - TODO("Not yet implemented") + if (list.isEmpty()) return + schoolDb.useWriterConnection { con -> + con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + schoolDb.getSchoolConfigSettingEntityDao().insert( + list.map { it.copy(stored = Clock.System.now()).asEntity() } + ) + } + } } override suspend fun updateLocal( list: List, forceOverwrite: Boolean ) { - TODO("Not yet implemented") + if (list.isEmpty()) return + schoolDb.useWriterConnection { con -> + con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + list.filter { item -> + forceOverwrite || schoolDb.getSchoolConfigSettingEntityDao().getLastModifiedByKey( + item.key + ).let { it ?: 0L } < item.lastModified.toEpochMilliseconds() + }.forEach { item -> + schoolDb.getSchoolConfigSettingEntityDao().insert(item.asEntity()) + } + } + } } override suspend fun findByUidList(uids: List): List { - TODO("Not yet implemented") + return schoolDb.getSchoolConfigSettingEntityDao().findByKeys(uids).map { it.asModel() } } -} \ No newline at end of file +} diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt index deae28cdf..e54b1bde0 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt @@ -1,6 +1,9 @@ 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 kotlinx.coroutines.flow.Flow import world.respect.datalayer.db.school.entities.SchoolConfigSettingEntity @@ -8,6 +11,34 @@ import world.respect.datalayer.db.school.entities.SchoolConfigSettingEntity @Dao interface SchoolConfigSettingEntityDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entity: SchoolConfigSettingEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entities: List) + + @Query(""" + SELECT SchoolConfigSettingEntity.* + FROM SchoolConfigSettingEntity + WHERE scsKey = :key + LIMIT 1 + """) + suspend fun findByKey(key: String): SchoolConfigSettingEntity? + + @Query(""" + SELECT SchoolConfigSettingEntity.* + FROM SchoolConfigSettingEntity + WHERE scsKey = :key + """) + fun findByKeyAsFlow(key: String): Flow + + @Query(""" + SELECT SchoolConfigSettingEntity.* + FROM SchoolConfigSettingEntity + WHERE scsKey IN (:keys) + """) + suspend fun findByKeys(keys: List): List + @Query(""" SELECT SchoolConfigSettingEntity.* FROM SchoolConfigSettingEntity @@ -19,4 +50,33 @@ interface SchoolConfigSettingEntityDao { since: Long = 0, ): Flow> -} \ No newline at end of file + @Query(""" + SELECT SchoolConfigSettingEntity.* + FROM SchoolConfigSettingEntity + WHERE ((:key IS NULL) OR scsKey = :key) + AND ((:since = 0) OR (scsStored > :since)) + """) + suspend fun list( + key: String? = null, + since: Long = 0, + ): List + + @Query(""" + SELECT SchoolConfigSettingEntity.* + FROM SchoolConfigSettingEntity + WHERE ((:key IS NULL) OR scsKey = :key) + AND ((:since = 0) OR (scsStored > :since)) + """) + fun listAsPagingSource( + key: String? = null, + since: Long = 0, + ): PagingSource + + @Query(""" + SELECT scsLastModified + FROM SchoolConfigSettingEntity + WHERE scsKey = :key + """) + suspend fun getLastModifiedByKey(key: String): Long? + +} 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..1de1c240c 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 @@ -16,13 +16,13 @@ import world.respect.datalayer.http.school.PersonPasskeyDataSourceHttp import world.respect.datalayer.http.school.PersonPasswordDataSourceHttp import world.respect.datalayer.http.school.PersonQrBadgeDataSourceHttp import world.respect.datalayer.http.school.SchoolAppDataSourceHttp +import world.respect.datalayer.http.school.SchoolConfigSettingDataSourceHttp import world.respect.datalayer.http.school.SchoolPermissionGrantDataSourceHttp 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.ClassDataSource -import world.respect.datalayer.school.DummySchoolConfigSettingsDataSource import world.respect.datalayer.school.EnrollmentDataSource import world.respect.datalayer.school.IndicatorDataSource import world.respect.datalayer.school.InviteDataSource @@ -172,8 +172,12 @@ class SchoolDataSourceHttp( } override val schoolConfigSettingDataSource: SchoolConfigSettingDataSource by lazy { - DummySchoolConfigSettingsDataSource( - defaultAppCatalogUrl = defaultAppCatalogUrl, + SchoolConfigSettingDataSourceHttp( + schoolUrl = schoolUrl, + schoolDirectoryEntryDataSource = schoolDirectoryEntryDataSource, + httpClient = httpClient, + tokenProvider = tokenProvider, + validationHelper = validationHelper, ) } } diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt new file mode 100644 index 000000000..e3361d14f --- /dev/null +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt @@ -0,0 +1,117 @@ +package world.respect.datalayer.http.school + +import io.ktor.client.HttpClient +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.contentType +import io.ktor.util.reflect.typeInfo +import kotlinx.coroutines.flow.Flow +import world.respect.datalayer.AuthTokenProvider +import world.respect.datalayer.DataLayerParams +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.ext.firstOrNotLoaded +import world.respect.datalayer.ext.getAsDataLoadState +import world.respect.datalayer.ext.getDataLoadResultAsFlow +import world.respect.datalayer.ext.useTokenProvider +import world.respect.datalayer.ext.useValidationCacheControl +import world.respect.datalayer.http.ext.appendCommonListParams +import world.respect.datalayer.http.ext.appendIfNotNull +import world.respect.datalayer.http.ext.respectEndpointUrl +import world.respect.datalayer.http.shared.paging.OffsetLimitHttpPagingSource +import world.respect.datalayer.networkvalidation.ExtendedDataSourceValidationHelper +import world.respect.datalayer.school.SchoolConfigSettingDataSource +import world.respect.datalayer.school.model.SchoolConfigSetting +import world.respect.datalayer.schooldirectory.SchoolDirectoryEntryDataSource +import world.respect.datalayer.shared.paging.IPagingSourceFactory + +class SchoolConfigSettingDataSourceHttp( + override val schoolUrl: Url, + override val schoolDirectoryEntryDataSource: SchoolDirectoryEntryDataSource, + private val httpClient: HttpClient, + private val tokenProvider: AuthTokenProvider, + private val validationHelper: ExtendedDataSourceValidationHelper?, +) : SchoolConfigSettingDataSource, SchoolUrlBasedDataSource { + + private suspend fun SchoolConfigSettingDataSource.GetListParams.urlWithParams(): Url { + return URLBuilder(respectEndpointUrl(SchoolConfigSettingDataSource.ENDPOINT_NAME)) + .apply { + parameters.appendCommonListParams(common) + parameters.appendIfNotNull(DataLayerParams.KEY, key) + } + .build() + } + + override suspend fun findByGuid( + params: DataLoadParams, + guid: String + ): DataLoadState { + return httpClient.getAsDataLoadState>( + SchoolConfigSettingDataSource.GetListParams( + key = guid + ).urlWithParams() + ) { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + }.firstOrNotLoaded() + } + + override fun listAsFlow( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): Flow>> { + return httpClient.getDataLoadResultAsFlow>( + urlFn = { params.urlWithParams() }, + dataLoadParams = loadParams, + validationHelper = validationHelper, + ) { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + } + } + + override fun listAsPagingSource( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): IPagingSourceFactory { + return IPagingSourceFactory { + OffsetLimitHttpPagingSource( + baseUrlProvider = { params.urlWithParams() }, + httpClient = httpClient, + validationHelper = validationHelper, + typeInfo = typeInfo>(), + requestBuilder = { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + }, + logPrefixExtra = { "SchoolConfigSetting-HTTP-listAsPagingSource(params=$params)" }, + ) + } + } + + override suspend fun list( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): DataLoadState> { + return httpClient.getAsDataLoadState>( + url = params.urlWithParams(), + validationHelper = validationHelper, + ) { + useTokenProvider(tokenProvider) + useValidationCacheControl(validationHelper) + } + } + + override suspend fun store(list: List) { + httpClient.post( + url = respectEndpointUrl(SchoolConfigSettingDataSource.ENDPOINT_NAME) + ) { + useTokenProvider(tokenProvider) + contentType(ContentType.Application.Json) + setBody(list) + } + } +} 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..c19ec3356 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 @@ -14,6 +14,7 @@ import world.respect.datalayer.repository.school.PersonPasskeyDataSourceReposito import world.respect.datalayer.repository.school.PersonPasswordDataSourceRepository import world.respect.datalayer.repository.school.PersonQrCodeBadgeDataSourceRepository import world.respect.datalayer.repository.school.SchoolAppDataSourceRepository +import world.respect.datalayer.repository.school.SchoolConfigSettingDataSourceRepository import world.respect.datalayer.repository.school.SchoolPermissionGrantDataSourceRepository import world.respect.datalayer.school.IndicatorDataSource import world.respect.datalayer.school.PersonPasskeyDataSource @@ -140,6 +141,11 @@ class SchoolDataSourceRepository( } override val schoolConfigSettingDataSource: SchoolConfigSettingDataSource by lazy { - local.schoolConfigSettingDataSource + SchoolConfigSettingDataSourceRepository( + local = local.schoolConfigSettingDataSource, + remote = remote.schoolConfigSettingDataSource, + validationHelper = validationHelper, + remoteWriteQueue = remoteWriteQueue, + ) } -} \ No newline at end of file +} diff --git a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingDataSourceRepository.kt b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingDataSourceRepository.kt new file mode 100644 index 000000000..a30e0c946 --- /dev/null +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingDataSourceRepository.kt @@ -0,0 +1,92 @@ +package world.respect.datalayer.repository.school + +import kotlinx.coroutines.flow.Flow +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.DataReadyState +import world.respect.datalayer.ext.combineWithRemote +import world.respect.datalayer.ext.updateFromRemoteIfNeeded +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.SchoolConfigSettingDataSource +import world.respect.datalayer.school.SchoolConfigSettingDataSourceLocal +import world.respect.datalayer.school.model.SchoolConfigSetting +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 SchoolConfigSettingDataSourceRepository( + override val local: SchoolConfigSettingDataSourceLocal, + override val remote: SchoolConfigSettingDataSource, + private val validationHelper: ExtendedDataSourceValidationHelper, + private val remoteWriteQueue: RemoteWriteQueue, +) : SchoolConfigSettingDataSource, RepositoryModelDataSource { + + override suspend fun findByGuid( + params: DataLoadParams, + guid: String + ): DataLoadState { + if (!params.onlyIfCached) { + val remoteResult = remote.findByGuid(params, guid) + local.updateFromRemoteIfNeeded(remoteResult, validationHelper) + } + return local.findByGuid(params, guid) + } + + override fun listAsFlow( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): Flow>> { + return local.listAsFlow(loadParams, params) + } + + override fun listAsPagingSource( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): IPagingSourceFactory { + val remoteSource = remote.takeIf { !loadParams.onlyIfCached }?.listAsPagingSource( + loadParams = loadParams, + params = params + )?.invoke() + + return RepositoryPagingSourceFactory( + onRemoteLoad = { remoteLoadParams -> + remoteSource?.loadAndUpdateLocal2( + remoteLoadParams, local::updateLocal, + ) + }, + local = local.listAsPagingSource(loadParams, params), + tag = { "Repo.SchoolConfigSetting.listAsPaging(params=$params)" }, + ) + } + + override suspend fun list( + loadParams: DataLoadParams, + params: SchoolConfigSettingDataSource.GetListParams + ): DataLoadState> { + val remoteResult = remote.list(loadParams, params) + if (remoteResult is DataReadyState) { + local.updateLocal(remoteResult.data) + validationHelper.updateValidationInfo(remoteResult.metaInfo) + } + + return local.list(loadParams, params).combineWithRemote(remoteResult) + } + + override suspend fun store(list: List) { + local.store(list) + val timeNow = systemTimeInMillis() + remoteWriteQueue.add( + list.map { + WriteQueueItem( + model = WriteQueueItem.Model.SCHOOL_CONFIG_SETTING, + uid = it.key, + timeQueued = timeNow, + ) + } + ) + } +} 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..eae537376 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/DataLayerParams.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/DataLayerParams.kt @@ -12,6 +12,8 @@ object DataLayerParams { const val GUID = "guid" + const val KEY = "key" + const val INCLUDE_RELATED = "includeRelated" const val INCLUDE_DELETED = "includeDeleted" diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/DummySchoolConfigSettingsDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/DummySchoolConfigSettingsDataSource.kt deleted file mode 100644 index 792e941ec..000000000 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/DummySchoolConfigSettingsDataSource.kt +++ /dev/null @@ -1,66 +0,0 @@ -package world.respect.datalayer.school - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import world.respect.datalayer.DataLoadParams -import world.respect.datalayer.DataLoadState -import world.respect.datalayer.DataReadyState -import world.respect.datalayer.school.model.SchoolConfigSetting -import world.respect.datalayer.shared.paging.IPagingSourceFactory - -class DummySchoolConfigSettingsDataSource( - private val defaultAppCatalogUrl: String?, -): SchoolConfigSettingDataSource { - - override suspend fun findByGuid( - params: DataLoadParams, - guid: String - ): DataLoadState { - TODO("Not yet implemented") - } - - override fun listAsPagingSource( - loadParams: DataLoadParams, - params: SchoolConfigSettingDataSource.GetListParams - ): IPagingSourceFactory { - TODO("Not yet implemented") - } - - override fun listAsFlow( - loadParams: DataLoadParams, - params: SchoolConfigSettingDataSource.GetListParams - ): Flow>> { - return flowOf( - DataReadyState( - data = defaultAppCatalogUrl?.let { - listOf( - SchoolConfigSetting( - key = SchoolConfigSettingDataSource.KEY_APP_CATALOGS, - value = it, - ) - ) - } ?: emptyList() - ) - ) - } - - override suspend fun list( - loadParams: DataLoadParams, - params: SchoolConfigSettingDataSource.GetListParams - ): DataLoadState> { - return DataReadyState( - data = defaultAppCatalogUrl?.let { - listOf( - SchoolConfigSetting( - key = SchoolConfigSettingDataSource.KEY_APP_CATALOGS, - value = it, - ) - ) - } ?: emptyList() - ) - } - - override suspend fun store(list: List) { - TODO("Not yet implemented") - } -} \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt index b4ca79900..e287fb8f9 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt @@ -2,6 +2,7 @@ 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.SchoolConfigSetting @@ -20,7 +21,8 @@ interface SchoolConfigSettingDataSource: WritableDataSource fun fromParams(params: StringValues): GetListParams { return GetListParams( - common = GetListCommonParams.fromParams(params) + common = GetListCommonParams.fromParams(params), + key = params[DataLayerParams.KEY] ) } } 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..d7dfce807 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), + SCHOOL_CONFIG_SETTING(11), ; diff --git a/respect-server/src/main/kotlin/world/respect/server/Application.kt b/respect-server/src/main/kotlin/world/respect/server/Application.kt index a8b501773..667b5d76a 100644 --- a/respect-server/src/main/kotlin/world/respect/server/Application.kt +++ b/respect-server/src/main/kotlin/world/respect/server/Application.kt @@ -54,6 +54,7 @@ import world.respect.server.routes.school.respect.PersonRoute import world.respect.server.routes.school.respect.PlaylistRoute import world.respect.server.routes.school.respect.RedeemInviteRoute import world.respect.server.routes.school.respect.SchoolAppRoute +import world.respect.server.routes.school.respect.SchoolConfigSettingRoute import world.respect.server.routes.school.respect.SchoolRegistrationRoute import world.respect.server.routes.school.respect.SchoolLinkRoute import world.respect.server.routes.school.respect.SchoolPermissionGrantRoute @@ -251,6 +252,7 @@ fun Application.module() { EnrollmentRoute() AssignmentRoute() PersonQrBadgeRoute() + SchoolConfigSettingRoute() AddChildAccountRoute( addChildAccountUseCase = { it.requireAccountScope().get() } ) diff --git a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/SchoolConfigSettingRoute.kt b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/SchoolConfigSettingRoute.kt new file mode 100644 index 000000000..6546fa710 --- /dev/null +++ b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/SchoolConfigSettingRoute.kt @@ -0,0 +1,45 @@ +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 world.respect.datalayer.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.school.SchoolConfigSettingDataSource +import world.respect.datalayer.school.model.SchoolConfigSetting +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.SchoolConfigSettingRoute( + schoolDataSource: (ApplicationCall) -> SchoolDataSource = { call -> + call.requireAccountScope().get() + }, +) { + get(SchoolConfigSettingDataSource.ENDPOINT_NAME) { + call.response.header(HttpHeaders.Vary, HttpHeaders.Authorization) + call.respondOffsetLimitPaging( + params = call.request.queryParameters.offsetLimitPagingLoadParams(), + pagingSource = schoolDataSource(call).schoolConfigSettingDataSource.listAsPagingSource( + loadParams = DataLoadParams(), + params = SchoolConfigSettingDataSource.GetListParams.fromParams( + call.request.queryParameters + ) + ).invoke() + ) + } + + post(SchoolConfigSettingDataSource.ENDPOINT_NAME) { + val schoolDataSource = schoolDataSource(call) + val settings: List = call.receive() + schoolDataSource.schoolConfigSettingDataSource.store(settings) + call.respond(HttpStatusCode.NoContent) + } +} From cf5f182893501cc16bed4fdbfa27844d8e23832d Mon Sep 17 00:00:00 2001 From: Anugraha Date: Mon, 23 Mar 2026 17:21:34 +0530 Subject: [PATCH 03/10] update DrainRemoteWriteQueueUseCase --- .../datalayer/repository/SchoolDataSourceRepository.kt | 2 +- .../school/writequeue/DrainRemoteWriteQueueUseCase.kt | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 c19ec3356..d8243a036 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 @@ -140,7 +140,7 @@ class SchoolDataSourceRepository( ) } - override val schoolConfigSettingDataSource: SchoolConfigSettingDataSource by lazy { + override val schoolConfigSettingDataSource: SchoolConfigSettingDataSourceRepository by lazy { SchoolConfigSettingDataSourceRepository( local = local.schoolConfigSettingDataSource, remote = remote.schoolConfigSettingDataSource, 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..68fce5e65 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 @@ -69,6 +69,10 @@ class DrainRemoteWriteQueueUseCase( repository.inviteDataSource.sendToRemote(listOf(item)) } + WriteQueueItem.Model.SCHOOL_CONFIG_SETTING -> { + repository.schoolConfigSettingDataSource.sendToRemote(listOf(item)) + } + WriteQueueItem.Model.OPDS_FEED -> { val dataLoad = repository.opdsFeedDataSource.local.getByUrl( url = Url(item.uid), From ea17d8adade6fff485c3e2da22d1d67a65c1957d Mon Sep 17 00:00:00 2001 From: Anugraha Date: Tue, 24 Mar 2026 17:59:51 +0530 Subject: [PATCH 04/10] add permission check query --- .../datalayer/db/SchoolDataSourceDb.kt | 3 +- .../school/SchoolConfigSettingDataSourceDb.kt | 57 ++++---- .../daos/SchoolConfigSettingEntityDao.kt | 124 +++++++++++------- .../SchoolConfigSettingDataSourceHttp.kt | 22 ---- ...SchoolConfigSettingDataSourceRepository.kt | 23 ---- .../school/SchoolConfigSettingDataSource.kt | 6 - 6 files changed, 114 insertions(+), 121 deletions(-) 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 583fa216b..05780d586 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 @@ -139,6 +139,7 @@ class SchoolDataSourceDb( SchoolConfigSettingDataSourceDb( schoolDb = schoolDb, authenticatedUser = authenticatedUser, + uidNumberMapper = uidNumberMapper, ) } -} \ No newline at end of file +} diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt index 781fcb690..0093b7b9e 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt @@ -10,29 +10,35 @@ 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.asEntity import world.respect.datalayer.db.school.adapters.asModel +import world.respect.datalayer.exceptions.ForbiddenException import world.respect.datalayer.school.SchoolConfigSettingDataSource import world.respect.datalayer.school.SchoolConfigSettingDataSourceLocal import world.respect.datalayer.school.model.SchoolConfigSetting 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 kotlin.time.Clock class SchoolConfigSettingDataSourceDb( private val schoolDb: RespectSchoolDatabase, private val authenticatedUser: AuthenticatedUserPrincipalId, + private val uidNumberMapper: UidNumberMapper, ) : SchoolConfigSettingDataSourceLocal { + private val authenticatedUserUidNum: Long + get() = uidNumberMapper(authenticatedUser.guid) + override suspend fun findByGuid( params: DataLoadParams, guid: String ): DataLoadState { - return schoolDb.getSchoolConfigSettingEntityDao().findByKey(guid) - ?.asModel()?.let { DataReadyState(it) } ?: NoDataLoadedState.notFound() + return schoolDb.getSchoolConfigSettingEntityDao().findByKey( + authenticatedPersonUidNum = authenticatedUserUidNum, + key = guid + )?.asModel()?.let { DataReadyState(it) } ?: NoDataLoadedState.notFound() } override fun listAsFlow( @@ -40,6 +46,7 @@ class SchoolConfigSettingDataSourceDb( params: SchoolConfigSettingDataSource.GetListParams ): Flow>> { return schoolDb.getSchoolConfigSettingEntityDao().listAsFlow( + authenticatedPersonUidNum = authenticatedUserUidNum, key = params.key, since = params.common.since?.toEpochMilliseconds() ?: 0 ).map { list -> @@ -49,26 +56,13 @@ class SchoolConfigSettingDataSourceDb( } } - override fun listAsPagingSource( - loadParams: DataLoadParams, - params: SchoolConfigSettingDataSource.GetListParams - ): IPagingSourceFactory { - return IPagingSourceFactory { - schoolDb.getSchoolConfigSettingEntityDao().listAsPagingSource( - key = params.key, - since = params.common.since?.toEpochMilliseconds() ?: 0 - ).map(tag = { "SchoolConfigSettingDataSourceDb/listAsPagingSource(params=$params)" }) { - it.asModel() - } - } - } - override suspend fun list( loadParams: DataLoadParams, params: SchoolConfigSettingDataSource.GetListParams ): DataLoadState> { val queryTime = Clock.System.now() val data = schoolDb.getSchoolConfigSettingEntityDao().list( + authenticatedPersonUidNum = authenticatedUserUidNum, key = params.key, since = params.common.since?.toEpochMilliseconds() ?: 0 ).map { it.asModel() } @@ -87,6 +81,18 @@ class SchoolConfigSettingDataSourceDb( if (list.isEmpty()) return schoolDb.useWriterConnection { con -> con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { + list.forEach { setting -> + val lastModAndPermission = schoolDb.getSchoolConfigSettingEntityDao() + .getLastModifiedAndHasPermission( + authenticatedPersonUidNum = authenticatedUserUidNum, + key = setting.key + ) + + if (!lastModAndPermission.hasPermission) { + throw ForbiddenException() + } + } + schoolDb.getSchoolConfigSettingEntityDao().insert( list.map { it.copy(stored = Clock.System.now()).asEntity() } ) @@ -101,18 +107,23 @@ class SchoolConfigSettingDataSourceDb( if (list.isEmpty()) return schoolDb.useWriterConnection { con -> con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { - list.filter { item -> + val toInsert = list.filter { item -> forceOverwrite || schoolDb.getSchoolConfigSettingEntityDao().getLastModifiedByKey( - item.key + key = item.key ).let { it ?: 0L } < item.lastModified.toEpochMilliseconds() - }.forEach { item -> - schoolDb.getSchoolConfigSettingEntityDao().insert(item.asEntity()) + }.map { it.asEntity() } + + if (toInsert.isNotEmpty()) { + schoolDb.getSchoolConfigSettingEntityDao().insert(toInsert) } } } } override suspend fun findByUidList(uids: List): List { - return schoolDb.getSchoolConfigSettingEntityDao().findByKeys(uids).map { it.asModel() } + return schoolDb.getSchoolConfigSettingEntityDao().findByKeys( + authenticatedPersonUidNum = authenticatedUserUidNum, + keys = uids + ).map { it.asModel() } } } diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt index e54b1bde0..fc679f8c2 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt @@ -1,77 +1,51 @@ 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 kotlinx.coroutines.flow.Flow +import world.respect.datalayer.db.school.entities.LastModifiedAndPermission import world.respect.datalayer.db.school.entities.SchoolConfigSettingEntity @Dao interface SchoolConfigSettingEntityDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(entity: SchoolConfigSettingEntity) - @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entities: List) - @Query(""" - SELECT SchoolConfigSettingEntity.* - FROM SchoolConfigSettingEntity - WHERE scsKey = :key - LIMIT 1 - """) - suspend fun findByKey(key: String): SchoolConfigSettingEntity? + @Query(FIND_BY_KEY_SQL) + suspend fun findByKey( + authenticatedPersonUidNum: Long, + key: String + ): SchoolConfigSettingEntity? - @Query(""" - SELECT SchoolConfigSettingEntity.* - FROM SchoolConfigSettingEntity - WHERE scsKey = :key - """) - fun findByKeyAsFlow(key: String): Flow + @Query(FIND_BY_KEY_SQL) + fun findByKeyAsFlow( + authenticatedPersonUidNum: Long, + key: String + ): Flow - @Query(""" - SELECT SchoolConfigSettingEntity.* - FROM SchoolConfigSettingEntity - WHERE scsKey IN (:keys) - """) - suspend fun findByKeys(keys: List): List + @Query(FIND_BY_KEYS_SQL) + suspend fun findByKeys( + authenticatedPersonUidNum: Long, + keys: List + ): List - @Query(""" - SELECT SchoolConfigSettingEntity.* - FROM SchoolConfigSettingEntity - WHERE ((:key IS NULL) OR scsKey = :key) - AND ((:since = 0) OR (scsStored > :since)) - """) + @Query(LIST_SQL) fun listAsFlow( + authenticatedPersonUidNum: Long, key: String? = null, since: Long = 0, ): Flow> - @Query(""" - SELECT SchoolConfigSettingEntity.* - FROM SchoolConfigSettingEntity - WHERE ((:key IS NULL) OR scsKey = :key) - AND ((:since = 0) OR (scsStored > :since)) - """) + @Query(LIST_SQL) suspend fun list( + authenticatedPersonUidNum: Long, key: String? = null, since: Long = 0, ): List - @Query(""" - SELECT SchoolConfigSettingEntity.* - FROM SchoolConfigSettingEntity - WHERE ((:key IS NULL) OR scsKey = :key) - AND ((:since = 0) OR (scsStored > :since)) - """) - fun listAsPagingSource( - key: String? = null, - since: Long = 0, - ): PagingSource - @Query(""" SELECT scsLastModified FROM SchoolConfigSettingEntity @@ -79,4 +53,62 @@ interface SchoolConfigSettingEntityDao { """) suspend fun getLastModifiedByKey(key: String): Long? + @Query(GET_LAST_MODIFIED_AND_HAS_PERMISSION_SQL) + suspend fun getLastModifiedAndHasPermission( + authenticatedPersonUidNum: Long, + key: String + ): LastModifiedAndPermission + + companion object { + + private const val AUTHENTICATED_USER_ROLE_SQL = """ + SELECT PersonRoleEntity.prRoleEnum + FROM PersonRoleEntity + WHERE PersonRoleEntity.prPersonGuidHash = :authenticatedPersonUidNum + LIMIT 1 + """ + + private const val READ_PERMISSION_CHECK_SQL = """ + scsAnonCanRead OR (($AUTHENTICATED_USER_ROLE_SQL) & scsCanReadFlags) > 0 + """ + + private const val WRITE_PERMISSION_CHECK_SQL = """ + (($AUTHENTICATED_USER_ROLE_SQL) & scsCanWriteFlags) > 0 + """ + + private const val FIND_BY_KEY_SQL = """ + SELECT SchoolConfigSettingEntity.* + FROM SchoolConfigSettingEntity + WHERE scsKey = :key + AND ($READ_PERMISSION_CHECK_SQL) + """ + + private const val FIND_BY_KEYS_SQL = """ + SELECT SchoolConfigSettingEntity.* + FROM SchoolConfigSettingEntity + WHERE scsKey IN (:keys) + AND ($READ_PERMISSION_CHECK_SQL) + """ + + private const val LIST_SQL = """ + SELECT SchoolConfigSettingEntity.* + FROM SchoolConfigSettingEntity + WHERE ((:key IS NULL) OR scsKey = :key) + AND ((:since = 0) OR (scsStored > :since)) + AND ($READ_PERMISSION_CHECK_SQL) + """ + + private const val GET_LAST_MODIFIED_AND_HAS_PERMISSION_SQL = """ + SELECT 0 AS uidNum, + (SELECT SchoolConfigSettingEntity.scsLastModified + FROM SchoolConfigSettingEntity + WHERE SchoolConfigSettingEntity.scsKey = :key) AS lastModified, + (EXISTS ( + SELECT 1 + FROM SchoolConfigSettingEntity + WHERE SchoolConfigSettingEntity.scsKey = :key + AND ($WRITE_PERMISSION_CHECK_SQL) + )) AS hasPermission + """ + } } diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt index e3361d14f..4bf65655d 100644 --- a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt @@ -7,7 +7,6 @@ import io.ktor.http.ContentType import io.ktor.http.URLBuilder import io.ktor.http.Url import io.ktor.http.contentType -import io.ktor.util.reflect.typeInfo import kotlinx.coroutines.flow.Flow import world.respect.datalayer.AuthTokenProvider import world.respect.datalayer.DataLayerParams @@ -21,12 +20,10 @@ 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.SchoolConfigSettingDataSource import world.respect.datalayer.school.model.SchoolConfigSetting import world.respect.datalayer.schooldirectory.SchoolDirectoryEntryDataSource -import world.respect.datalayer.shared.paging.IPagingSourceFactory class SchoolConfigSettingDataSourceHttp( override val schoolUrl: Url, @@ -73,25 +70,6 @@ class SchoolConfigSettingDataSourceHttp( } } - override fun listAsPagingSource( - loadParams: DataLoadParams, - params: SchoolConfigSettingDataSource.GetListParams - ): IPagingSourceFactory { - return IPagingSourceFactory { - OffsetLimitHttpPagingSource( - baseUrlProvider = { params.urlWithParams() }, - httpClient = httpClient, - validationHelper = validationHelper, - typeInfo = typeInfo>(), - requestBuilder = { - useTokenProvider(tokenProvider) - useValidationCacheControl(validationHelper) - }, - logPrefixExtra = { "SchoolConfigSetting-HTTP-listAsPagingSource(params=$params)" }, - ) - } - } - override suspend fun list( loadParams: DataLoadParams, params: SchoolConfigSettingDataSource.GetListParams diff --git a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingDataSourceRepository.kt b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingDataSourceRepository.kt index a30e0c946..19618deca 100644 --- a/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingDataSourceRepository.kt +++ b/respect-datalayer-repository/src/commonMain/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingDataSourceRepository.kt @@ -7,15 +7,12 @@ import world.respect.datalayer.DataReadyState import world.respect.datalayer.ext.combineWithRemote import world.respect.datalayer.ext.updateFromRemoteIfNeeded 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.SchoolConfigSettingDataSource import world.respect.datalayer.school.SchoolConfigSettingDataSourceLocal import world.respect.datalayer.school.model.SchoolConfigSetting 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 SchoolConfigSettingDataSourceRepository( @@ -43,26 +40,6 @@ class SchoolConfigSettingDataSourceRepository( return local.listAsFlow(loadParams, params) } - override fun listAsPagingSource( - loadParams: DataLoadParams, - params: SchoolConfigSettingDataSource.GetListParams - ): IPagingSourceFactory { - val remoteSource = remote.takeIf { !loadParams.onlyIfCached }?.listAsPagingSource( - loadParams = loadParams, - params = params - )?.invoke() - - return RepositoryPagingSourceFactory( - onRemoteLoad = { remoteLoadParams -> - remoteSource?.loadAndUpdateLocal2( - remoteLoadParams, local::updateLocal, - ) - }, - local = local.listAsPagingSource(loadParams, params), - tag = { "Repo.SchoolConfigSetting.listAsPaging(params=$params)" }, - ) - } - override suspend fun list( loadParams: DataLoadParams, params: SchoolConfigSettingDataSource.GetListParams diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt index e287fb8f9..ffc6f5748 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt @@ -7,7 +7,6 @@ import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataLoadState import world.respect.datalayer.school.model.SchoolConfigSetting import world.respect.datalayer.shared.WritableDataSource -import world.respect.datalayer.shared.paging.IPagingSourceFactory import world.respect.datalayer.shared.params.GetListCommonParams interface SchoolConfigSettingDataSource: WritableDataSource { @@ -39,11 +38,6 @@ interface SchoolConfigSettingDataSource: WritableDataSource params: GetListParams = GetListParams(), ): Flow>> - fun listAsPagingSource( - loadParams: DataLoadParams = DataLoadParams(), - params: GetListParams = GetListParams(), - ): IPagingSourceFactory - suspend fun list( loadParams: DataLoadParams = DataLoadParams(), params: GetListParams = GetListParams(), From 06cee38c2872d01b34004996ea5813b7d1f29e5b Mon Sep 17 00:00:00 2001 From: Anugraha Date: Wed, 25 Mar 2026 13:13:04 +0530 Subject: [PATCH 05/10] fix build failure --- .../routes/school/respect/SchoolConfigSettingRoute.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/SchoolConfigSettingRoute.kt b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/SchoolConfigSettingRoute.kt index 6546fa710..f15df437e 100644 --- a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/SchoolConfigSettingRoute.kt +++ b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/SchoolConfigSettingRoute.kt @@ -13,9 +13,8 @@ import world.respect.datalayer.DataLoadParams import world.respect.datalayer.SchoolDataSource import world.respect.datalayer.school.SchoolConfigSettingDataSource import world.respect.datalayer.school.model.SchoolConfigSetting -import world.respect.server.util.ext.offsetLimitPagingLoadParams import world.respect.server.util.ext.requireAccountScope -import world.respect.server.util.ext.respondOffsetLimitPaging +import world.respect.server.util.ext.respondDataLoadState @Suppress("FunctionName") fun Route.SchoolConfigSettingRoute( @@ -25,14 +24,13 @@ fun Route.SchoolConfigSettingRoute( ) { get(SchoolConfigSettingDataSource.ENDPOINT_NAME) { call.response.header(HttpHeaders.Vary, HttpHeaders.Authorization) - call.respondOffsetLimitPaging( - params = call.request.queryParameters.offsetLimitPagingLoadParams(), - pagingSource = schoolDataSource(call).schoolConfigSettingDataSource.listAsPagingSource( + call.respondDataLoadState( + schoolDataSource(call).schoolConfigSettingDataSource.list( loadParams = DataLoadParams(), params = SchoolConfigSettingDataSource.GetListParams.fromParams( call.request.queryParameters ) - ).invoke() + ) ) } From a92896fcb939b993687ea4e723c4a5011f268bf2 Mon Sep 17 00:00:00 2001 From: Anugraha Date: Thu, 26 Mar 2026 16:09:17 +0530 Subject: [PATCH 06/10] add db migration --- .../datalayer/db/RespectSchoolDatabaseMigrations.kt | 10 +++++++--- .../respect/datalayer/school/model/PersonRoleEnum.kt | 8 ++++---- 2 files changed, 11 insertions(+), 7 deletions(-) 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 0d0ec033e..fbb9a26dc 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 @@ -21,8 +21,13 @@ val MIGRATION_11_12 = object: Migration(11, 12) { val MIGRATION_12_13 = object: Migration(12, 13) { override fun migrate(connection: SQLiteConnection) { - //HERE: IMPORTANT: Need to run an update to change the flags in database on - //existing fields including permission grants. + + connection.execSQL("UPDATE PersonRoleEntity SET prRoleEnum = CASE WHEN prRoleEnum = 3 THEN 4 WHEN prRoleEnum = 4 THEN 8 WHEN prRoleEnum = 5 THEN 16 ELSE prRoleEnum END WHERE prRoleEnum IN (3, 4, 5)") + + connection.execSQL("UPDATE SchoolPermissionGrantEntity SET spgToRole = CASE WHEN spgToRole = 3 THEN 4 WHEN spgToRole = 4 THEN 8 WHEN spgToRole = 5 THEN 16 ELSE spgToRole END WHERE spgToRole IN (3, 4, 5)") + + connection.execSQL("UPDATE InviteEntity SET iNewUserRole = CASE WHEN iNewUserRole = 3 THEN 4 WHEN iNewUserRole = 4 THEN 8 WHEN iNewUserRole = 5 THEN 16 ELSE iNewUserRole END WHERE iNewUserRole IN (3, 4, 5)") + connection.execSQL("CREATE TABLE IF NOT EXISTS `SchoolConfigSettingEntity` (`scsKey` TEXT NOT NULL, `scsValue` TEXT NOT NULL, `scsStatus` INTEGER NOT NULL, `scsLastModified` INTEGER NOT NULL, `scsStored` INTEGER NOT NULL, `scsCanReadFlags` INTEGER NOT NULL, `scsAnonCanRead` INTEGER NOT NULL, `scsCanWriteFlags` INTEGER NOT NULL, PRIMARY KEY(`scsKey`))") } } @@ -35,4 +40,3 @@ fun RoomDatabase.Builder.addCommonMigrations( MIGRATION_12_13, ) } - diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonRoleEnum.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonRoleEnum.kt index ac40607bb..4bfc11837 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonRoleEnum.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonRoleEnum.kt @@ -22,11 +22,11 @@ enum class PersonRoleEnum(val value: String, val flag: Int) { const val STUDENT_INT = 2 - const val SYSTEM_ADMINISTRATOR_INT = 3 + const val SYSTEM_ADMINISTRATOR_INT = 4 - const val TEACHER_INT = 4 + const val TEACHER_INT = 8 - const val PARENT_INT = 5 + const val PARENT_INT = 16 fun fromValue(value: String): PersonRoleEnum { @@ -39,7 +39,7 @@ enum class PersonRoleEnum(val value: String, val flag: Int) { fun unfoldFromFlag(flag: Int): List { return entries.filter { enum -> - flag.and(enum.flag) == flag + (flag and enum.flag) == enum.flag } } From c0fe5a3f603bc350ed2c560eb3667701a2ed9746 Mon Sep 17 00:00:00 2001 From: Anugraha Date: Mon, 30 Mar 2026 16:53:46 +0530 Subject: [PATCH 07/10] Update SchoolConfigSettingDataSource.GetListParams to use a list of keys instead of a single key --- .../school/SchoolConfigSettingDataSourceDb.kt | 12 +++--- .../daos/SchoolConfigSettingEntityDao.kt | 38 ++----------------- .../SchoolConfigSettingDataSourceHttp.kt | 4 +- .../respect/datalayer/DataLayerParams.kt | 2 + .../school/SchoolConfigSettingDataSource.kt | 6 +-- .../viewmodel/apps/list/AppListViewModel.kt | 2 +- 6 files changed, 17 insertions(+), 47 deletions(-) diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt index 0093b7b9e..7c655b87f 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt @@ -35,10 +35,10 @@ class SchoolConfigSettingDataSourceDb( params: DataLoadParams, guid: String ): DataLoadState { - return schoolDb.getSchoolConfigSettingEntityDao().findByKey( + return schoolDb.getSchoolConfigSettingEntityDao().list( authenticatedPersonUidNum = authenticatedUserUidNum, - key = guid - )?.asModel()?.let { DataReadyState(it) } ?: NoDataLoadedState.notFound() + keys = listOf(guid) + ).firstOrNull()?.asModel()?.let { DataReadyState(it) } ?: NoDataLoadedState.notFound() } override fun listAsFlow( @@ -47,7 +47,7 @@ class SchoolConfigSettingDataSourceDb( ): Flow>> { return schoolDb.getSchoolConfigSettingEntityDao().listAsFlow( authenticatedPersonUidNum = authenticatedUserUidNum, - key = params.key, + keys = params.keys, since = params.common.since?.toEpochMilliseconds() ?: 0 ).map { list -> DataReadyState( @@ -63,7 +63,7 @@ class SchoolConfigSettingDataSourceDb( val queryTime = Clock.System.now() val data = schoolDb.getSchoolConfigSettingEntityDao().list( authenticatedPersonUidNum = authenticatedUserUidNum, - key = params.key, + keys = params.keys, since = params.common.since?.toEpochMilliseconds() ?: 0 ).map { it.asModel() } @@ -121,7 +121,7 @@ class SchoolConfigSettingDataSourceDb( } override suspend fun findByUidList(uids: List): List { - return schoolDb.getSchoolConfigSettingEntityDao().findByKeys( + return schoolDb.getSchoolConfigSettingEntityDao().list( authenticatedPersonUidNum = authenticatedUserUidNum, keys = uids ).map { it.asModel() } diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt index fc679f8c2..24795ffcf 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt @@ -14,35 +14,17 @@ interface SchoolConfigSettingEntityDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entities: List) - @Query(FIND_BY_KEY_SQL) - suspend fun findByKey( - authenticatedPersonUidNum: Long, - key: String - ): SchoolConfigSettingEntity? - - @Query(FIND_BY_KEY_SQL) - fun findByKeyAsFlow( - authenticatedPersonUidNum: Long, - key: String - ): Flow - - @Query(FIND_BY_KEYS_SQL) - suspend fun findByKeys( - authenticatedPersonUidNum: Long, - keys: List - ): List - @Query(LIST_SQL) fun listAsFlow( authenticatedPersonUidNum: Long, - key: String? = null, + keys: List? = null, since: Long = 0, ): Flow> @Query(LIST_SQL) suspend fun list( authenticatedPersonUidNum: Long, - key: String? = null, + keys: List? = null, since: Long = 0, ): List @@ -76,24 +58,10 @@ interface SchoolConfigSettingEntityDao { (($AUTHENTICATED_USER_ROLE_SQL) & scsCanWriteFlags) > 0 """ - private const val FIND_BY_KEY_SQL = """ - SELECT SchoolConfigSettingEntity.* - FROM SchoolConfigSettingEntity - WHERE scsKey = :key - AND ($READ_PERMISSION_CHECK_SQL) - """ - - private const val FIND_BY_KEYS_SQL = """ - SELECT SchoolConfigSettingEntity.* - FROM SchoolConfigSettingEntity - WHERE scsKey IN (:keys) - AND ($READ_PERMISSION_CHECK_SQL) - """ - private const val LIST_SQL = """ SELECT SchoolConfigSettingEntity.* FROM SchoolConfigSettingEntity - WHERE ((:key IS NULL) OR scsKey = :key) + WHERE ((:keys IS NULL) OR scsKey IN (:keys)) AND ((:since = 0) OR (scsStored > :since)) AND ($READ_PERMISSION_CHECK_SQL) """ diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt index 4bf65655d..68f6617f9 100644 --- a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt @@ -37,7 +37,7 @@ class SchoolConfigSettingDataSourceHttp( return URLBuilder(respectEndpointUrl(SchoolConfigSettingDataSource.ENDPOINT_NAME)) .apply { parameters.appendCommonListParams(common) - parameters.appendIfNotNull(DataLayerParams.KEY, key) + keys?.forEach { parameters.append(DataLayerParams.KEYS, it) } } .build() } @@ -48,7 +48,7 @@ class SchoolConfigSettingDataSourceHttp( ): DataLoadState { return httpClient.getAsDataLoadState>( SchoolConfigSettingDataSource.GetListParams( - key = guid + keys = listOf(guid) ).urlWithParams() ) { useTokenProvider(tokenProvider) 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 eae537376..3501e8514 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/DataLayerParams.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/DataLayerParams.kt @@ -14,6 +14,8 @@ object DataLayerParams { const val KEY = "key" + const val KEYS = "keys" + const val INCLUDE_RELATED = "includeRelated" const val INCLUDE_DELETED = "includeDeleted" diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt index ffc6f5748..3b76226bf 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt @@ -13,7 +13,7 @@ interface SchoolConfigSettingDataSource: WritableDataSource data class GetListParams( val common: GetListCommonParams = GetListCommonParams(), - val key: String? = null, + val keys: List? = null, ) { companion object { @@ -21,7 +21,7 @@ interface SchoolConfigSettingDataSource: WritableDataSource fun fromParams(params: StringValues): GetListParams { return GetListParams( common = GetListCommonParams.fromParams(params), - key = params[DataLayerParams.KEY] + keys = params.getAll(DataLayerParams.KEYS) ?: params[DataLayerParams.KEY]?.let { listOf(it) } ) } } @@ -52,4 +52,4 @@ interface SchoolConfigSettingDataSource: WritableDataSource const val KEY_APP_CATALOGS = "app-catalogs" } -} \ No newline at end of file +} diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/list/AppListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/list/AppListViewModel.kt index 407af21b2..aa953547a 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/list/AppListViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/apps/list/AppListViewModel.kt @@ -60,7 +60,7 @@ class AppListViewModel( schoolDataSource.schoolConfigSettingDataSource.listAsFlow( loadParams = DataLoadParams(), params = SchoolConfigSettingDataSource.GetListParams( - key = SchoolConfigSettingDataSource.KEY_APP_CATALOGS + keys = listOf(SchoolConfigSettingDataSource.KEY_APP_CATALOGS) ) ).collectLatest { config -> val feedUrl = config.dataOrNull()?.firstOrNull()?.value?.let { From d6687537767cc2cd5abbe0db73b255b784da4e4f Mon Sep 17 00:00:00 2001 From: Anugraha Date: Thu, 2 Apr 2026 17:02:39 +0530 Subject: [PATCH 08/10] add refactor --- .../datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt | 3 +-- .../respect/datalayer/school/SchoolConfigSettingDataSource.kt | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt index 68f6617f9..325e15e82 100644 --- a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/SchoolConfigSettingDataSourceHttp.kt @@ -18,7 +18,6 @@ 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.networkvalidation.ExtendedDataSourceValidationHelper import world.respect.datalayer.school.SchoolConfigSettingDataSource @@ -37,7 +36,7 @@ class SchoolConfigSettingDataSourceHttp( return URLBuilder(respectEndpointUrl(SchoolConfigSettingDataSource.ENDPOINT_NAME)) .apply { parameters.appendCommonListParams(common) - keys?.forEach { parameters.append(DataLayerParams.KEYS, it) } + keys?.let { parameters.appendAll(DataLayerParams.KEYS, it) } } .build() } diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt index 3b76226bf..b624332c1 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/SchoolConfigSettingDataSource.kt @@ -21,7 +21,7 @@ interface SchoolConfigSettingDataSource: WritableDataSource fun fromParams(params: StringValues): GetListParams { return GetListParams( common = GetListCommonParams.fromParams(params), - keys = params.getAll(DataLayerParams.KEYS) ?: params[DataLayerParams.KEY]?.let { listOf(it) } + keys = params.getAll(DataLayerParams.KEYS) ) } } From eebc0afb2b0e86afad897bd63a2660519a4dc6f0 Mon Sep 17 00:00:00 2001 From: Anugraha Date: Mon, 6 Apr 2026 16:18:42 +0530 Subject: [PATCH 09/10] add unittest for school config --- .../school/SchoolConfigSettingDataSourceDb.kt | 11 +- .../daos/SchoolConfigSettingEntityDao.kt | 20 +- .../SchoolConfigSettingIntegrationTest.kt | 196 ++++++++++++++++++ 3 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingIntegrationTest.kt diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt index 7c655b87f..5433d2d3e 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/SchoolConfigSettingDataSourceDb.kt @@ -17,6 +17,7 @@ import world.respect.datalayer.db.school.adapters.asModel import world.respect.datalayer.exceptions.ForbiddenException import world.respect.datalayer.school.SchoolConfigSettingDataSource import world.respect.datalayer.school.SchoolConfigSettingDataSourceLocal +import world.respect.datalayer.school.ext.foldToFlag import world.respect.datalayer.school.model.SchoolConfigSetting import world.respect.datalayer.shared.maxLastModifiedOrNull import world.respect.datalayer.shared.maxLastStoredOrNull @@ -85,7 +86,8 @@ class SchoolConfigSettingDataSourceDb( val lastModAndPermission = schoolDb.getSchoolConfigSettingEntityDao() .getLastModifiedAndHasPermission( authenticatedPersonUidNum = authenticatedUserUidNum, - key = setting.key + key = setting.key, + canWriteRolesMask = setting.canWrite.foldToFlag() ) if (!lastModAndPermission.hasPermission) { @@ -108,9 +110,10 @@ class SchoolConfigSettingDataSourceDb( schoolDb.useWriterConnection { con -> con.withTransaction(Transactor.SQLiteTransactionType.IMMEDIATE) { val toInsert = list.filter { item -> - forceOverwrite || schoolDb.getSchoolConfigSettingEntityDao().getLastModifiedByKey( - key = item.key - ).let { it ?: 0L } < item.lastModified.toEpochMilliseconds() + forceOverwrite || schoolDb.getSchoolConfigSettingEntityDao() + .getLastModifiedByKey( + key = item.key + ).let { it ?: 0L } < item.lastModified.toEpochMilliseconds() }.map { it.asEntity() } if (toInsert.isNotEmpty()) { diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt index 24795ffcf..f587ee3af 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/SchoolConfigSettingEntityDao.kt @@ -38,7 +38,8 @@ interface SchoolConfigSettingEntityDao { @Query(GET_LAST_MODIFIED_AND_HAS_PERMISSION_SQL) suspend fun getLastModifiedAndHasPermission( authenticatedPersonUidNum: Long, - key: String + key: String, + canWriteRolesMask: Int = 0 ): LastModifiedAndPermission companion object { @@ -61,9 +62,9 @@ interface SchoolConfigSettingEntityDao { private const val LIST_SQL = """ SELECT SchoolConfigSettingEntity.* FROM SchoolConfigSettingEntity - WHERE ((:keys IS NULL) OR scsKey IN (:keys)) - AND ((:since = 0) OR (scsStored > :since)) - AND ($READ_PERMISSION_CHECK_SQL) + WHERE scsKey IN (:keys) + AND SchoolConfigSettingEntity.scsStored > :since + AND ($READ_PERMISSION_CHECK_SQL) """ private const val GET_LAST_MODIFIED_AND_HAS_PERMISSION_SQL = """ @@ -71,12 +72,19 @@ interface SchoolConfigSettingEntityDao { (SELECT SchoolConfigSettingEntity.scsLastModified FROM SchoolConfigSettingEntity WHERE SchoolConfigSettingEntity.scsKey = :key) AS lastModified, - (EXISTS ( + ( + -- for existing records + EXISTS ( SELECT 1 FROM SchoolConfigSettingEntity WHERE SchoolConfigSettingEntity.scsKey = :key AND ($WRITE_PERMISSION_CHECK_SQL) - )) AS hasPermission + ) + OR + -- for new records (using the passed mask) + (NOT EXISTS (SELECT 1 FROM SchoolConfigSettingEntity WHERE scsKey = :key) + AND ($AUTHENTICATED_USER_ROLE_SQL) & :canWriteRolesMask > 0) + ) AS hasPermission """ } } diff --git a/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingIntegrationTest.kt b/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingIntegrationTest.kt new file mode 100644 index 000000000..5aee4ef90 --- /dev/null +++ b/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingIntegrationTest.kt @@ -0,0 +1,196 @@ +package world.respect.datalayer.repository.school + +import io.github.aakira.napier.DebugAntilog +import io.github.aakira.napier.Napier +import io.ktor.server.routing.route +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import world.respect.datalayer.AuthenticatedUserPrincipalId +import world.respect.datalayer.exceptions.ForbiddenException +import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.SchoolConfigSettingDataSource +import world.respect.datalayer.school.ext.foldToFlag +import world.respect.datalayer.school.model.Person +import world.respect.datalayer.school.model.PersonGenderEnum +import world.respect.datalayer.school.model.PersonRole +import world.respect.datalayer.school.model.PersonRoleEnum +import world.respect.datalayer.school.model.SchoolConfigSetting +import world.respect.lib.test.clientservertest.clientServerDatasourceTest +import world.respect.server.routes.school.respect.SchoolConfigSettingRoute +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SchoolConfigSettingIntegrationTest { + + @Rule + @JvmField + val temporaryFolder: TemporaryFolder = TemporaryFolder() + + @BeforeTest + fun setup() { + Napier.base(DebugAntilog()) + } + + private val teacherUser = Person( + guid = "teacher-1", + givenName = "Teacher", + familyName = "One", + gender = PersonGenderEnum.UNSPECIFIED, + roles = listOf(PersonRole(true, PersonRoleEnum.TEACHER)) + ) + + @Test + fun givenAdminUser_whenStoreSchoolConfigSetting_thenDataIsPersisted() { + runBlocking { + clientServerDatasourceTest(temporaryFolder.newFolder("test")) { + serverRouting { + route("api/school/respect") { + SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) + } + } + + server.start() + + val testSetting = SchoolConfigSetting( + key = "test-key", + value = "test-value", + canRead = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR), + canWrite = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR) + ) + + val adminUidNum = stringHasher.hash(adminUserId.guid) + serverDb.getSchoolConfigSettingEntityDao() + .getLastModifiedAndHasPermission( + authenticatedPersonUidNum = adminUidNum, + key = testSetting.key, + canWriteRolesMask = testSetting.canWrite.foldToFlag() + ) + + serverSchoolDataSource.schoolConfigSettingDataSource.store(listOf(testSetting)) + + val directEntity = serverDb.getSchoolConfigSettingEntityDao().list( + keys = listOf(testSetting.key), + authenticatedPersonUidNum = stringHasher.hash(adminUserId.guid), + since = 0 + ) + assertNotNull(directEntity, "Entity should exist in DB") + assertEquals(testSetting.value, directEntity.firstOrNull()?.scsValue) + } + } + } + + @Test + fun givenTeacherRole_whenRequestingAdminOnlySetting_thenNoDataReturned() { + runBlocking { + clientServerDatasourceTest(temporaryFolder.newFolder("test-neg")) { + val teacherPrincipal = AuthenticatedUserPrincipalId(teacherUser.guid) + + serverRouting { + route("api/school/respect") { + SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) + } + } + + server.start() + + + val adminOnlySetting = SchoolConfigSetting( + key = "admin-only", + value = "secret", + canRead = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR), + canWrite = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR) + ) + serverSchoolDataSource.schoolConfigSettingDataSource.store(listOf(adminOnlySetting)) + + + val teacherDbAndSource = newLocalSchoolDatabase( + temporaryFolder.newFolder("teacher-db"), + stringHasher, + teacherPrincipal + ) + val teacherLocalSource = teacherDbAndSource.second + + teacherLocalSource.personDataSource.updateLocal(listOf(teacherUser)) + + val teacherResult = teacherLocalSource.schoolConfigSettingDataSource.list( + params = SchoolConfigSettingDataSource.GetListParams(keys = listOf(adminOnlySetting.key)) + ) + + assertTrue(teacherResult.dataOrNull()?.isEmpty() ?: true, "Teacher should not be able to read admin-only setting") + } + } + } + + @Test + fun givenTeacherRole_whenTryingToStoreAdminOnlySetting_thenForbiddenExceptionThrown() { + runBlocking { + clientServerDatasourceTest(temporaryFolder.newFolder("test-forbidden")) { + val teacherPrincipal = AuthenticatedUserPrincipalId(teacherUser.guid) + + val teacherDbAndSource = newLocalSchoolDatabase( + temporaryFolder.newFolder("teacher-store-db"), + stringHasher, + teacherPrincipal + ) + val teacherLocalSource = teacherDbAndSource.second + teacherLocalSource.personDataSource.updateLocal(listOf(teacherUser)) + + val adminOnlySetting = SchoolConfigSetting( + key = "admin-only-write", + value = "attempt", + canRead = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR), + canWrite = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR) + ) + + assertFailsWith { + teacherLocalSource.schoolConfigSettingDataSource.store(listOf(adminOnlySetting)) + } + } + } + } + + + @Test + fun givenClientStoresSetting_whenDrained_thenServerHasTheData() { + runBlocking { + clientServerDatasourceTest(temporaryFolder.newFolder("test-writequeue")) { + serverRouting { + route("api/school/respect") { + SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) + } + } + + server.start() + + val client = clients.first() + client.insertServerAdminAndDefaultGrants() + + val testSetting = SchoolConfigSetting( + key = "client-key", + value = "client-value", + canRead = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR), + canWrite = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR) + ) + + client.schoolDataSource.schoolConfigSettingDataSource.store(listOf(testSetting)) + + delay(2000) + + val serverEntity = serverDb.getSchoolConfigSettingEntityDao().list( + keys = listOf(testSetting.key), + authenticatedPersonUidNum = stringHasher.hash(adminUserId.guid), + since = 0 + ) + assertNotNull(serverEntity.firstOrNull(), "Data should have been synced to server") + assertEquals("client-value", serverEntity.firstOrNull()?.scsValue) + } + } + } +} + From 5402720583d9ba56fba0956645309713800becca Mon Sep 17 00:00:00 2001 From: Anugraha Date: Tue, 7 Apr 2026 16:42:11 +0530 Subject: [PATCH 10/10] fix unit test --- .../SchoolConfigSettingIntegrationTest.kt | 196 --------- ...lConfigSettingRepositoryIntegrationTest.kt | 384 ++++++++++++++++++ 2 files changed, 384 insertions(+), 196 deletions(-) delete mode 100644 respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingIntegrationTest.kt create mode 100644 respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingRepositoryIntegrationTest.kt diff --git a/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingIntegrationTest.kt b/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingIntegrationTest.kt deleted file mode 100644 index 5aee4ef90..000000000 --- a/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingIntegrationTest.kt +++ /dev/null @@ -1,196 +0,0 @@ -package world.respect.datalayer.repository.school - -import io.github.aakira.napier.DebugAntilog -import io.github.aakira.napier.Napier -import io.ktor.server.routing.route -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import org.junit.Rule -import org.junit.rules.TemporaryFolder -import world.respect.datalayer.AuthenticatedUserPrincipalId -import world.respect.datalayer.exceptions.ForbiddenException -import world.respect.datalayer.ext.dataOrNull -import world.respect.datalayer.school.SchoolConfigSettingDataSource -import world.respect.datalayer.school.ext.foldToFlag -import world.respect.datalayer.school.model.Person -import world.respect.datalayer.school.model.PersonGenderEnum -import world.respect.datalayer.school.model.PersonRole -import world.respect.datalayer.school.model.PersonRoleEnum -import world.respect.datalayer.school.model.SchoolConfigSetting -import world.respect.lib.test.clientservertest.clientServerDatasourceTest -import world.respect.server.routes.school.respect.SchoolConfigSettingRoute -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class SchoolConfigSettingIntegrationTest { - - @Rule - @JvmField - val temporaryFolder: TemporaryFolder = TemporaryFolder() - - @BeforeTest - fun setup() { - Napier.base(DebugAntilog()) - } - - private val teacherUser = Person( - guid = "teacher-1", - givenName = "Teacher", - familyName = "One", - gender = PersonGenderEnum.UNSPECIFIED, - roles = listOf(PersonRole(true, PersonRoleEnum.TEACHER)) - ) - - @Test - fun givenAdminUser_whenStoreSchoolConfigSetting_thenDataIsPersisted() { - runBlocking { - clientServerDatasourceTest(temporaryFolder.newFolder("test")) { - serverRouting { - route("api/school/respect") { - SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) - } - } - - server.start() - - val testSetting = SchoolConfigSetting( - key = "test-key", - value = "test-value", - canRead = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR), - canWrite = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR) - ) - - val adminUidNum = stringHasher.hash(adminUserId.guid) - serverDb.getSchoolConfigSettingEntityDao() - .getLastModifiedAndHasPermission( - authenticatedPersonUidNum = adminUidNum, - key = testSetting.key, - canWriteRolesMask = testSetting.canWrite.foldToFlag() - ) - - serverSchoolDataSource.schoolConfigSettingDataSource.store(listOf(testSetting)) - - val directEntity = serverDb.getSchoolConfigSettingEntityDao().list( - keys = listOf(testSetting.key), - authenticatedPersonUidNum = stringHasher.hash(adminUserId.guid), - since = 0 - ) - assertNotNull(directEntity, "Entity should exist in DB") - assertEquals(testSetting.value, directEntity.firstOrNull()?.scsValue) - } - } - } - - @Test - fun givenTeacherRole_whenRequestingAdminOnlySetting_thenNoDataReturned() { - runBlocking { - clientServerDatasourceTest(temporaryFolder.newFolder("test-neg")) { - val teacherPrincipal = AuthenticatedUserPrincipalId(teacherUser.guid) - - serverRouting { - route("api/school/respect") { - SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) - } - } - - server.start() - - - val adminOnlySetting = SchoolConfigSetting( - key = "admin-only", - value = "secret", - canRead = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR), - canWrite = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR) - ) - serverSchoolDataSource.schoolConfigSettingDataSource.store(listOf(adminOnlySetting)) - - - val teacherDbAndSource = newLocalSchoolDatabase( - temporaryFolder.newFolder("teacher-db"), - stringHasher, - teacherPrincipal - ) - val teacherLocalSource = teacherDbAndSource.second - - teacherLocalSource.personDataSource.updateLocal(listOf(teacherUser)) - - val teacherResult = teacherLocalSource.schoolConfigSettingDataSource.list( - params = SchoolConfigSettingDataSource.GetListParams(keys = listOf(adminOnlySetting.key)) - ) - - assertTrue(teacherResult.dataOrNull()?.isEmpty() ?: true, "Teacher should not be able to read admin-only setting") - } - } - } - - @Test - fun givenTeacherRole_whenTryingToStoreAdminOnlySetting_thenForbiddenExceptionThrown() { - runBlocking { - clientServerDatasourceTest(temporaryFolder.newFolder("test-forbidden")) { - val teacherPrincipal = AuthenticatedUserPrincipalId(teacherUser.guid) - - val teacherDbAndSource = newLocalSchoolDatabase( - temporaryFolder.newFolder("teacher-store-db"), - stringHasher, - teacherPrincipal - ) - val teacherLocalSource = teacherDbAndSource.second - teacherLocalSource.personDataSource.updateLocal(listOf(teacherUser)) - - val adminOnlySetting = SchoolConfigSetting( - key = "admin-only-write", - value = "attempt", - canRead = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR), - canWrite = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR) - ) - - assertFailsWith { - teacherLocalSource.schoolConfigSettingDataSource.store(listOf(adminOnlySetting)) - } - } - } - } - - - @Test - fun givenClientStoresSetting_whenDrained_thenServerHasTheData() { - runBlocking { - clientServerDatasourceTest(temporaryFolder.newFolder("test-writequeue")) { - serverRouting { - route("api/school/respect") { - SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) - } - } - - server.start() - - val client = clients.first() - client.insertServerAdminAndDefaultGrants() - - val testSetting = SchoolConfigSetting( - key = "client-key", - value = "client-value", - canRead = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR), - canWrite = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR) - ) - - client.schoolDataSource.schoolConfigSettingDataSource.store(listOf(testSetting)) - - delay(2000) - - val serverEntity = serverDb.getSchoolConfigSettingEntityDao().list( - keys = listOf(testSetting.key), - authenticatedPersonUidNum = stringHasher.hash(adminUserId.guid), - since = 0 - ) - assertNotNull(serverEntity.firstOrNull(), "Data should have been synced to server") - assertEquals("client-value", serverEntity.firstOrNull()?.scsValue) - } - } - } -} - diff --git a/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingRepositoryIntegrationTest.kt b/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingRepositoryIntegrationTest.kt new file mode 100644 index 000000000..505e4c35e --- /dev/null +++ b/respect-datalayer-repository/src/jvmTest/kotlin/world/respect/datalayer/repository/school/SchoolConfigSettingRepositoryIntegrationTest.kt @@ -0,0 +1,384 @@ +package world.respect.datalayer.repository.school + +import app.cash.turbine.test +import io.github.aakira.napier.DebugAntilog +import io.github.aakira.napier.Napier +import io.ktor.server.routing.route +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.DataReadyState +import world.respect.datalayer.NoDataLoadedState +import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.SchoolConfigSettingDataSource +import world.respect.datalayer.school.model.PersonRoleEnum +import world.respect.datalayer.school.model.SchoolConfigSetting +import world.respect.datalayer.shared.params.GetListCommonParams +import world.respect.lib.test.clientservertest.clientServerDatasourceTest +import world.respect.server.routes.school.respect.SchoolConfigSettingRoute +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds + +class SchoolConfigSettingRepositoryIntegrationTest { + + @Rule + @JvmField + val temporaryFolder: TemporaryFolder = TemporaryFolder() + + @BeforeTest + fun setup() { + Napier.base(DebugAntilog()) + } + + private fun getTestSetting(key: String = "test-key", value: String = "test-value") = + SchoolConfigSetting( + key = key, + value = value, + canRead = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR), + canWrite = listOf(PersonRoleEnum.SYSTEM_ADMINISTRATOR) + ) + + @Test + fun givenRequestMade_whenSameRequestMadeAgain_thenRemoteDataWillReturnNotModified() { + runBlocking { + clientServerDatasourceTest(temporaryFolder.newFolder("test-not-mod")) { + serverRouting { + route("api/school/respect") { + SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) + } + } + + server.start() + + clients.first().insertServerAdminAndDefaultGrants() + + val setting = getTestSetting() + + serverSchoolDataSource.schoolConfigSettingDataSource.store(listOf(setting)) + + val params = SchoolConfigSettingDataSource.GetListParams(keys = listOf(setting.key)) + + val initData = clients.first().schoolDataSource.schoolConfigSettingDataSource.list( + loadParams = DataLoadParams(), + params = params + ) + + val validatedData = + clients.first().schoolDataSource.schoolConfigSettingDataSource.list( + loadParams = DataLoadParams(), + params = params + ) + + assertTrue(initData.dataOrNull()!!.any { it.key == setting.key }) + assertEquals( + NoDataLoadedState.Reason.NOT_MODIFIED, + (validatedData.remoteState as? NoDataLoadedState)?.reason + ) + } + } + } + + @Test + fun givenRequestMade_whenDataChangedAndSameRequestMadeAgain_thenRemoteDataWillBeLoaded() { + runBlocking { + clientServerDatasourceTest(temporaryFolder.newFolder("test-remote-load")) { + serverRouting { + route("api/school/respect") { + SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) + } + } + + server.start() + + clients.first().insertServerAdminAndDefaultGrants() + + val setting = getTestSetting() + serverSchoolDataSource.schoolConfigSettingDataSource.store(listOf(setting)) + + + val params = SchoolConfigSettingDataSource.GetListParams(keys = listOf(setting.key)) + val initData = clients.first().schoolDataSource.schoolConfigSettingDataSource.list( + loadParams = DataLoadParams(), + params = params + ) + + val updatedValue = "updated" + //DataSource will need to reject same-second changes and respond with a wait message. + Thread.sleep(2_000) + + serverSchoolDataSource.schoolConfigSettingDataSource.store( + listOf( + setting.copy( + value = updatedValue, + lastModified = Clock.System.now(), + ) + ) + ) + + val newData = clients.first().schoolDataSource.schoolConfigSettingDataSource.list( + loadParams = DataLoadParams(), + params = params + ) + + assertTrue(initData.dataOrNull()!!.any { it.key == setting.key }) + assertTrue(newData.remoteState is DataReadyState) + assertEquals( + updatedValue, + newData.dataOrNull()!!.first { it.key == setting.key }.value + ) + } + } + } + + @Test + fun givenRequestMade_whenNextRequestSinceParamSetToPreviousConsistentThroughValue_thenRemoteResultShouldBeEmpty() { + runBlocking { + clientServerDatasourceTest(temporaryFolder.newFolder("test-since-empty")) { + serverRouting { + route("api/school/respect") { + SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) + } + } + + server.start() + + val client = clients.first() + client.insertServerAdminAndDefaultGrants() + + val setting = getTestSetting() + serverSchoolDataSource.schoolConfigSettingDataSource.store(listOf(setting)) + + val startTime = Clock.System.now() + val params = SchoolConfigSettingDataSource.GetListParams(keys = listOf(setting.key)) + val initData = client.schoolDataSource.schoolConfigSettingDataSource.list( + loadParams = DataLoadParams(), + params = params + ) + + val answer1ConsistentThrough = initData.remoteState?.metaInfo?.consistentThrough!! + assertTrue(answer1ConsistentThrough >= startTime) + + val dataSince = client.schoolDataSource.schoolConfigSettingDataSource.list( + loadParams = DataLoadParams(), + params = SchoolConfigSettingDataSource.GetListParams( + keys = listOf(setting.key), + common = GetListCommonParams( + since = answer1ConsistentThrough + ) + ) + ) + + val remoteDataState = dataSince.remoteState + assertTrue(remoteDataState is DataReadyState) + val remoteData = remoteDataState.data as List<*> + assertEquals(0, remoteData.size) + } + } + } + + @Test + fun givenRequestMade_whenDataChangedAndNextRequestSinceParamSetToPreviousConsistentThroughValue_thenRemoteResultShouldBeUpdated() { + runBlocking { + clientServerDatasourceTest(temporaryFolder.newFolder("test-since-updated")) { + serverRouting { + route("api/school/respect") { + SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) + } + } + + server.start() + + val client = clients.first() + client.insertServerAdminAndDefaultGrants() + + val setting = getTestSetting() + serverSchoolDataSource.schoolConfigSettingDataSource.store(listOf(setting)) + + val startTime = Clock.System.now() + val params = SchoolConfigSettingDataSource.GetListParams(keys = listOf(setting.key)) + val initData = client.schoolDataSource.schoolConfigSettingDataSource.list( + loadParams = DataLoadParams(), + params = params + ) + + val answer1ConsistentThrough = initData.remoteState?.metaInfo?.consistentThrough!! + assertTrue(answer1ConsistentThrough >= startTime) + + val updatedValue = "updated" + serverSchoolDataSource.schoolConfigSettingDataSource.store( + listOf( + setting.copy( + value = updatedValue, + lastModified = Clock.System.now() + ) + ) + ) + + val dataSince = client.schoolDataSource.schoolConfigSettingDataSource.list( + loadParams = DataLoadParams(), + params = SchoolConfigSettingDataSource.GetListParams( + keys = listOf(setting.key), + common = GetListCommonParams( + since = answer1ConsistentThrough + ) + ) + ) + + val remoteDataState = dataSince.remoteState + assertTrue(remoteDataState is DataReadyState) + @Suppress("UNCHECKED_CAST") + val remoteData = remoteDataState.data as List + assertEquals(1, remoteData.size) + assertEquals(updatedValue, remoteData.first().value) + } + } + } + + @Test + fun givenSettingWrittenLocally_whenStored_thenWillSendToRemote() { + runBlocking { + clientServerDatasourceTest(temporaryFolder.newFolder("test-sync")) { + serverRouting { + route("api/school/respect") { + SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) + } + } + + server.start() + + val client = clients.first() + client.insertServerAdminAndDefaultGrants() + + val setting = getTestSetting("sync-key", "sync-value") + + client.schoolDataSource.schoolConfigSettingDataSource.store( + listOf(setting) + ) + + serverSchoolDataSource.schoolConfigSettingDataSource.listAsFlow( + params = SchoolConfigSettingDataSource.GetListParams(keys = listOf(setting.key)) + ).filter { + it is DataReadyState && it.data.any { s -> s.key == setting.key } + }.test(timeout = 30.seconds) { + val item = awaitItem() + assertEquals( + "sync-value", + item.dataOrNull()?.first { it.key == setting.key }?.value + ) + } + } + } + } + + @Test + fun givenFindByGuid_whenDataNotInLocalCache_thenFetchesFromRemote() { + runBlocking { + clientServerDatasourceTest(temporaryFolder.newFolder("test-find-by-guid")) { + serverRouting { + route("api/school/respect") { + SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) + } + } + + server.start() + + val client = clients.first() + client.insertServerAdminAndDefaultGrants() + + val setting = getTestSetting("guid-test-key", "guid-test-value") + serverSchoolDataSource.schoolConfigSettingDataSource.store(listOf(setting)) + + // This should fetch from remote since not in local cache + val result = client.schoolDataSource.schoolConfigSettingDataSource.findByGuid( + params = DataLoadParams(), + guid = setting.key + ) + + assertTrue(result is DataReadyState) + assertEquals(setting.value, result.data.value) + } + } + } + + @Test + fun givenFindByGuid_whenDataInLocalCacheAndOnlyIfCachedTrue_thenDoesNotHitRemote() { + runBlocking { + clientServerDatasourceTest(temporaryFolder.newFolder("test-find-by-guid-cached")) { + serverRouting { + route("api/school/respect") { + SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) + } + } + + server.start() + + val client = clients.first() + client.insertServerAdminAndDefaultGrants() + + val setting = getTestSetting("guid-cached-key", "guid-cached-value") + serverSchoolDataSource.schoolConfigSettingDataSource.store(listOf(setting)) + + // First request - populates cache + client.schoolDataSource.schoolConfigSettingDataSource.findByGuid( + params = DataLoadParams(), + guid = setting.key + ) + + // Second request with onlyIfCached = true - should not hit remote + val cachedResult = client.schoolDataSource.schoolConfigSettingDataSource.findByGuid( + params = DataLoadParams(onlyIfCached = true), + guid = setting.key + ) + + assertTrue(cachedResult is DataReadyState) + assertEquals(setting.value, cachedResult.data.value) + } + } + } + + @Test + fun givenListAsFlow_whenDataChanges_thenFlowEmitsUpdates() { + runBlocking { + clientServerDatasourceTest(temporaryFolder.newFolder("test-flow")) { + serverRouting { + route("api/school/respect") { + SchoolConfigSettingRoute(schoolDataSource = { serverSchoolDataSource }) + } + } + + server.start() + + val client = clients.first() + client.insertServerAdminAndDefaultGrants() + + val setting = getTestSetting("flow-key", "flow-value") + + client.schoolDataSource.schoolConfigSettingDataSource.listAsFlow( + loadParams = DataLoadParams(), + params = SchoolConfigSettingDataSource.GetListParams(keys = listOf(setting.key)) + ).test(timeout = 30.seconds) { + // Initially no data + val initial = awaitItem() + assertTrue(initial is DataReadyState && initial.data.isEmpty()) + + // Store data + client.schoolDataSource.schoolConfigSettingDataSource.store(listOf(setting)) + + // Should receive update with the data + val updated = awaitItem() + assertTrue(updated is DataReadyState) + assertEquals( + "flow-value", + updated.dataOrNull()?.first { it.key == setting.key }?.value + ) + } + } + } + } +}