Skip to content

Commit

Permalink
Implement job control for entries processed on the server (issue #750)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
mikedawson committed Apr 10, 2024
1 parent 186493b commit 78c82ca
Show file tree
Hide file tree
Showing 20 changed files with 338 additions and 35 deletions.
Expand Up @@ -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
Expand Down Expand Up @@ -474,5 +475,13 @@ val DesktopDomainDiModule = DI.Module("Desktop-Domain") {
)
}

bind<CancelRemoteContentEntryImportUseCase>() with scoped(EndpointScope.Default).provider {
CancelRemoteContentEntryImportUseCase(
endpoint = context,
httpClient = instance(),
repo = instance(tag = DoorTag.TAG_REPO),
)
}


}
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -426,11 +430,18 @@ fun Application.umRestApplication(
)
}

bind<ValidateUserSessionOnServerUseCase>() with scoped(EndpointScope.Default).singleton {
ValidateUserSessionOnServerUseCase(
db = instance(tag = DoorTag.TAG_DB),
nodeIdAuthCache = instance(),
)
}

bind<SetPasswordServerUseCase>() with scoped(EndpointScope.Default).singleton {
SetPasswordServerUseCase(
db = instance(tag = DoorTag.TAG_DB),
setPasswordUseCase = instance(),
nodeIdAndAuthCache = instance(),
validateUserSessionOnServerUseCase = instance()
)
}

Expand Down Expand Up @@ -541,6 +552,22 @@ fun Application.umRestApplication(
BulkAddPersonStatusMap()
}

bind<CancelImportContentEntryUseCase>() with scoped(EndpointScope.Default).singleton {
CancelImportContentEntryUseCaseJvm(
scheduler = instance(),
endpoint = context,
)
}

bind<CancelImportContentEntryServerUseCase>() with scoped(EndpointScope.Default).singleton {
CancelImportContentEntryServerUseCase(
cancelImportContentEntryUseCase = instance(),
validateUserSessionOnServerUseCase = instance(),
db = instance(tag = DoorTag.TAG_DB),
endpoint = context,
)
}

try {
appConfig.config("mail")

Expand Down Expand Up @@ -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() }
)
}

Expand Down
@@ -1,27 +1,34 @@
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
val db = dbFn(call)
val inProgressJobs = db.contentEntryImportJobDao.findInProgressJobsByContentEntryUidAsync(
contentEntryUid
)

call.response.header("cache-control", "no-store")
call.respondText(
contentType = ContentType.Application.Json,
text = json.encodeToString(
Expand All @@ -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)
}
}
}
Expand Up @@ -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
Expand Down Expand Up @@ -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())
}
Expand Down
1 change: 1 addition & 0 deletions app-react/src/jsMain/kotlin/UstadJsDi.kt
Expand Up @@ -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
Expand Down
Expand Up @@ -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<UstadLinearProgressListItemProps> {props ->
val strings = useStringProvider()
val errorVal = props.error
val progressVal = props.progress
val onCancelVal = props.onCancel
val onDismissErrorVal = props.onDismissError

ListItem {
if(errorVal != null) {
Expand Down Expand Up @@ -55,6 +60,28 @@ val UstadLinearProgressListItem = FC<UstadLinearProgressListItemProps> {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()
}
}
}
}
}

Expand Down
Expand Up @@ -72,6 +72,10 @@ external interface ContentEntryDetailOverviewScreenProps : Props {

var onClickTranslation: (ContentEntryRelatedEntryJoinWithLanguage) -> Unit

var onCancelRemoteImport: (Long) -> Unit

var onDismissRemoteImportError: (Long) -> Unit

}

val ContentEntryDetailOverviewComponent2 = FC<ContentEntryDetailOverviewScreenProps> { props ->
Expand Down Expand Up @@ -105,10 +109,20 @@ val ContentEntryDetailOverviewComponent2 = FC<ContentEntryDetailOverviewScreenPr
}

props.uiState.remoteImportJobs.forEach {
val canCancelJob = props.uiState.canCancelRemoteImportJob(it)
UstadLinearProgressListItem {
progress = it.progress
secondaryContent = ReactNode(strings[MR.strings.importing])
error = it.cjiError
if(canCancelJob) {
onCancel = {
props.onCancelRemoteImport(it.cjiUid)
}
onDismissError = {
props.onDismissRemoteImportError(it.cjiUid)
}
}

}
}

Expand Down Expand Up @@ -353,6 +367,8 @@ val ContentEntryDetailOverviewScreen = FC<Props> {
ContentEntryDetailOverviewComponent2 {
uiState = uiStateVal
onClickOpen = viewModel::onClickOpen
onCancelRemoteImport = viewModel::onCancelRemoteImport
onDismissRemoteImportError = viewModel::onDismissRemoteImportError
}
}

Expand Down
Expand Up @@ -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)
Expand Down
@@ -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}" }
}

}
@@ -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")
}
}

}

0 comments on commit 78c82ca

Please sign in to comment.