From 78c82caa577ab7590eb83fe371a324e5c22d1b97 Mon Sep 17 00:00:00 2001 From: Mike Dawson Date: Wed, 10 Apr 2024 20:25:15 +0400 Subject: [PATCH] Implement job control for entries processed on the server (issue #750) 1. Implement support for users with permission to cancel a content entry import job that is running on the server (e.g. file upload from web, or entry being imported from a url) 2. Add display of content entry import progress on app-react/JS version --- .../port/desktop/DesktopDomainDiModule.kt | 9 ++++ .../ustadmobile/lib/rest/UmRestApplication.kt | 36 +++++++++++++-- .../ImportContentEntryJobRoute.kt | 29 +++++++++++- .../lib/rest/ext/ApplicationCallExt.kt | 3 +- app-react/src/jsMain/kotlin/UstadJsDi.kt | 1 + .../components/UstadLinearProgressListItem.kt | 33 ++++++++++++-- .../ContentEntryDetailOverviewScreen.kt | 16 +++++++ ...EnqueueImportContentEntryUseCaseAndroid.kt | 4 +- .../CancelImportContentEntryServerUseCase.kt | 45 +++++++++++++++++++ .../CancelRemoteContentEntryImportUseCase.kt | 36 +++++++++++++++ .../ValidateUserSessionOnServerUseCase.kt | 39 ++++++++++++++++ .../ContentEntryDetailOverviewViewModel.kt | 30 ++++++++++++- .../edit/ContentEntryEditViewModel.kt | 1 + .../core/impl/di/DomainDiModuleJs.kt | 9 ++++ .../account/SetPasswordServerUseCase.kt | 17 ++----- .../core/db/dao/ContentEntryImportJobDao.kt | 9 ++++ .../db/dao/ContentEntryImportJobDaoCommon.kt | 3 +- .../ContentEntryImportJobProgress.kt | 2 + .../components/UstadLinearProgressListItem.kt | 24 ++++++---- .../ContentEntryDetailOverviewScreen.kt | 27 +++++++++++ 20 files changed, 338 insertions(+), 35 deletions(-) create mode 100644 core/src/commonMain/kotlin/com/ustadmobile/core/domain/contententry/importcontent/CancelImportContentEntryServerUseCase.kt create mode 100644 core/src/commonMain/kotlin/com/ustadmobile/core/domain/contententry/importcontent/CancelRemoteContentEntryImportUseCase.kt create mode 100644 core/src/commonMain/kotlin/com/ustadmobile/core/domain/usersession/ValidateUserSessionOnServerUseCase.kt diff --git a/app-desktop/src/main/java/com/ustadmobile/port/desktop/DesktopDomainDiModule.kt b/app-desktop/src/main/java/com/ustadmobile/port/desktop/DesktopDomainDiModule.kt index 123eb9b4f0..04d36e25a9 100644 --- a/app-desktop/src/main/java/com/ustadmobile/port/desktop/DesktopDomainDiModule.kt +++ b/app-desktop/src/main/java/com/ustadmobile/port/desktop/DesktopDomainDiModule.kt @@ -54,6 +54,7 @@ import com.ustadmobile.core.domain.contententry.getmetadatafromuri.ContentEntryG import com.ustadmobile.core.domain.contententry.getmetadatafromuri.ContentEntryGetMetaDataFromUriUseCaseCommonJvm import com.ustadmobile.core.domain.contententry.importcontent.CancelImportContentEntryUseCase import com.ustadmobile.core.domain.contententry.importcontent.CancelImportContentEntryUseCaseJvm +import com.ustadmobile.core.domain.contententry.importcontent.CancelRemoteContentEntryImportUseCase import com.ustadmobile.core.domain.contententry.importcontent.CreateRetentionLocksForManifestUseCase import com.ustadmobile.core.domain.contententry.importcontent.CreateRetentionLocksForManifestUseCaseCommonJvm import com.ustadmobile.core.domain.contententry.importcontent.ImportContentEntryUseCase @@ -474,5 +475,13 @@ val DesktopDomainDiModule = DI.Module("Desktop-Domain") { ) } + bind() with scoped(EndpointScope.Default).provider { + CancelRemoteContentEntryImportUseCase( + endpoint = context, + httpClient = instance(), + repo = instance(tag = DoorTag.TAG_REPO), + ) + } + } \ No newline at end of file diff --git a/app-ktor-server/src/main/kotlin/com/ustadmobile/lib/rest/UmRestApplication.kt b/app-ktor-server/src/main/kotlin/com/ustadmobile/lib/rest/UmRestApplication.kt index 816c918c90..19876396cc 100644 --- a/app-ktor-server/src/main/kotlin/com/ustadmobile/lib/rest/UmRestApplication.kt +++ b/app-ktor-server/src/main/kotlin/com/ustadmobile/lib/rest/UmRestApplication.kt @@ -21,6 +21,9 @@ import com.ustadmobile.core.domain.cachestoragepath.GetStoragePathForUrlUseCaseC import com.ustadmobile.core.domain.clazzenrolment.pendingenrolment.EnrolIntoCourseUseCase import com.ustadmobile.core.domain.compress.video.CompressVideoUseCase import com.ustadmobile.core.domain.compress.video.CompressVideoUseCaseHandbrake +import com.ustadmobile.core.domain.contententry.importcontent.CancelImportContentEntryServerUseCase +import com.ustadmobile.core.domain.contententry.importcontent.CancelImportContentEntryUseCase +import com.ustadmobile.core.domain.contententry.importcontent.CancelImportContentEntryUseCaseJvm import com.ustadmobile.core.domain.contententry.importcontent.EnqueueContentEntryImportUseCase import com.ustadmobile.core.domain.contententry.importcontent.EnqueueImportContentEntryUseCaseJvm import com.ustadmobile.core.domain.contententry.importcontent.ImportContentEntryUseCase @@ -41,6 +44,7 @@ import com.ustadmobile.core.domain.tmpfiles.DeleteUrisUseCase import com.ustadmobile.core.domain.tmpfiles.DeleteUrisUseCaseCommonJvm import com.ustadmobile.core.domain.tmpfiles.IsTempFileCheckerUseCase import com.ustadmobile.core.domain.tmpfiles.IsTempFileCheckerUseCaseJvm +import com.ustadmobile.core.domain.usersession.ValidateUserSessionOnServerUseCase import com.ustadmobile.core.domain.validateemail.ValidateEmailUseCase import com.ustadmobile.core.domain.validatevideofile.ValidateVideoFileUseCase import com.ustadmobile.core.domain.validatevideofile.ValidateVideoFileUseCaseMediaInfo @@ -93,7 +97,7 @@ import java.util.* import com.ustadmobile.core.logging.LogbackAntiLog import com.ustadmobile.core.util.UMFileUtil import com.ustadmobile.door.log.NapierDoorLogger -import com.ustadmobile.lib.rest.domain.contententry.importcontent.ImportContentEntryJobStatus +import com.ustadmobile.lib.rest.domain.contententry.importcontent.ImportContentEntryJobRoute import com.ustadmobile.lib.rest.domain.person.bulkadd.BulkAddPersonRoute import com.ustadmobile.libcache.headers.FileMimeTypeHelperImpl import com.ustadmobile.libcache.headers.MimeTypeHelper @@ -426,11 +430,18 @@ fun Application.umRestApplication( ) } + bind() with scoped(EndpointScope.Default).singleton { + ValidateUserSessionOnServerUseCase( + db = instance(tag = DoorTag.TAG_DB), + nodeIdAuthCache = instance(), + ) + } + bind() with scoped(EndpointScope.Default).singleton { SetPasswordServerUseCase( db = instance(tag = DoorTag.TAG_DB), setPasswordUseCase = instance(), - nodeIdAndAuthCache = instance(), + validateUserSessionOnServerUseCase = instance() ) } @@ -541,6 +552,22 @@ fun Application.umRestApplication( BulkAddPersonStatusMap() } + bind() with scoped(EndpointScope.Default).singleton { + CancelImportContentEntryUseCaseJvm( + scheduler = instance(), + endpoint = context, + ) + } + + bind() with scoped(EndpointScope.Default).singleton { + CancelImportContentEntryServerUseCase( + cancelImportContentEntryUseCase = instance(), + validateUserSessionOnServerUseCase = instance(), + db = instance(tag = DoorTag.TAG_DB), + endpoint = context, + ) + } + try { appConfig.config("mail") @@ -703,9 +730,10 @@ fun Application.umRestApplication( } route("contententryimportjob"){ - ImportContentEntryJobStatus( + ImportContentEntryJobRoute( json = di.direct.instance(), - dbFn = { call -> di.on(call).direct.instance(tag = DoorTag.TAG_DB) } + dbFn = { call -> di.on(call).direct.instance(tag = DoorTag.TAG_DB) }, + cancelImportContentEntryServerUseCase = { call -> di.on(call).direct.instance() } ) } diff --git a/app-ktor-server/src/main/kotlin/com/ustadmobile/lib/rest/domain/contententry/importcontent/ImportContentEntryJobRoute.kt b/app-ktor-server/src/main/kotlin/com/ustadmobile/lib/rest/domain/contententry/importcontent/ImportContentEntryJobRoute.kt index d758f3f51b..69149e071e 100644 --- a/app-ktor-server/src/main/kotlin/com/ustadmobile/lib/rest/domain/contententry/importcontent/ImportContentEntryJobRoute.kt +++ b/app-ktor-server/src/main/kotlin/com/ustadmobile/lib/rest/domain/contententry/importcontent/ImportContentEntryJobRoute.kt @@ -1,19 +1,26 @@ package com.ustadmobile.lib.rest.domain.contententry.importcontent import com.ustadmobile.core.db.UmAppDatabase +import com.ustadmobile.core.domain.contententry.importcontent.CancelImportContentEntryServerUseCase +import com.ustadmobile.door.ext.requireRemoteNodeIdAndAuth import com.ustadmobile.lib.db.composites.ContentEntryImportJobProgress +import io.github.aakira.napier.Napier import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall import io.ktor.server.application.call +import io.ktor.server.response.header +import io.ktor.server.response.respond import io.ktor.server.response.respondText import io.ktor.server.routing.Route import io.ktor.server.routing.get import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json -fun Route.ImportContentEntryJobStatus( +fun Route.ImportContentEntryJobRoute( json: Json, dbFn: (ApplicationCall) -> UmAppDatabase, + cancelImportContentEntryServerUseCase: (ApplicationCall) -> CancelImportContentEntryServerUseCase, ) { get("importjobs") { val contentEntryUid = call.request.queryParameters["contententryuid"]?.toLong() ?: 0 @@ -21,7 +28,7 @@ fun Route.ImportContentEntryJobStatus( val inProgressJobs = db.contentEntryImportJobDao.findInProgressJobsByContentEntryUidAsync( contentEntryUid ) - + call.response.header("cache-control", "no-store") call.respondText( contentType = ContentType.Application.Json, text = json.encodeToString( @@ -30,4 +37,22 @@ fun Route.ImportContentEntryJobStatus( ), ) } + + get("cancel") { + val jobUid = call.request.queryParameters["jobUid"]?.toLong() ?: 0 + val accountPersonUid = call.request.queryParameters["accountPersonUid"]?.toLong() ?: 0 + try { + val (fromNode, auth) = requireRemoteNodeIdAndAuth() + + cancelImportContentEntryServerUseCase(call).invoke( + cjiUid = jobUid, + accountPersonUid = accountPersonUid, + remoteNodeId = fromNode, + nodeAuth = auth, + ) + call.respond(HttpStatusCode.OK, "") + }catch(e: Throwable) { + Napier.w("CancelImportContentEntryServer: exception with cancel request", e) + } + } } \ No newline at end of file diff --git a/app-ktor-server/src/main/kotlin/com/ustadmobile/lib/rest/ext/ApplicationCallExt.kt b/app-ktor-server/src/main/kotlin/com/ustadmobile/lib/rest/ext/ApplicationCallExt.kt index 59550ce20d..4f6adb52e1 100644 --- a/app-ktor-server/src/main/kotlin/com/ustadmobile/lib/rest/ext/ApplicationCallExt.kt +++ b/app-ktor-server/src/main/kotlin/com/ustadmobile/lib/rest/ext/ApplicationCallExt.kt @@ -3,6 +3,7 @@ package com.ustadmobile.lib.rest.ext import com.ustadmobile.core.account.Endpoint import com.ustadmobile.core.contentformats.ContentImportersManager import com.ustadmobile.core.contentjob.MetadataResult +import com.ustadmobile.core.util.ext.requirePostfix import com.ustadmobile.lib.rest.CONF_DBMODE_SINGLETON import com.ustadmobile.lib.rest.CONF_DBMODE_VIRTUALHOST import com.ustadmobile.lib.rest.CONF_KEY_SITE_URL @@ -132,7 +133,7 @@ val ApplicationCall.callEndpoint: Endpoint val dbMode = config.dbModeProperty() return if(dbMode == CONF_DBMODE_SINGLETON) { - Endpoint(config.property(CONF_KEY_SITE_URL).getString()) + Endpoint(config.property(CONF_KEY_SITE_URL).getString().requirePostfix("/")) }else { Endpoint(request.clientProtocolAndHost()) } diff --git a/app-react/src/jsMain/kotlin/UstadJsDi.kt b/app-react/src/jsMain/kotlin/UstadJsDi.kt index 15ea348542..fcb6843a21 100644 --- a/app-react/src/jsMain/kotlin/UstadJsDi.kt +++ b/app-react/src/jsMain/kotlin/UstadJsDi.kt @@ -5,6 +5,7 @@ import com.russhwolf.settings.set import com.ustadmobile.BuildConfigJs import com.ustadmobile.core.account.* import com.ustadmobile.core.db.UmAppDatabase +import com.ustadmobile.core.domain.contententry.importcontent.CancelRemoteContentEntryImportUseCase import com.ustadmobile.core.domain.getversion.GetVersionUseCase import com.ustadmobile.core.domain.person.bulkadd.BulkAddPersonsFromLocalUriUseCase import com.ustadmobile.core.domain.person.bulkadd.BulkAddPersonsFromLocalUriUseCaseJs diff --git a/app-react/src/jsMain/kotlin/com/ustadmobile/mui/components/UstadLinearProgressListItem.kt b/app-react/src/jsMain/kotlin/com/ustadmobile/mui/components/UstadLinearProgressListItem.kt index 1eac834e5e..1aa2f3b697 100644 --- a/app-react/src/jsMain/kotlin/com/ustadmobile/mui/components/UstadLinearProgressListItem.kt +++ b/app-react/src/jsMain/kotlin/com/ustadmobile/mui/components/UstadLinearProgressListItem.kt @@ -8,24 +8,29 @@ import react.Props import react.ReactNode import com.ustadmobile.core.MR import js.objects.jso +import mui.material.IconButton import mui.material.LinearProgress import mui.material.LinearProgressVariant +import mui.material.Tooltip import react.create +import react.dom.aria.ariaLabel import react.dom.html.ReactHTML.div - +import mui.icons.material.Close as CloseIcon external interface UstadLinearProgressListItemProps: Props { var progress: Float? var secondaryContent: ReactNode - var onCancel: () -> Unit + var onCancel: (() -> Unit)? var error: String? - var onDismissError: () -> Unit + var onDismissError: (() -> Unit)? } val UstadLinearProgressListItem = FC {props -> val strings = useStringProvider() val errorVal = props.error val progressVal = props.progress + val onCancelVal = props.onCancel + val onDismissErrorVal = props.onDismissError ListItem { if(errorVal != null) { @@ -55,6 +60,28 @@ val UstadLinearProgressListItem = FC {props -> } + val showSecondaryAction = (errorVal != null && onDismissErrorVal != null) || + onDismissErrorVal != null + + if(showSecondaryAction) { + secondaryAction = Tooltip.create { + title = ReactNode(strings[MR.strings.cancel]) + + IconButton { + ariaLabel = strings[MR.strings.cancel] + + onClick = { + if(errorVal != null && onDismissErrorVal != null) { + onDismissErrorVal() + }else if(onCancelVal != null) { + onCancelVal() + } + } + + CloseIcon() + } + } + } } } diff --git a/app-react/src/jsMain/kotlin/com/ustadmobile/view/contententry/detailoverviewtab/ContentEntryDetailOverviewScreen.kt b/app-react/src/jsMain/kotlin/com/ustadmobile/view/contententry/detailoverviewtab/ContentEntryDetailOverviewScreen.kt index 4116c36789..77db486183 100644 --- a/app-react/src/jsMain/kotlin/com/ustadmobile/view/contententry/detailoverviewtab/ContentEntryDetailOverviewScreen.kt +++ b/app-react/src/jsMain/kotlin/com/ustadmobile/view/contententry/detailoverviewtab/ContentEntryDetailOverviewScreen.kt @@ -72,6 +72,10 @@ external interface ContentEntryDetailOverviewScreenProps : Props { var onClickTranslation: (ContentEntryRelatedEntryJoinWithLanguage) -> Unit + var onCancelRemoteImport: (Long) -> Unit + + var onDismissRemoteImportError: (Long) -> Unit + } val ContentEntryDetailOverviewComponent2 = FC { props -> @@ -105,10 +109,20 @@ val ContentEntryDetailOverviewComponent2 = FC { ContentEntryDetailOverviewComponent2 { uiState = uiStateVal onClickOpen = viewModel::onClickOpen + onCancelRemoteImport = viewModel::onCancelRemoteImport + onDismissRemoteImportError = viewModel::onDismissRemoteImportError } } diff --git a/core/src/androidMain/kotlin/com/ustadmobile/core/domain/contententry/importcontent/EnqueueImportContentEntryUseCaseAndroid.kt b/core/src/androidMain/kotlin/com/ustadmobile/core/domain/contententry/importcontent/EnqueueImportContentEntryUseCaseAndroid.kt index 64af6ee7a3..f351a27406 100644 --- a/core/src/androidMain/kotlin/com/ustadmobile/core/domain/contententry/importcontent/EnqueueImportContentEntryUseCaseAndroid.kt +++ b/core/src/androidMain/kotlin/com/ustadmobile/core/domain/contententry/importcontent/EnqueueImportContentEntryUseCaseAndroid.kt @@ -21,7 +21,9 @@ class EnqueueImportContentEntryUseCaseAndroid( private val enqueueRemoteImport: EnqueueContentEntryImportUseCase, ) : EnqueueContentEntryImportUseCase { - override suspend fun invoke(contentJobItem: ContentEntryImportJob) { + override suspend fun invoke( + contentJobItem: ContentEntryImportJob, + ) { val sourceUri = DoorUri.parse(contentJobItem.sourceUri!!) if(sourceUri.isRemote()) { enqueueRemoteImport(contentJobItem) diff --git a/core/src/commonMain/kotlin/com/ustadmobile/core/domain/contententry/importcontent/CancelImportContentEntryServerUseCase.kt b/core/src/commonMain/kotlin/com/ustadmobile/core/domain/contententry/importcontent/CancelImportContentEntryServerUseCase.kt new file mode 100644 index 0000000000..6cbcfafb91 --- /dev/null +++ b/core/src/commonMain/kotlin/com/ustadmobile/core/domain/contententry/importcontent/CancelImportContentEntryServerUseCase.kt @@ -0,0 +1,45 @@ +package com.ustadmobile.core.domain.contententry.importcontent + +import com.ustadmobile.core.account.Endpoint +import com.ustadmobile.core.db.UmAppDatabase +import com.ustadmobile.core.domain.usersession.ValidateUserSessionOnServerUseCase +import io.github.aakira.napier.Napier + +/** + * Server side implementation to cancel a running import job on request from a client. This will + * validate that the request comes from a node that has a valid session representing the owner of + * the import job. + */ +class CancelImportContentEntryServerUseCase( + private val cancelImportContentEntryUseCase: CancelImportContentEntryUseCase, + private val validateUserSessionOnServerUseCase: ValidateUserSessionOnServerUseCase, + private val db: UmAppDatabase, + private val endpoint: Endpoint, +) { + + suspend operator fun invoke( + cjiUid: Long, + remoteNodeId: Long, + nodeAuth: String, + accountPersonUid: Long, + ) { + Napier.d { "CancelImportContentEntryServerUseCase: validating session to cancel #$cjiUid"} + validateUserSessionOnServerUseCase( + nodeId = remoteNodeId, + nodeAuth = nodeAuth, + accountPersonUid = accountPersonUid, + ) + + Napier.d { "CancelImportContentEntryServerUseCase: validating owner to cancel #$cjiUid"} + + val ownerPersonUid = db.contentEntryImportJobDao.findOwnerByUidAsync(cjiUid) + if(ownerPersonUid != accountPersonUid) + throw IllegalArgumentException("$accountPersonUid is not owner of the job $cjiUid ($ownerPersonUid)") + + Napier.d { "CancelImportContentEntryServerUseCase: requesting cancellation of #$cjiUid "} + + cancelImportContentEntryUseCase(cjiUid) + Napier.d { "CancelImportContentEntryServerUseCase: Canceled import #$cjiUid on ${endpoint.url}" } + } + +} \ No newline at end of file diff --git a/core/src/commonMain/kotlin/com/ustadmobile/core/domain/contententry/importcontent/CancelRemoteContentEntryImportUseCase.kt b/core/src/commonMain/kotlin/com/ustadmobile/core/domain/contententry/importcontent/CancelRemoteContentEntryImportUseCase.kt new file mode 100644 index 0000000000..4caa5395e5 --- /dev/null +++ b/core/src/commonMain/kotlin/com/ustadmobile/core/domain/contententry/importcontent/CancelRemoteContentEntryImportUseCase.kt @@ -0,0 +1,36 @@ +package com.ustadmobile.core.domain.contententry.importcontent + +import com.ustadmobile.core.account.Endpoint +import com.ustadmobile.core.db.UmAppDatabase +import com.ustadmobile.door.DoorDatabaseRepository +import com.ustadmobile.door.ext.doorNodeIdHeader +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.parameter + +/** + * Cancels a content entry import where the import is running on the server + */ +class CancelRemoteContentEntryImportUseCase( + private val endpoint: Endpoint, + private val httpClient: HttpClient, + private val repo: UmAppDatabase, +) { + + suspend operator fun invoke( + cjiUid: Long, + activeUserPersonUid: Long, + ) { + val repoVal = repo as? DoorDatabaseRepository + ?: throw IllegalArgumentException() + + httpClient.get("${endpoint.url}api/contententryimportjob/cancel") { + parameter("jobUid", cjiUid) + doorNodeIdHeader(repoVal) + parameter("accountPersonUid", activeUserPersonUid) + header("cache-control", "no-store") + } + } + +} \ No newline at end of file diff --git a/core/src/commonMain/kotlin/com/ustadmobile/core/domain/usersession/ValidateUserSessionOnServerUseCase.kt b/core/src/commonMain/kotlin/com/ustadmobile/core/domain/usersession/ValidateUserSessionOnServerUseCase.kt new file mode 100644 index 0000000000..83683c72ad --- /dev/null +++ b/core/src/commonMain/kotlin/com/ustadmobile/core/domain/usersession/ValidateUserSessionOnServerUseCase.kt @@ -0,0 +1,39 @@ +package com.ustadmobile.core.domain.usersession + +import com.ustadmobile.core.db.UmAppDatabase +import com.ustadmobile.door.util.NodeIdAuthCache + +/** + * Use case for a server to validate client credentials + * 1) Check that the door node id and node auth are valid + * 2) Check that the given node has an active session for the given accountPersonUid + */ +class ValidateUserSessionOnServerUseCase( + private val db: UmAppDatabase, + private val nodeIdAuthCache: NodeIdAuthCache, +) { + + suspend operator fun invoke( + nodeId: Long, + nodeAuth: String, + accountPersonUid: Long, + ) { + if(nodeId == 0L || accountPersonUid == 0L) + throw IllegalArgumentException("Cannot validate session for nodeid = 0 or personuid = 0") + + if(!nodeIdAuthCache.verify(nodeId, nodeAuth)) { + throw IllegalArgumentException("Invalid nodeId/nodeauth") + } + + //nodeActiveUserUid must have an active session + val sessionsForUser = db.userSessionDao.countActiveSessionsForUserAndNode( + personUid = accountPersonUid, + nodeId = nodeId, + ) + + if(sessionsForUser < 1) { + throw IllegalArgumentException("User $nodeId does not have an active session on $nodeId") + } + } + +} \ No newline at end of file diff --git a/core/src/commonMain/kotlin/com/ustadmobile/core/viewmodel/contententry/detailoverviewtab/ContentEntryDetailOverviewViewModel.kt b/core/src/commonMain/kotlin/com/ustadmobile/core/viewmodel/contententry/detailoverviewtab/ContentEntryDetailOverviewViewModel.kt index 961e2a5ccc..10aaabed8a 100644 --- a/core/src/commonMain/kotlin/com/ustadmobile/core/viewmodel/contententry/detailoverviewtab/ContentEntryDetailOverviewViewModel.kt +++ b/core/src/commonMain/kotlin/com/ustadmobile/core/viewmodel/contententry/detailoverviewtab/ContentEntryDetailOverviewViewModel.kt @@ -14,6 +14,7 @@ import com.ustadmobile.core.MR import com.ustadmobile.core.domain.blob.download.CancelDownloadUseCase import com.ustadmobile.core.domain.blob.download.MakeContentEntryAvailableOfflineUseCase import com.ustadmobile.core.domain.contententry.importcontent.CancelImportContentEntryUseCase +import com.ustadmobile.core.domain.contententry.importcontent.CancelRemoteContentEntryImportUseCase import com.ustadmobile.core.domain.contententry.launchcontent.LaunchContentEntryVersionUseCase import com.ustadmobile.core.domain.contententry.launchcontent.epub.LaunchEpubUseCase import com.ustadmobile.core.domain.contententry.launchcontent.xapi.LaunchXapiUseCase @@ -31,6 +32,7 @@ import com.ustadmobile.lib.db.composites.TransferJobAndTotals import io.github.aakira.napier.Napier import io.ktor.client.HttpClient import io.ktor.client.request.get +import io.ktor.client.request.header import io.ktor.client.request.parameter import kotlinx.coroutines.flow.updateAndGet import kotlinx.serialization.builtins.ListSerializer @@ -64,6 +66,8 @@ data class ContentEntryDetailOverviewUiState( val offlineItemAndState: OfflineItemAndState? = null, val openButtonEnabled: Boolean = true, + + val activeUserPersonUid: Long = 0, ) { val scoreProgressVisible: Boolean get() = scoreProgress?.progress != null && scoreProgress.progress > 0 @@ -96,6 +100,10 @@ data class ContentEntryDetailOverviewUiState( it.cevStorageSize > 0 } ?: false + fun canCancelRemoteImportJob(importJobProgress: ContentEntryImportJobProgress): Boolean { + return importJobProgress.cjiOwnerPersonUid == activeUserPersonUid + } + } class ContentEntryDetailOverviewViewModel( @@ -130,10 +138,14 @@ class ContentEntryDetailOverviewViewModel( private val cancelImportContentEntryUseCase: CancelImportContentEntryUseCase? by di.onActiveEndpoint().instanceOrNull() - private val httpClient: HttpClient by di.instance() + private val cancelRemoteContentEntryImportUseCase: CancelRemoteContentEntryImportUseCase by + di.onActiveEndpoint().instance() + private val httpClient: HttpClient by di.instance() init { + _uiState.update { it.copy(activeUserPersonUid = activeUserPersonUid) } + viewModelScope.launch { _uiState.whenSubscribed { launch { @@ -211,6 +223,7 @@ class ContentEntryDetailOverviewViewModel( "${accountManager.activeEndpoint.url}api/contententryimportjob/importjobs" ) { parameter("contententryuid", entityUidArg.toString()) + header("cache-control", "no-store") }.bodyAsDecodedText() val remoteImportJobs = json.decodeFromString( ListSerializer(ContentEntryImportJobProgress.serializer()), @@ -305,12 +318,27 @@ class ContentEntryDetailOverviewViewModel( } } + fun onCancelRemoteImport(jobUid: Long) { + viewModelScope.launch { + try { + cancelRemoteContentEntryImportUseCase(jobUid, activeUserPersonUid) + snackDispatcher.showSnackBar(Snack(systemImpl.getString(MR.strings.canceled))) + }catch(e: Throwable) { + snackDispatcher.showSnackBar(Snack(systemImpl.getString(MR.strings.error))) + } + } + } + fun onDismissImportError(jobUid: Long) { viewModelScope.launch { activeDb.contentEntryImportJobDao.updateErrorDismissed(jobUid, true) } } + fun onDismissRemoteImportError(jobUid: Long) { + + } + companion object { const val DEST_NAME = "ContentEntryDetailOverviewView" diff --git a/core/src/commonMain/kotlin/com/ustadmobile/core/viewmodel/contententry/edit/ContentEntryEditViewModel.kt b/core/src/commonMain/kotlin/com/ustadmobile/core/viewmodel/contententry/edit/ContentEntryEditViewModel.kt index 982215223b..293eb6efd2 100644 --- a/core/src/commonMain/kotlin/com/ustadmobile/core/viewmodel/contententry/edit/ContentEntryEditViewModel.kt +++ b/core/src/commonMain/kotlin/com/ustadmobile/core/viewmodel/contententry/edit/ContentEntryEditViewModel.kt @@ -158,6 +158,7 @@ class ContentEntryEditViewModel( cjiContentEntryUid = newContentEntryUid, sourceUri = importedMetaData.entry.sourceUrl, cjiOriginalFilename = importedMetaData.originalFilename, + cjiOwnerPersonUid = activeUserPersonUid, ), ).also { savedStateHandle[KEY_TITLE] = systemImpl.formatString(MR.strings.importing, diff --git a/core/src/jsMain/kotlin/com/ustadmobile/core/impl/di/DomainDiModuleJs.kt b/core/src/jsMain/kotlin/com/ustadmobile/core/impl/di/DomainDiModuleJs.kt index 41dc619233..f68511e2ee 100644 --- a/core/src/jsMain/kotlin/com/ustadmobile/core/impl/di/DomainDiModuleJs.kt +++ b/core/src/jsMain/kotlin/com/ustadmobile/core/impl/di/DomainDiModuleJs.kt @@ -18,6 +18,7 @@ import com.ustadmobile.core.domain.compress.image.CompressImageUseCaseJs import com.ustadmobile.core.domain.contententry.delete.DeleteContentEntryParentChildJoinUseCase import com.ustadmobile.core.domain.contententry.getmetadatafromuri.ContentEntryGetMetaDataFromUriUseCaseJs import com.ustadmobile.core.domain.contententry.getmetadatafromuri.ContentEntryGetMetaDataFromUriUseCase +import com.ustadmobile.core.domain.contententry.importcontent.CancelRemoteContentEntryImportUseCase import com.ustadmobile.core.domain.contententry.importcontent.EnqueueContentEntryImportUseCase import com.ustadmobile.core.domain.contententry.importcontent.EnqueueImportContentEntryUseCaseRemote import com.ustadmobile.core.domain.contententry.launchcontent.xapi.LaunchXapiUseCase @@ -223,4 +224,12 @@ fun DomainDiModuleJs(endpointScope: EndpointScope) = DI.Module("DomainDiModuleJs ) } + bind() with scoped(EndpointScope.Default).singleton { + CancelRemoteContentEntryImportUseCase( + endpoint = context, + httpClient = instance(), + repo = instance(tag = DoorTag.TAG_REPO), + ) + } + } diff --git a/core/src/jvmMain/kotlin/com/ustadmobile/core/domain/account/SetPasswordServerUseCase.kt b/core/src/jvmMain/kotlin/com/ustadmobile/core/domain/account/SetPasswordServerUseCase.kt index 80bad59402..f24eb49260 100644 --- a/core/src/jvmMain/kotlin/com/ustadmobile/core/domain/account/SetPasswordServerUseCase.kt +++ b/core/src/jvmMain/kotlin/com/ustadmobile/core/domain/account/SetPasswordServerUseCase.kt @@ -2,7 +2,7 @@ package com.ustadmobile.core.domain.account import com.ustadmobile.core.db.PermissionFlags import com.ustadmobile.core.db.UmAppDatabase -import com.ustadmobile.door.util.NodeIdAuthCache +import com.ustadmobile.core.domain.usersession.ValidateUserSessionOnServerUseCase import io.github.aakira.napier.Napier /** @@ -18,7 +18,7 @@ import io.github.aakira.napier.Napier class SetPasswordServerUseCase( private val db: UmAppDatabase, private val setPasswordUseCase: SetPasswordUseCase, - private val nodeIdAndAuthCache: NodeIdAuthCache, + private val validateUserSessionOnServerUseCase: ValidateUserSessionOnServerUseCase, ) { suspend operator fun invoke( @@ -32,18 +32,7 @@ class SetPasswordServerUseCase( ) { try { //Validate from node credentials and run permission check - if(!nodeIdAndAuthCache.verify(fromNodeId, nodeAuth)) { - throw IllegalArgumentException("Invalid nodeId/nodeauth") - } - //nodeActiveUserUid must have an active session - val sessionsForUser = db.userSessionDao.countActiveSessionsForUserAndNode( - personUid = nodeActiveUserUid, - nodeId = fromNodeId, - ) - - if(sessionsForUser < 1) { - throw IllegalArgumentException("User $nodeActiveUserUid does not have an active session on $fromNodeId") - } + validateUserSessionOnServerUseCase(fromNodeId, nodeAuth, nodeActiveUserUid) if(currentPassword == null) { if(!db.systemPermissionDao.personHasSystemPermission( diff --git a/lib-database/src/commonMain/kotlin/com/ustadmobile/core/db/dao/ContentEntryImportJobDao.kt b/lib-database/src/commonMain/kotlin/com/ustadmobile/core/db/dao/ContentEntryImportJobDao.kt index a6c2347dc0..32cbb70611 100644 --- a/lib-database/src/commonMain/kotlin/com/ustadmobile/core/db/dao/ContentEntryImportJobDao.kt +++ b/lib-database/src/commonMain/kotlin/com/ustadmobile/core/db/dao/ContentEntryImportJobDao.kt @@ -52,6 +52,15 @@ expect abstract class ContentEntryImportJobDao { """) abstract suspend fun findByUidAsync(cjiUid: Long): ContentEntryImportJob? + @Query(""" + SELECT COALESCE( + (SELECT ContentEntryImportJob.cjiOwnerPersonUid + FROM ContentEntryImportJob + WHERE ContentEntryImportJob.cjiUid = :cjiUid), 0) + """) + abstract suspend fun findOwnerByUidAsync(cjiUid: Long): Long + + @Query(FIND_IN_PROGRESS_JOBS_BY_CONTENT_ENTRY_UID) abstract fun findInProgressJobsByContentEntryUid( contentEntryUid: Long, diff --git a/lib-database/src/commonMain/kotlin/com/ustadmobile/core/db/dao/ContentEntryImportJobDaoCommon.kt b/lib-database/src/commonMain/kotlin/com/ustadmobile/core/db/dao/ContentEntryImportJobDaoCommon.kt index 1951c43e0b..5da6ba82a7 100644 --- a/lib-database/src/commonMain/kotlin/com/ustadmobile/core/db/dao/ContentEntryImportJobDaoCommon.kt +++ b/lib-database/src/commonMain/kotlin/com/ustadmobile/core/db/dao/ContentEntryImportJobDaoCommon.kt @@ -9,7 +9,8 @@ object ContentEntryImportJobDaoCommon { ContentEntryImportJob.cjiItemProgress, ContentEntryImportJob.cjiItemTotal, ContentEntryImportJob.cjiStatus, - ContentEntryImportJob.cjiError + ContentEntryImportJob.cjiError, + ContentEntryImportJob.cjiOwnerPersonUid FROM ContentEntryImportJob WHERE ContentEntryImportJob.cjiContentEntryUid = :contentEntryUid AND ( ContentEntryImportJob.cjiStatus BETWEEN ${JobStatus.QUEUED} AND ${JobStatus.RUNNING_MAX} diff --git a/lib-database/src/commonMain/kotlin/com/ustadmobile/lib/db/composites/ContentEntryImportJobProgress.kt b/lib-database/src/commonMain/kotlin/com/ustadmobile/lib/db/composites/ContentEntryImportJobProgress.kt index 35080d41d4..a43a82dc26 100644 --- a/lib-database/src/commonMain/kotlin/com/ustadmobile/lib/db/composites/ContentEntryImportJobProgress.kt +++ b/lib-database/src/commonMain/kotlin/com/ustadmobile/lib/db/composites/ContentEntryImportJobProgress.kt @@ -20,4 +20,6 @@ data class ContentEntryImportJobProgress( var cjiError: String? = null, + var cjiOwnerPersonUid: Long = 0, + ) diff --git a/lib-ui-compose/src/commonMain/kotlin/com/ustadmobile/libuicompose/components/UstadLinearProgressListItem.kt b/lib-ui-compose/src/commonMain/kotlin/com/ustadmobile/libuicompose/components/UstadLinearProgressListItem.kt index af51739a77..68711cda4e 100644 --- a/lib-ui-compose/src/commonMain/kotlin/com/ustadmobile/libuicompose/components/UstadLinearProgressListItem.kt +++ b/lib-ui-compose/src/commonMain/kotlin/com/ustadmobile/libuicompose/components/UstadLinearProgressListItem.kt @@ -20,9 +20,9 @@ import dev.icerock.moko.resources.compose.stringResource fun UstadLinearProgressListItem( progress: Float?, supportingContent: @Composable () -> Unit, - onCancel: () -> Unit, + onCancel: (() -> Unit)?, error: String? = null, - onDismissError: () -> Unit = { }, + onDismissError: (() -> Unit)? = { }, modifier: Modifier = Modifier, ) { ListItem( @@ -58,13 +58,21 @@ fun UstadLinearProgressListItem( } }, trailingContent = { - UstadTooltipBox( - tooltipText = stringResource(MR.strings.cancel), - ) { - IconButton( - onClick = if(error != null) onDismissError else onCancel + val showButton = (error != null && onDismissError != null) || + onCancel != null + if(showButton){ + UstadTooltipBox( + tooltipText = stringResource(MR.strings.cancel), ) { - Icon(Icons.Default.Close, contentDescription = stringResource(MR.strings.cancel)) + IconButton( + onClick = if(error != null && onDismissError != null) { + { onDismissError.invoke() } + }else { + { onCancel?.invoke() } + } + ) { + Icon(Icons.Default.Close, contentDescription = stringResource(MR.strings.cancel)) + } } } } diff --git a/lib-ui-compose/src/commonMain/kotlin/com/ustadmobile/libuicompose/view/contententry/detailoverviewtab/ContentEntryDetailOverviewScreen.kt b/lib-ui-compose/src/commonMain/kotlin/com/ustadmobile/libuicompose/view/contententry/detailoverviewtab/ContentEntryDetailOverviewScreen.kt index 331bb0fcb2..6a7ee55fd3 100644 --- a/lib-ui-compose/src/commonMain/kotlin/com/ustadmobile/libuicompose/view/contententry/detailoverviewtab/ContentEntryDetailOverviewScreen.kt +++ b/lib-ui-compose/src/commonMain/kotlin/com/ustadmobile/libuicompose/view/contententry/detailoverviewtab/ContentEntryDetailOverviewScreen.kt @@ -63,7 +63,9 @@ fun ContentEntryDetailOverviewScreen( onClickOpen = viewModel::onClickOpen, onClickOfflineButton = viewModel::onClickOffline, onCancelImport = viewModel::onCancelImport, + onCancelRemoteImport = viewModel::onCancelRemoteImport, onDismissImportError = viewModel::onDismissImportError, + onDismissRemoteImportError = viewModel::onDismissRemoteImportError, ) } @@ -78,7 +80,9 @@ fun ContentEntryDetailOverviewScreen( onClickManageDownload: () -> Unit = {}, onClickTranslation: (ContentEntryRelatedEntryJoinWithLanguage) -> Unit = {}, onCancelImport: (Long) -> Unit = { }, + onCancelRemoteImport: (Long) -> Unit = { }, onDismissImportError: (Long) -> Unit = { }, + onDismissRemoteImportError: (Long) -> Unit = { }, ) { UstadLazyColumn( verticalArrangement = Arrangement.spacedBy(5.dp), @@ -167,6 +171,29 @@ fun ContentEntryDetailOverviewScreen( ) } + items( + items = uiState.remoteImportJobs, + key = { Pair("remoteimport", it.cjiUid) } + ) {contentJobItem -> + val canCancel = uiState.canCancelRemoteImportJob(contentJobItem) + + UstadLinearProgressListItem( + progress = contentJobItem.progress, + supportingContent = { + Text(stringResource(MR.strings.importing)) + }, + onCancel = if(canCancel) { + { onCancelRemoteImport(contentJobItem.cjiUid) } + }else { + null + }, + error = contentJobItem.cjiError, + onDismissError = { + onDismissRemoteImportError(contentJobItem.cjiUid) + } + ) + } + item { HorizontalDivider(thickness = 1.dp) }