diff --git a/assessment-api/assessment-actors/src/main/scala/org/sunbird/utils/AssessmentContants.scala b/assessment-api/assessment-actors/src/main/scala/org/sunbird/utils/AssessmentContants.scala index 16e47ccde..af637268e 100644 --- a/assessment-api/assessment-actors/src/main/scala/org/sunbird/utils/AssessmentContants.scala +++ b/assessment-api/assessment-actors/src/main/scala/org/sunbird/utils/AssessmentContants.scala @@ -45,4 +45,35 @@ object AssessmentConstants { val PRE_CONDITION: String = "preCondition" val SOURCE: String = "source" val PRE_CONDITION_VAR : String = "var" + val ASSESSMENTS = "assessments" + val QUESTION_SET_TOKEN = "questionSetToken" + val QUESTION_LIST = "questionList" + val QUESTION_LIST_EDITOR_URL = "question.list.search.editor.url" + val QUESTIONS = "questions" + val CORRECT_RESPONSE = "correctResponse" + val EVENTS = "events" + val EDATA = "edata" + val ITEM = "item" + val ID = "id" + val RESPONSE1 = "response1" + val CARDINALITY = "cardinality" + val MAX_SCORE = "maxScore" + val MULTIPLE = "multiple" + val EDITOR_STATE = "editorState" + val RESPONSE_DECLARATION = "responseDeclaration" + val PASS = "pass" + val YES = "Yes" + val NO = "No" + val RESVALUES = "resvalues" + val PARAMS = "params" + val SCORE = "score" + val VALUE = "value" + val MAPPING = "mapping" + val RESPONSE = "response" + val OUTCOMES = "outcomes" + val OPTIONS = "options" + val EVAL: String = "evalMode" + val SERVER: String = "server" + val FLOWER_BRACKETS: String = "{}" + } diff --git a/assessment-api/assessment-actors/src/main/scala/org/sunbird/v5/actors/QuestionActor.scala b/assessment-api/assessment-actors/src/main/scala/org/sunbird/v5/actors/QuestionActor.scala index efb027b00..49029aec9 100644 --- a/assessment-api/assessment-actors/src/main/scala/org/sunbird/v5/actors/QuestionActor.scala +++ b/assessment-api/assessment-actors/src/main/scala/org/sunbird/v5/actors/QuestionActor.scala @@ -1,16 +1,18 @@ package org.sunbird.v5.actors +import com.fasterxml.jackson.databind.ObjectMapper import org.apache.commons.lang3.StringUtils import org.sunbird.`object`.importer.{ImportConfig, ImportManager} import org.sunbird.actor.core.BaseActor import org.sunbird.common.dto.{Request, Response, ResponseHandler} -import org.sunbird.common.exception.ClientException +import org.sunbird.common.exception.{ClientException, ServerException} import org.sunbird.common.{DateUtils, Platform} import org.sunbird.graph.OntologyEngineContext import org.sunbird.graph.nodes.DataNode import org.sunbird.graph.schema.DefinitionNode +import org.sunbird.graph.utils.NodeUtil import org.sunbird.managers.CopyManager -import org.sunbird.utils.{AssessmentErrorCodes, RequestUtil} +import org.sunbird.utils.{AssessmentConstants, AssessmentErrorCodes, RequestUtil} import org.sunbird.v5.managers.AssessmentV5Manager import java.util @@ -25,7 +27,8 @@ class QuestionActor @Inject()(implicit oec: OntologyEngineContext) extends BaseA private lazy val importConfig = getImportConfig() private lazy val importMgr = new ImportManager(importConfig) - val defaultVersion = Platform.config.getNumber("v5_default_qumlVersion") + val defaultVersion:String = Platform.config.getNumber("v5_default_qumlVersion").toString + private val mapper = new ObjectMapper() override def onReceive(request: Request): Future[Response] = request.getOperation match { case "createQuestion" => AssessmentV5Manager.create(request) @@ -48,6 +51,16 @@ class QuestionActor @Inject()(implicit oec: OntologyEngineContext) extends BaseA val extPropNameList:util.List[String] = DefinitionNode.getExternalProps(request.getContext.get("graph_id").asInstanceOf[String], request.getContext.get("version").asInstanceOf[String], request.getContext.get("schemaName").asInstanceOf[String]).asJava request.getRequest.put("fields", extPropNameList) DataNode.read(request).map(node => { + val serverEvaluable = node.getMetadata.get(AssessmentConstants.EVAL) + val data = serverEvaluable + if (data != null && data == AssessmentConstants.SERVER && !StringUtils.equals(request.getOrDefault("isEditor", "").asInstanceOf[String], "true")) { + val hideEditorResponse = AssessmentV5Manager.hideEditorStateAns(node) + if (StringUtils.isNotEmpty(hideEditorResponse)) + node.getMetadata.put(AssessmentConstants.EDITOR_STATE, hideEditorResponse) + val hideCorrectAns = AssessmentV5Manager.hideCorrectResponse(node) + if (StringUtils.isNotEmpty(hideCorrectAns)) + node.getMetadata.put(AssessmentConstants.RESPONSE_DECLARATION, hideCorrectAns) + } if (StringUtils.equalsIgnoreCase(node.getMetadata.get("visibility").asInstanceOf[String], "Private")) throw new ClientException(AssessmentErrorCodes.ERR_ACCESS_DENIED, s"Question visibility is private, hence access denied") ResponseHandler.OK.put("question", AssessmentV5Manager.getQuestionMetadata(node, fields, extPropNameList)) @@ -77,12 +90,46 @@ class QuestionActor @Inject()(implicit oec: OntologyEngineContext) extends BaseA RequestUtil.validateListRequest(request) val fields: util.List[String] = JavaConverters.seqAsJavaListConverter(request.get("fields").asInstanceOf[String].split(",").filter(field => StringUtils.isNotBlank(field) && !StringUtils.equalsIgnoreCase(field, "null"))).asJava request.getRequest.put("fields", fields) - DataNode.search(request).map(nodeList => { - val questionList = nodeList.map(node => AssessmentV5Manager.getQuestionMetadata(node, fields, List().asJava)).asJava - ResponseHandler.OK.put("questions", questionList).put("count", questionList.size) + val extPropNameList: util.List[String] = DefinitionNode.getExternalProps(request.getContext.get("graph_id").asInstanceOf[String], request.getContext.get("version").asInstanceOf[String], request.getContext.get("schemaName").asInstanceOf[String]).asJava + request.getRequest.put("extPropNameList", extPropNameList) + + DataNode.search(request).flatMap(nodeList => { + // Use map to process each node and return a Future[util.Map[String, AnyRef]] + val processedNodes: List[Future[util.Map[String, AnyRef]]] = nodeList.map(node => { + val serverEvaluable = node.getMetadata.get(AssessmentConstants.EVAL) + val data = serverEvaluable + if (data != null && data == AssessmentConstants.SERVER && !StringUtils.equals(request.get("isEditor").asInstanceOf[String], "true")) { + val hideEditorStateAns = AssessmentV5Manager.hideEditorStateAns(node) + if (StringUtils.isNotEmpty(hideEditorStateAns)) + node.getMetadata.put(AssessmentConstants.EDITOR_STATE, hideEditorStateAns) + val hideCorrectResponse = AssessmentV5Manager.hideCorrectResponse(node) + if (StringUtils.isNotEmpty(hideCorrectResponse)) + node.getMetadata.put(AssessmentConstants.RESPONSE_DECLARATION, hideCorrectResponse) + } + + // Process each node and return a Future[util.Map[String, AnyRef]] + val result = NodeUtil.serialize(node, fields, node.getObjectType.toLowerCase.replace("Image", ""), request.getContext.get("version").asInstanceOf[String]) + val questionMetadata = AssessmentV5Manager.getQuestionMetadata(node, fields, extPropNameList) + val responseMap: util.Map[String, AnyRef] = new util.HashMap[String, AnyRef]() + responseMap.put("question", questionMetadata) + Future.successful(responseMap) + }) + + // Use Future.sequence to collect the results into a List[util.Map[String, AnyRef]] + val collectedResponses: Future[List[util.Map[String, AnyRef]]] = Future.sequence(processedNodes) + + collectedResponses.map { responses => + val collectedResponsesJava = new java.util.ArrayList[util.Map[String, AnyRef]](responses.asJava) + ResponseHandler.OK.put("questions", collectedResponsesJava).put("count", responses.size) + }.recover { + case _ => // Handle the error case here + val errorMessage = "Failed to retrieve questions." + throw new ServerException("ERR_QUESTION_","" + errorMessage) + } }) } + def privateRead(request: Request)(implicit oec: OntologyEngineContext, ec: ExecutionContext): Future[Response] = { val fields: util.List[String] = JavaConverters.seqAsJavaListConverter(request.get("fields").asInstanceOf[String].split(",").filter(field => StringUtils.isNotBlank(field) && !StringUtils.equalsIgnoreCase(field, "null"))).asJava val extPropNameList:util.List[String] = DefinitionNode.getExternalProps(request.getContext.get("graph_id").asInstanceOf[String], request.getContext.get("version").asInstanceOf[String], request.getContext.get("schemaName").asInstanceOf[String]).asJava diff --git a/assessment-api/assessment-actors/src/main/scala/org/sunbird/v5/actors/QuestionSetActor.scala b/assessment-api/assessment-actors/src/main/scala/org/sunbird/v5/actors/QuestionSetActor.scala index 2d533812b..9955e42a1 100644 --- a/assessment-api/assessment-actors/src/main/scala/org/sunbird/v5/actors/QuestionSetActor.scala +++ b/assessment-api/assessment-actors/src/main/scala/org/sunbird/v5/actors/QuestionSetActor.scala @@ -17,19 +17,24 @@ import org.sunbird.graph.schema.{DefinitionNode, ObjectCategoryDefinition} import org.sunbird.graph.utils.NodeUtil import org.sunbird.managers.HierarchyManager.hierarchyPrefix import org.sunbird.managers.{CopyManager, HierarchyManager, UpdateHierarchyManager} -import org.sunbird.utils.{AssessmentErrorCodes, RequestUtil} +import org.sunbird.utils.{AssessmentConstants, AssessmentErrorCodes, HierarchyConstants, RequestUtil} import org.sunbird.v5.managers.AssessmentV5Manager import scala.collection.JavaConverters import scala.collection.JavaConverters._ import scala.concurrent.{ExecutionContext, Future} +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.scala.DefaultScalaModule +import com.fasterxml.jackson.databind.JsonNode + +import scala.collection.mutable class QuestionSetActor @Inject()(implicit oec: OntologyEngineContext) extends BaseActor { implicit val ec: ExecutionContext = getContext().dispatcher private lazy val importConfig = getImportConfig() private lazy val importMgr = new ImportManager(importConfig) - val defaultVersion = Platform.config.getNumber("v5_default_qumlVersion") + val defaultVersion:String = Platform.config.getNumber("v5_default_qumlVersion").toString override def onReceive(request: Request): Future[Response] = request.getOperation match { case "createQuestionSet" => AssessmentV5Manager.create(request) @@ -49,6 +54,7 @@ class QuestionSetActor @Inject()(implicit oec: OntologyEngineContext) extends Ba case "copyQuestionSet" => copy(request) case "updateCommentQuestionSet" => updateComment(request) case "readCommentQuestionSet" => AssessmentV5Manager.readComment(request, "comments") + case "assessQuestionSet" => assessment(request) case _ => ERROR(request.getOperation) } @@ -74,24 +80,55 @@ class QuestionSetActor @Inject()(implicit oec: OntologyEngineContext) extends Ba } def getHierarchy(request: Request)(implicit oec: OntologyEngineContext, ec: ExecutionContext): Future[Response] = { - HierarchyManager.getHierarchy(request).map(resp => { - if (StringUtils.equalsIgnoreCase(resp.getResponseCode.toString, "OK")) { - val hierarchyMap = resp.getResult.get("questionSet").asInstanceOf[util.Map[String, AnyRef]] + HierarchyManager.getHierarchy(request).map(resp => { + if (StringUtils.equalsIgnoreCase(resp.getResponseCode.toString, "OK")) { + val hierarchyMap = resp.getResult.get("questionSet").asInstanceOf[util.Map[String, AnyRef]] + val schemaVersion = hierarchyMap.getOrDefault("schemaVersion", "1.1").asInstanceOf[String] + val updateHierarchy = if (StringUtils.equalsIgnoreCase(schemaVersion, "1.0")) { val hStr: String = JsonUtils.serialize(hierarchyMap) val regex = """\"identifier\":\"(.*?)\.img\"""" val pattern = regex.r val updateHStr = pattern.replaceAllIn(hStr, m => s""""identifier":"${m.group(1)}"""") val updatedHierarchyMap = JsonUtils.deserialize[util.Map[String, AnyRef]](updateHStr, classOf[util.Map[String, AnyRef]]) - val schemaVersion = updatedHierarchyMap.getOrDefault("schemaVersion", "1.0").asInstanceOf[String] - val updateHierarchy = if (StringUtils.equalsIgnoreCase("1.0", schemaVersion)) AssessmentV5Manager.getTransformedHierarchy(updatedHierarchyMap) else { - updatedHierarchyMap + AssessmentV5Manager.getTransformedHierarchy(updatedHierarchyMap).asInstanceOf[mutable.Map[String, AnyRef]] + } else { + mutable.Map[String, AnyRef](hierarchyMap.asScala.toSeq: _*) + } + val mode = request.getOrDefault("mode", "").asInstanceOf[String] + val serverEvaluable = request.getOrDefault(HierarchyConstants.SERVEREVALUABLE, HierarchyConstants.FALSE).asInstanceOf[String] + if (!mode.equals("edit") && serverEvaluable.equalsIgnoreCase(HierarchyConstants.TRUE)) { + val childrenList = updateHierarchy.get(HierarchyConstants.CHILDREN).getOrElse(new util.ArrayList[java.util.Map[String, AnyRef]]()) + .asInstanceOf[util.ArrayList[java.util.Map[String, AnyRef]]] + val updatedChildrenList = childrenList.asScala.map(child => { + val maxQuestions = Option(child.get(HierarchyConstants.MAXQUESTIONS)).map(_.asInstanceOf[Int]).getOrElse(0) + val shuffle = Option(child.get(HierarchyConstants.SHUFFLE)).map(_.asInstanceOf[Boolean]).getOrElse(false) + val randomizedChild = if (shuffle) HierarchyManager.shuffleQuestions(child) else child + val limitedChild = HierarchyManager.limitQuestions(randomizedChild, maxQuestions) + + limitedChild + }).asJava + val serverEvaluable = updatedChildrenList.get(0).get(HierarchyConstants.EVAL) + if (serverEvaluable != null && serverEvaluable == HierarchyConstants.SERVER) { + request.put(HierarchyConstants.EVAL_MODE, HierarchyConstants.SERVER) + } else { + request.put(HierarchyConstants.EVAL_MODE, HierarchyConstants.CLIENT) } - resp.getResult.remove("questionSet") - resp.put("questionset", updateHierarchy) - resp - } else resp - }) - } + val nestedChildrenIdentifiers = HierarchyManager.getNestedChildrenIdentifiers(updatedChildrenList) + val mergedMap: util.Map[String, String] = HierarchyManager.createMergedMap(request, nestedChildrenIdentifiers) + val userMapJson = JsonUtils.serialize(mergedMap) + val jwtToken = HierarchyManager.generateJwtToken(userMapJson) + updateHierarchy.put(HierarchyConstants.QUESTIONSETTOKEN, jwtToken) + updateHierarchy.put(HierarchyConstants.IDENTIFIER, request.get("contentID")) + updateHierarchy.put(HierarchyConstants.CHILDREN, updatedChildrenList) + resp.getResult.put("questionset", updateHierarchy.asJava) + } + resp + } else { + resp.getResult.remove("questionSet") + resp + } + }) +} @throws[Exception] def review(request: Request): Future[Response] = { @@ -262,5 +299,12 @@ class QuestionSetActor @Inject()(implicit oec: OntologyEngineContext) extends Ba } } } + private def assessment(req: Request): Future[Response] = { + val assessments = req.getRequest.getOrDefault(AssessmentConstants.ASSESSMENTS, new util.ArrayList[util.Map[String, AnyRef]]).asInstanceOf[util.List[util.Map[String, AnyRef]]] + val quesDoIds = AssessmentV5Manager.validateAssessRequest(req) + val list: Response = AssessmentV5Manager.questionList(quesDoIds) + AssessmentV5Manager.calculateScore(list, assessments) + Future(ResponseHandler.OK.put(AssessmentConstants.QUESTIONS, req.getRequest)) + } } diff --git a/assessment-api/assessment-actors/src/main/scala/org/sunbird/v5/managers/AssessmentV5Manager.scala b/assessment-api/assessment-actors/src/main/scala/org/sunbird/v5/managers/AssessmentV5Manager.scala index 674805697..a58d3a525 100644 --- a/assessment-api/assessment-actors/src/main/scala/org/sunbird/v5/managers/AssessmentV5Manager.scala +++ b/assessment-api/assessment-actors/src/main/scala/org/sunbird/v5/managers/AssessmentV5Manager.scala @@ -1,5 +1,6 @@ package org.sunbird.v5.managers +import com.fasterxml.jackson.databind.ObjectMapper import org.apache.commons.collections4.CollectionUtils import org.apache.commons.lang3.StringUtils import org.sunbird.common.{DateUtils, JsonUtils, Platform} @@ -12,7 +13,7 @@ import org.sunbird.graph.schema.{DefinitionNode, ObjectCategoryDefinition} import org.sunbird.graph.utils.NodeUtil import org.sunbird.managers.HierarchyManager import org.sunbird.telemetry.util.LogTelemetryEventUtil -import org.sunbird.utils.{AssessmentErrorCodes, RequestUtil} +import org.sunbird.utils.{AssessmentConstants, AssessmentErrorCodes, RequestUtil} import java.util import java.util.UUID @@ -22,6 +23,11 @@ import scala.collection.convert.ImplicitConversions._ import scala.collection.JavaConverters._ import scala.collection.mutable.ListBuffer import scala.concurrent.duration.Duration +import com.mashape.unirest.http.Unirest +import org.apache.http.HttpResponse +import org.sunbird.utils.{AssessmentConstants, JavaJsonUtils, RequestUtil} +import com.fasterxml.jackson.databind.node.{ArrayNode, ObjectNode} +import org.sunbird.common.exception.{ClientException, ErrorCodes, ResourceNotFoundException, ServerException} object AssessmentV5Manager { @@ -29,6 +35,8 @@ object AssessmentV5Manager { val supportedVersions: java.util.List[Number] = Platform.config.getNumberList("v5_supported_qumlVersions") val skipValidation: Boolean = Platform.getBoolean("assessment.skip.validation", false) val validStatus = List("Draft", "Review") + val mapper = new ObjectMapper() + val map = Map("userId" -> "userID", "attemptId" -> "attemptID") def validateAndGetVersion(ver: AnyRef): AnyRef = { if (supportedVersions.contains(ver)) ver else throw new ClientException(AssessmentErrorCodes.ERR_REQUEST_DATA_VALIDATION, s"Platform doesn't support quml version ${ver} | Currently Supported quml version are: ${supportedVersions}") @@ -85,6 +93,16 @@ object AssessmentV5Manager { def getValidateNodeForReject(request: Request, errCode: String)(implicit ec: ExecutionContext, oec: OntologyEngineContext): Future[Node] = { request.put("mode", "edit") DataNode.read(request).map(node => { + val serverEvaluable = node.getMetadata.get(AssessmentConstants.EVAL) + val data = serverEvaluable + if (data != null && data == AssessmentConstants.SERVER && !StringUtils.equals(request.getOrDefault("isEditor", "").asInstanceOf[String], "true")) { + val hideEditorResponse = hideEditorStateAns(node) + if (StringUtils.isNotEmpty(hideEditorResponse)) + node.getMetadata.put(AssessmentConstants.EDITOR_STATE, hideEditorResponse) + val hideCorrectAns = hideCorrectResponse(node) + if (StringUtils.isNotEmpty(hideCorrectAns)) + node.getMetadata.put(AssessmentConstants.RESPONSE_DECLARATION, hideCorrectAns) + } if (StringUtils.equalsIgnoreCase(node.getMetadata.getOrDefault("visibility", "").asInstanceOf[String], "Parent")) throw new ClientException(errCode, s"${node.getObjectType.replace("Image", "")} with visibility Parent, can't be sent for reject individually.") if (!StringUtils.equalsIgnoreCase("Review", node.getMetadata.get("status").asInstanceOf[String])) @@ -190,6 +208,7 @@ object AssessmentV5Manager { def updateHierarchy(hierarchy: util.Map[String, AnyRef], status: String, rootUserId: String): (java.util.Map[String, AnyRef], java.util.List[String]) = { val keys = List("identifier", "children").asJava hierarchy.keySet().retainAll(keys) + val children = hierarchy.getOrDefault("children", new util.ArrayList[java.util.Map[String, AnyRef]]).asInstanceOf[util.List[java.util.Map[String, AnyRef]]] val childrenToUpdate: List[String] = updateChildrenRecursive(children, status, List(), rootUserId) (hierarchy, childrenToUpdate.asJava) @@ -515,6 +534,167 @@ object AssessmentV5Manager { } } + + def validateAssessRequest(req: Request) = { + val body = req.getRequest + val jwt = body.getOrDefault(AssessmentConstants.QUESTION_SET_TOKEN, "").asInstanceOf[String] + val payload = try { + val tuple = HierarchyManager.verifyRS256Token(jwt) + if (tuple._1 == false) + throw new ClientException(ErrorCodes.ERR_BAD_REQUEST.name(), "Token Authentication Failed") + tuple._2.get("data").asInstanceOf[String] + } catch { + case e: Exception => throw new ClientException(ErrorCodes.ERR_BAD_REQUEST.name(), "Token Authentication Failed") + } + val questToken = JavaJsonUtils.deserialize[java.util.Map[String, AnyRef]](payload) + val assessments = body.getOrDefault(AssessmentConstants.ASSESSMENTS, new util.ArrayList[util.Map[String, AnyRef]]).asInstanceOf[util.List[util.Map[String, AnyRef]]] + val courseMetaData = Option(assessments.get(0)).getOrElse(new util.HashMap[String, AnyRef]) + val count = map.filter(key => StringUtils.equals(courseMetaData.get(key._1).asInstanceOf[String], questToken.get(key._2).asInstanceOf[String])).size + if (count != map.size) + throw new ClientException(ErrorCodes.ERR_BAD_REQUEST.name(), "Token Authentication Failed") + questToken.get(AssessmentConstants.QUESTION_LIST).asInstanceOf[String].split(",").asInstanceOf[Array[String]] + } + + def questionList(fields: Array[String]): Response = { + val url: String = Platform.getString(AssessmentConstants.QUESTION_LIST_EDITOR_URL, "") + val bdy = "{\"request\":{\"search\":{\"identifier\":" + JavaJsonUtils.serialize(fields) + "}}}" + val httpResponse = post(url, bdy) + if (200 != httpResponse.status) throw new ServerException("ERR_QUESTION_LIST_API_COMM", "Error communicating to question list api") + JsonUtils.deserialize(httpResponse.body, classOf[Response]) + } + + def calculateScore(privateList: Response, assessments: util.List[util.Map[String, AnyRef]]): Unit = { +// val answerMaps: (Map[String, AnyRef], Map[String, AnyRef]) = getListMap(privateList.getResult, AssessmentConstants.QUESTIONS) +// .map { que => +// ((que.get(AssessmentConstants.IDENTIFIER).toString -> que.get(AssessmentConstants.RESPONSE_DECLARATION)), +// (que.get(AssessmentConstants.IDENTIFIER).toString -> que.get(AssessmentConstants.EDITOR_STATE))) +// ) +// }.unzip match { +// case (map1, map2) => (map1.toMap, map2.toMap) +// } +val answerMaps: (Map[String, AnyRef], Map[String, AnyRef], Map[String, AnyRef]) = { + val listOfMaps = getListMap(privateList.getResult, AssessmentConstants.QUESTIONS) + .map { que => + ( + que.get(AssessmentConstants.IDENTIFIER).toString -> que.get(AssessmentConstants.RESPONSE_DECLARATION), + que.get(AssessmentConstants.IDENTIFIER).toString -> que.get(AssessmentConstants.EDITOR_STATE), + que.get(AssessmentConstants.IDENTIFIER).toString -> que.get(AssessmentConstants.MAX_SCORE) + ) + } + val (map1, map2, map3) = (listOfMaps.map(_._1).toMap, listOfMaps.map(_._2).toMap, listOfMaps.map(_._3).toMap) + (map1, map2, map3) +} + val answerMap = answerMaps._1 + val editorStateMap = answerMaps._2 + val maxScoreMap = answerMaps._3 + assessments.foreach { k => + getListMap(k, AssessmentConstants.EVENTS).toList.foreach { event => + val edata = getMap(event, AssessmentConstants.EDATA) + val item = getMap(edata, AssessmentConstants.ITEM) + val identifier = item.getOrDefault(AssessmentConstants.ID, "").asInstanceOf[String] + if (!answerMap.contains(identifier)) + throw new ClientException(ErrorCodes.ERR_BAD_REQUEST.name(), "Invalid Request") + val res = getMap(answerMap.get(identifier).asInstanceOf[Some[util.Map[String, AnyRef]]].x, AssessmentConstants.RESPONSE1) + val cardinality = res.getOrDefault(AssessmentConstants.CARDINALITY, "").asInstanceOf[String] + // val maxScore = res.getOrDefault(AssessmentConstants.MAX_SCORE, 0.asInstanceOf[Integer]).asInstanceOf[Integer] + val maxScoreOption = maxScoreMap.get(identifier) + val maxScore = maxScoreOption.getOrElse(0).asInstanceOf[Integer] + cardinality match { + case AssessmentConstants.MULTIPLE => populateMultiCardinality(res, edata, maxScore) + case _ => populateSingleCardinality(res, edata, maxScore) + } + populateParams(item, editorStateMap) + } + } + } + + private def getListMap(arg: util.Map[String, AnyRef], param: String) = { + arg.getOrDefault(param, new util.ArrayList[util.Map[String, AnyRef]]()).asInstanceOf[util.ArrayList[util.Map[String, AnyRef]]] + } + + private def getMap(arg: util.Map[String, AnyRef], param: String) = { + arg.getOrDefault(param, new util.HashMap[String, AnyRef]()).asInstanceOf[util.Map[String, AnyRef]] + } + + def hideEditorStateAns(node: Node): String = { + // Modify editorState + Option(node.getMetadata.get("editorState")) match { + case Some(jsonStr: String) => + val jsonNode = mapper.readTree(jsonStr) + //if (jsonNode != null && jsonNode.has("question")) { + //val questionNode = jsonNode.get("question") + if (jsonNode != null && jsonNode.has("options")) { + val optionsNode = jsonNode.get("options").asInstanceOf[ArrayNode] + val iterator = optionsNode.elements() + while (iterator.hasNext) { + val optionNode = iterator.next().asInstanceOf[ObjectNode] + optionNode.remove("answer") + } + //} + } + mapper.writeValueAsString(jsonNode) + case _ => "" + } + } + + def hideCorrectResponse(node: Node): String = { + val responseDeclaration = Option(node.getMetadata.get("responseDeclaration")) match { + case Some(jsonStr: String) => jsonStr + case _ => "" + } + val jsonNode = mapper.readTree(responseDeclaration) + if (null != jsonNode && jsonNode.has("response1")) { + val responseNode = jsonNode.get("response1").asInstanceOf[ObjectNode] + responseNode.remove("correctResponse") + mapper.writeValueAsString(jsonNode) + } + else + "" + } + + private def populateParams(item: util.Map[String, AnyRef], editorState: Map[String, AnyRef]) = { + item.put(AssessmentConstants.PARAMS, editorState.get(item.get(AssessmentConstants.ID)).asInstanceOf[util.Map[String, AnyRef]].get(AssessmentConstants.OPTIONS)) + } + + private def post(url: String, requestBody: String, headers: Map[String, String] = Map[String, String]("Content-Type" -> "application/json")): HTTPResponse = { + val res = Unirest.post(url).headers(headers.asJava).body(requestBody).asString() + HTTPResponse(res.getStatus, res.getBody) + } + + private case class HTTPResponse(status: Int, body: String) extends Serializable + + private def populateSingleCardinality(res: util.Map[String, AnyRef], edata: util.Map[String, AnyRef], maxScore: Integer): Unit = { + val correctValue = getMap(res, AssessmentConstants.CORRECT_RESPONSE).getOrDefault(AssessmentConstants.VALUE, new util.ArrayList[Integer]).toString + val usrResponse = getListMap(edata, AssessmentConstants.RESVALUES).get(0).getOrDefault(AssessmentConstants.VALUE, "").toString + StringUtils.equals(usrResponse, correctValue) match { + case true => { + edata.put(AssessmentConstants.SCORE, maxScore) + edata.put(AssessmentConstants.PASS, AssessmentConstants.YES) + } + case _ => { + edata.put(AssessmentConstants.SCORE, 0.asInstanceOf[Integer]) + edata.put(AssessmentConstants.PASS, AssessmentConstants.NO) + } + } + } + + private def populateMultiCardinality(res: util.Map[String, AnyRef], edata: util.Map[String, AnyRef], maxScore: Integer) = { + val correctValue = getMap(res, AssessmentConstants.CORRECT_RESPONSE).getOrDefault(AssessmentConstants.VALUE, new util.ArrayList[Integer]).asInstanceOf[util.ArrayList[Integer]].flatMap(k => List(k)).sorted + val usrResponse = edata.getOrDefault(AssessmentConstants.RESVALUES, new util.ArrayList[util.ArrayList[util.Map[String, AnyRef]]]()) + .asInstanceOf[util.ArrayList[util.ArrayList[util.Map[String, AnyRef]]]] + .flatMap(_.flatMap(res => List(res.getOrDefault(AssessmentConstants.VALUE, -1.asInstanceOf[Integer]).asInstanceOf[Integer]))).sorted + correctValue.equals(usrResponse) match { + case true => edata.put(AssessmentConstants.SCORE, maxScore) + case _ => { + var ttlScr = 0.0d + getListMap(res, AssessmentConstants.MAPPING).foreach(k => if (usrResponse.contains(k.getOrDefault(AssessmentConstants.RESPONSE, -1.asInstanceOf[Integer]).asInstanceOf[Integer])) + ttlScr += getMap(k, AssessmentConstants.OUTCOMES).get(AssessmentConstants.SCORE).asInstanceOf[Double]) + edata.put(AssessmentConstants.SCORE, ttlScr.asInstanceOf[AnyRef]) + if (ttlScr > 0) edata.put(AssessmentConstants.PASS, AssessmentConstants.YES) else edata.put(AssessmentConstants.PASS, AssessmentConstants.NO) + } + } + } + def readComment(request: Request, resName: String)(implicit oec: OntologyEngineContext, ec: ExecutionContext): Future[Response] = { val fields: util.List[String] = JavaConverters.seqAsJavaListConverter(request.get("fields").asInstanceOf[String].split(",").filter(field => StringUtils.isNotBlank(field) && !StringUtils.equalsIgnoreCase(field, "null"))).asJava request.getRequest.put("fields", fields) @@ -534,4 +714,5 @@ object AssessmentV5Manager { }) } + } diff --git a/assessment-api/assessment-service/app/controllers/v5/QuestionController.scala b/assessment-api/assessment-service/app/controllers/v5/QuestionController.scala index dcbda8868..605d71a71 100644 --- a/assessment-api/assessment-service/app/controllers/v5/QuestionController.scala +++ b/assessment-api/assessment-service/app/controllers/v5/QuestionController.scala @@ -26,14 +26,8 @@ class QuestionController @Inject()(@Named(ActorNames.QUESTION_V5_ACTOR) question getResult(ApiId.CREATE_QUESTION, questionActor, questionRequest) } - def read(identifier: String, mode: Option[String], fields: Option[String]) = Action.async { implicit request => - val headers = commonHeaders() - val question = new java.util.HashMap().asInstanceOf[java.util.Map[String, Object]] - question.putAll(headers) - question.putAll(Map("identifier" -> identifier, "fields" -> fields.getOrElse(""), "mode" -> mode.getOrElse("read")).asJava) - val questionRequest = getRequest(question, headers, QuestionOperations.readQuestion.toString) - setRequestContext(questionRequest, defaultVersion, objectType, schemaName) - getResult(ApiId.READ_QUESTION, questionActor, questionRequest) + def read(identifier: String, mode: Option[String], fields: Option[String]) = { + readQuestion(identifier, mode, fields, false) } def privateRead(identifier: String, mode: Option[String], fields: Option[String]) = Action.async { implicit request => @@ -112,16 +106,8 @@ class QuestionController @Inject()(@Named(ActorNames.QUESTION_V5_ACTOR) question getResult(ApiId.SYSTEM_UPDATE_QUESTION, questionActor, questionRequest) } - def list(fields: Option[String]) = Action.async { implicit request => - val headers = commonHeaders() - val body = requestBody() - val question = body.getOrDefault("search", new java.util.HashMap()).asInstanceOf[java.util.Map[String, Object]]; - question.putAll(headers) - question.put("fields", fields.getOrElse("")) - val questionRequest = getRequest(question, headers, QuestionOperations.listQuestions.toString) - questionRequest.put("identifiers", questionRequest.get("identifier")) - setRequestContext(questionRequest, defaultVersion, objectType, schemaName) - getResult(ApiId.LIST_QUESTIONS, questionActor, questionRequest) + def list(fields: Option[String]) = { + fetchQuestions(fields, false) } def reject(identifier: String) = Action.async { implicit request => @@ -145,4 +131,36 @@ class QuestionController @Inject()(@Named(ActorNames.QUESTION_V5_ACTOR) question setRequestContext(questionRequest, defaultVersion, objectType, schemaName) getResult(ApiId.COPY_QUESTION, questionActor, questionRequest) } + + def editorList(fields: Option[String]) = { + fetchQuestions(fields, true) + } + + def editorRead(identifier: String, mode: Option[String], fields: Option[String]) = { + readQuestion(identifier, mode, fields, true) + } + + private def readQuestion(identifier: String, mode: Option[String], fields: Option[String], exclusive: Boolean) = Action.async { implicit request => + val headers = commonHeaders() + val question = new java.util.HashMap().asInstanceOf[java.util.Map[String, Object]] + question.putAll(headers) + question.putAll(Map("identifier" -> identifier, "fields" -> fields.getOrElse(""), "mode" -> mode.getOrElse("read")).asJava) + if (exclusive) question.put("isEditor", "true") + val questionRequest = getRequest(question, headers, QuestionOperations.readQuestion.toString) + setRequestContext(questionRequest, defaultVersion, objectType, schemaName) + getResult(ApiId.READ_QUESTION, questionActor, questionRequest) + } + + private def fetchQuestions(fields: Option[String], exclusive: Boolean) = Action.async { implicit request => + val headers = commonHeaders() + val body = requestBody() + val question = body.getOrDefault("search", new java.util.HashMap()).asInstanceOf[java.util.Map[String, Object]]; + question.putAll(headers) + question.put("fields", fields.getOrElse("")) + if (exclusive) question.put("isEditor", "true") + val questionRequest = getRequest(question, headers, QuestionOperations.listQuestions.toString) + questionRequest.put("identifiers", questionRequest.get("identifier")) + setRequestContext(questionRequest, defaultVersion, objectType, schemaName) + getResult(ApiId.LIST_QUESTIONS, questionActor, questionRequest) + } } diff --git a/assessment-api/assessment-service/app/controllers/v5/QuestionSetController.scala b/assessment-api/assessment-service/app/controllers/v5/QuestionSetController.scala index 2e57a4702..2d6fae6c9 100644 --- a/assessment-api/assessment-service/app/controllers/v5/QuestionSetController.scala +++ b/assessment-api/assessment-service/app/controllers/v5/QuestionSetController.scala @@ -123,14 +123,18 @@ class QuestionSetController @Inject()(@Named(ActorNames.QUESTION_SET_V5_ACTOR) q getResult(ApiId.UPDATE_HIERARCHY, questionSetActor, questionSetRequest) } - def getHierarchy(identifier: String, mode: Option[String]) = Action.async { implicit request => - val headers = commonHeaders() - val questionSet = new java.util.HashMap().asInstanceOf[java.util.Map[String, Object]] - questionSet.putAll(headers) - questionSet.putAll(Map("rootId" -> identifier, "mode" -> mode.getOrElse("")).asJava) - val readRequest = getRequest(questionSet, headers, "getHierarchy") - setRequestContext(readRequest, defaultVersion, objectType, schemaName) - getResult(ApiId.GET_HIERARCHY, questionSetActor, readRequest) +// def getHierarchy(identifier: String, mode: Option[String]) = Action.async { implicit request => +// val headers = commonHeaders() +// val questionSet = new java.util.HashMap().asInstanceOf[java.util.Map[String, Object]] +// questionSet.putAll(headers) +// questionSet.putAll(Map("rootId" -> identifier, "mode" -> mode.getOrElse("")).asJava) +// val readRequest = getRequest(questionSet, headers, "getHierarchy") +// setRequestContext(readRequest, defaultVersion, objectType, schemaName) +// getResult(ApiId.GET_HIERARCHY, questionSetActor, readRequest) +// } + + def getHierarchy(identifier: String, mode: Option[String]) = { + fetchHierarchy(identifier, mode) } def reject(identifier: String) = Action.async { implicit request => @@ -175,6 +179,19 @@ class QuestionSetController @Inject()(@Named(ActorNames.QUESTION_SET_V5_ACTOR) q getResult(ApiId.COPY_QUESTION_SET, questionSetActor, questionSetRequest) } + def getHierarchyRead(identifier: String, mode: Option[String]) = { + fetchHierarchy(identifier, mode, "true") + } + def fetchHierarchy(identifier: String, mode: Option[String], evaluable: String = "false") = Action.async { implicit request => + val headers = commonHeaders() + val body = requestBody() + val questionSet = body.getOrDefault("questionset", new java.util.HashMap()).asInstanceOf[java.util.Map[String, Object]]; + questionSet.putAll(headers) + questionSet.putAll(Map("rootId" -> identifier, "mode" -> mode.getOrElse(""), "serverEvaluable" -> evaluable).asJava) + val readRequest = getRequest(questionSet, headers, "getHierarchy") + setRequestContext(readRequest, defaultVersion, objectType, schemaName) + getResult(ApiId.GET_HIERARCHY, questionSetActor, readRequest) + def updateComment(identifier: String) = Action.async { implicit request => val headers = commonHeaders() val body = requestBody() @@ -189,13 +206,33 @@ class QuestionSetController @Inject()(@Named(ActorNames.QUESTION_SET_V5_ACTOR) q getResult(ApiId.UPDATE_COMMENT_QUESTION_SET, questionSetActor, questionSetRequest) } - def readComment(identifier: String) = Action.async { implicit request => - val headers = commonHeaders() - val questionSet = new java.util.HashMap().asInstanceOf[java.util.Map[String, Object]] - questionSet.putAll(headers) - questionSet.putAll(Map("identifier" -> identifier, "fields" -> "", "mode" -> "read").asJava) - val questionSetRequest = getRequest(questionSet, headers, QuestionSetOperations.readCommentQuestionSet.toString) - setRequestContext(questionSetRequest, defaultVersion, objectType, schemaName) - getResult(ApiId.READ_COMMENT_QUESTION_SET, questionSetActor, questionSetRequest) - } + def updateComment() = Action.async { implicit request => + val headers = commonHeaders() + val body = requestBody() + val commentList = body.getOrElse("comments", new java.util.ArrayList[java.util.Map[String, Object]]()).asInstanceOf[java.util.ArrayList[java.util.Map[String, Object]]].asScala.toList + val filteredComments = new java.util.ArrayList[java.util.Map[String, Object]](commentList.groupBy(_.getOrElse("identifier", "")).values.map(_.last).toList.asJava) + val questionSet = new java.util.HashMap().asInstanceOf[java.util.Map[String, Object]] + questionSet.putAll(headers) + questionSet.put("comments", filteredComments) + val questionSetRequest = getRequest(questionSet, headers, QuestionSetOperations.updateCommentQuestionSet.toString) + setRequestContext(questionSetRequest, defaultVersion, objectType, schemaName) + getResult(ApiId.UPDATE_COMMENT_QUESTION_SET, questionSetActor, questionSetRequest) + } + + def readComment(identifier: String) = Action.async { implicit request => + val headers = commonHeaders() + val questionSet = new java.util.HashMap().asInstanceOf[java.util.Map[String, Object]] + questionSet.putAll(headers) + questionSet.putAll(Map("identifier" -> identifier, "fields" -> "", "mode" -> "read").asJava) + val questionSetRequest = getRequest(questionSet, headers, QuestionSetOperations.readCommentQuestionSet.toString) + setRequestContext(questionSetRequest, defaultVersion, objectType, schemaName) + getResult(ApiId.READ_COMMENT_QUESTION_SET, questionSetActor, questionSetRequest) + + } + def assessment() = Action.async { implicit request => + val headers = commonHeaders() + val body = requestBody() + val questionSetAssessRequest = getRequest(body, headers, QuestionSetOperations.assessQuestionSet.toString) + getResult(ApiId.ASSESS_QUESTION_SET, questionSetActor, questionSetAssessRequest) + } } diff --git a/assessment-api/assessment-service/app/utils/ActorNames.scala b/assessment-api/assessment-service/app/utils/ActorNames.scala index 526ec1b19..73206702c 100644 --- a/assessment-api/assessment-service/app/utils/ActorNames.scala +++ b/assessment-api/assessment-service/app/utils/ActorNames.scala @@ -8,5 +8,4 @@ object ActorNames { final val QUESTION_SET_ACTOR = "questionSetActor" final val QUESTION_V5_ACTOR = "questionV5Actor" final val QUESTION_SET_V5_ACTOR = "questionSetV5Actor" - } diff --git a/assessment-api/assessment-service/app/utils/ApiId.scala b/assessment-api/assessment-service/app/utils/ApiId.scala index a5ac8d2a0..47c9ef0a2 100644 --- a/assessment-api/assessment-service/app/utils/ApiId.scala +++ b/assessment-api/assessment-service/app/utils/ApiId.scala @@ -42,6 +42,10 @@ object ApiId { val IMPORT_QUESTION_SET = "api.questionset.import" val SYSTEM_UPDATE_QUESTION_SET = "api.questionset.system.update" val COPY_QUESTION_SET = "api.questionset.copy" + + val ASSESS_QUESTION_SET="api.questionset.assess" + val UPDATE_COMMENT_QUESTION_SET = "api.questionset.update.comment" val READ_COMMENT_QUESTION_SET = "api.questionset.read.comment" + } diff --git a/assessment-api/assessment-service/app/utils/QuestionSetOperations.scala b/assessment-api/assessment-service/app/utils/QuestionSetOperations.scala index f3b6cd8bc..8571385a7 100644 --- a/assessment-api/assessment-service/app/utils/QuestionSetOperations.scala +++ b/assessment-api/assessment-service/app/utils/QuestionSetOperations.scala @@ -3,5 +3,6 @@ package utils object QuestionSetOperations extends Enumeration { val createQuestionSet, readQuestionSet, readPrivateQuestionSet, updateQuestionSet, reviewQuestionSet, publishQuestionSet, retireQuestionSet, addQuestion, removeQuestion, updateHierarchyQuestion, readHierarchyQuestion, - rejectQuestionSet, importQuestionSet, systemUpdateQuestionSet, copyQuestionSet, updateCommentQuestionSet, readCommentQuestionSet = Value + rejectQuestionSet, importQuestionSet, systemUpdateQuestionSet, copyQuestionSet, assessQuestionSet, updateCommentQuestionSet, readCommentQuestionSet = Value + } diff --git a/assessment-api/assessment-service/conf/application.conf b/assessment-api/assessment-service/conf/application.conf index 8c3416b3b..4ef2acbe1 100644 --- a/assessment-api/assessment-service/conf/application.conf +++ b/assessment-api/assessment-service/conf/application.conf @@ -101,6 +101,12 @@ akka { nr-of-instances = 5 dispatcher = actors-dispatcher } + /questionSetAssessActor + { + router = smallest-mailbox-pool + nr-of-instances = 5 + dispatcher = actors-dispatcher + } } } } @@ -352,7 +358,7 @@ schema.base_path="../../schemas/" # Cassandra Configuration cassandra.lp.connection="127.0.0.1:9042" -content.keyspace = "content_store" +content.keyspace = "dev_content_store" # Redis Configuration redis.host="localhost" @@ -402,12 +408,12 @@ kafka { topic.send.enable : true topics.instruction : "sunbirddev.assessment.publish.request" } -objectcategorydefinition.keyspace="local_category_store" +objectcategorydefinition.keyspace="dev_category_store" question { - keyspace = "local_question_store" + keyspace = "dev_question_store" list.limit=20 } -questionset.keyspace="local_hierarchy_store" +questionset.keyspace="dev_hierarchy_store" cassandra { lp { @@ -445,4 +451,12 @@ assessment.copy.props_to_remove=["downloadUrl", "artifactUrl", "variants", "concepts", "keywords", "reservedDialcodes", "dialcodeRequired", "leafNodes", "sYS_INTERNAL_LAST_UPDATED_ON", "prevStatus", "lastPublishedBy", "streamingUrl"] v5_supported_qumlVersions=[1.1] -v5_default_qumlVersion=1.1 \ No newline at end of file +v5_default_qumlVersion=1.1 + +api.jwt.keyprefix=device +api.jwt.keycount=3 +api.jwt.basepath=../../keys/ +question.list.search.editor.url="http://localhost:9000/question/v5/editor/list" +useHardcodedKeys=true +api.jwt.publickey="" +api.jwt.privatekey="" \ No newline at end of file diff --git a/assessment-api/assessment-service/conf/routes b/assessment-api/assessment-service/conf/routes index f4271f7de..71f371ba2 100644 --- a/assessment-api/assessment-service/conf/routes +++ b/assessment-api/assessment-service/conf/routes @@ -75,5 +75,15 @@ POST /questionset/v5/reject/:identifier controllers.v5.QuestionSetC POST /questionset/v5/import controllers.v5.QuestionSetController.importQuestionSet() PATCH /questionset/v5/system/update/:identifier controllers.v5.QuestionSetController.systemUpdate(identifier:String) POST /questionset/v5/copy/:identifier controllers.v5.QuestionSetController.copy(identifier:String, mode:Option[String], type:String?="deep") + + +POST /questionset/v5/hierarchy/:identifier controllers.v5.QuestionSetController.getHierarchyRead(identifier:String, mode:Option[String]) +POST /question/v5/editor/list controllers.v5.QuestionController.editorList(fields:Option[String]) +GET /question/v5/editor/read/:identifier controllers.v5.QuestionController.editorRead(identifier:String, mode:Option[String], fields:Option[String]) + +# QuestionValidate API's +POST /questionset/v5/assestment/validate controllers.v5.QuestionSetController.assessment + PATCH /questionset/v5/comment/update/:identifier controllers.v5.QuestionSetController.updateComment(identifier:String) -GET /questionset/v5/comment/read/:identifier controllers.v5.QuestionSetController.readComment(identifier:String) \ No newline at end of file +GET /questionset/v5/comment/read/:identifier controllers.v5.QuestionSetController.readComment(identifier:String) + diff --git a/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/HierarchyManager.scala b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/HierarchyManager.scala index 72d868898..82177cd8d 100644 --- a/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/HierarchyManager.scala +++ b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/HierarchyManager.scala @@ -21,7 +21,9 @@ import com.mashape.unirest.http.Unirest import org.apache.commons.collections4.{CollectionUtils, MapUtils} import org.sunbird.graph.OntologyEngineContext import org.sunbird.telemetry.logger.TelemetryManager -import org.sunbird.utils.{HierarchyConstants, HierarchyErrorCodes} +import org.sunbird.utils.{HierarchyConstants, HierarchyErrorCodes, JwtUtils} + +import scala.collection.mutable object HierarchyManager { @@ -31,6 +33,8 @@ object HierarchyManager { val statusList = List("Live", "Unlisted", "Flagged") val ASSESSMENT_OBJECT_TYPES = List("Question", "QuestionSet") + val keyManager = new KeyManager(Platform.getString("api.jwt.basepath","./keys/"), Platform.getString("api.jwt.keyprefix","device"), Platform.getInteger("api.jwt.keycount",1)) + val keyTobeRemoved = { if(Platform.config.hasPath("content.hierarchy.removed_props_for_leafNodes")) Platform.config.getStringList("content.hierarchy.removed_props_for_leafNodes") @@ -250,9 +254,9 @@ object HierarchyManager { hierarchyFuture.map(result => { if (!result.isEmpty) { val bookmarkId = request.get("bookmarkId").asInstanceOf[String] - val rootHierarchy = result.get("questionSet").asInstanceOf[util.Map[String, AnyRef]] + val rootHierarchy = result.get(HierarchyConstants.QUESTIONSET).asInstanceOf[util.Map[String, AnyRef]] if (StringUtils.isEmpty(bookmarkId)) { - ResponseHandler.OK.put("questionSet", rootHierarchy) + ResponseHandler.OK.put(HierarchyConstants.QUESTIONSET, rootHierarchy) } else { val children = rootHierarchy.getOrElse("children", new util.ArrayList[util.Map[String, AnyRef]]()).asInstanceOf[util.List[util.Map[String, AnyRef]]] val bookmarkHierarchy = filterBookmarkHierarchy(children, bookmarkId) @@ -756,5 +760,67 @@ object HierarchyManager { updatedBranchingLogic } + def shuffleQuestions(child: util.Map[String, AnyRef]): util.Map[String, AnyRef] = { + val questions = child.getOrDefault(HierarchyConstants.CHILDREN, new util.ArrayList[util.Map[String, AnyRef]]()).asInstanceOf[util.List[util.Map[String, AnyRef]]] + util.Collections.shuffle(questions) + child + } + + def limitQuestions(child: util.Map[String, AnyRef], maxQuestions: Int): util.Map[String, AnyRef] = { + if (maxQuestions > 0) { + val questions = child.getOrDefault(HierarchyConstants.CHILDREN, new util.ArrayList[util.Map[String, AnyRef]]()).asInstanceOf[util.List[util.Map[String, AnyRef]]] + val limitedQuestions = questions.subList(0, Math.min(maxQuestions, questions.size())) + child.put(HierarchyConstants.CHILDREN, limitedQuestions) + } + child + } + + + def getNestedChildrenIdentifiers(childrenList: util.List[java.util.Map[String, AnyRef]]): String = { + val javaChildrenList: java.util.List[java.util.Map[String, AnyRef]] = childrenList.map(map => mapAsJavaMap(map)).asJava + javaChildrenList.asScala.flatMap { child => + val nestedChildren = child + .getOrDefault(HierarchyConstants.CHILDREN, new util.ArrayList[java.util.Map[String, AnyRef]]) + .asInstanceOf[util.List[java.util.Map[String, AnyRef]]] + .asScala + .toList + .asInstanceOf[Seq[java.util.Map[String, AnyRef]]] + val javaNestedChildren = JavaConverters.seqAsJavaListConverter(nestedChildren).asJava + javaNestedChildren.asScala.map(_.get(HierarchyConstants.IDENTIFIER).asInstanceOf[String]) + }.mkString(",") + } + + def createMergedMap(request: Request, nestedChildrenIdentifiers: String): util.Map[String, String] = { + val questionMap: util.HashMap[String, String] = new util.HashMap[String, String]() + val userMap: util.Map[String, String] = new util.HashMap[String, String]() + val mergedMap: util.Map[String, String] = new util.HashMap[String, String]() + + questionMap.put(HierarchyConstants.QUESTIONLIST, nestedChildrenIdentifiers) + userMap.put(HierarchyConstants.CONTENTID, request.get(HierarchyConstants.ROOTID).asInstanceOf[String]) + userMap.put(HierarchyConstants.COLLECTIONID, request.get(HierarchyConstants.COLLECTIONID).asInstanceOf[String]) + userMap.put(HierarchyConstants.USERID, request.get(HierarchyConstants.USERID).asInstanceOf[String]) + userMap.put(HierarchyConstants.ATTEMPTID, request.get(HierarchyConstants.ATTEMPTID).asInstanceOf[String]) + userMap.put(HierarchyConstants.EVAL_MODE, request.get(HierarchyConstants.EVAL_MODE).asInstanceOf[String]) + mergedMap.putAll(userMap) + mergedMap.putAll(questionMap) + + mergedMap + } + + def generateJwtToken(userMapJson: String): String = { + val headerOptions: java.util.Map[String, String] = new java.util.HashMap[String, String]() + val keyData = keyManager.getRandomKey() + val id = keyData.getKeyId + val privateKey = keyData.getPrivateKey + headerOptions.put("type", "jwt") + headerOptions.put("alg", "RS256") + headerOptions.put("keyId", id) + JwtUtils.createRS256Token(userMapJson, privateKey, headerOptions) + } + + def verifyRS256Token(token: String) = { + JwtUtils.verifyRS256Token(token, keyManager) + } + } diff --git a/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/KeyData.scala b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/KeyData.scala new file mode 100644 index 000000000..4c238d5f4 --- /dev/null +++ b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/KeyData.scala @@ -0,0 +1,25 @@ +package org.sunbird.managers + +import java.security.PrivateKey +import java.security.PublicKey + +class KeyData(private var keyId: String, private var privateKey: PrivateKey, private var publicKey: PublicKey) { + + def getKeyId: String = keyId + + def setKeyId(keyId: String): Unit = { + this.keyId = keyId + } + + def getPrivateKey: PrivateKey = privateKey + + def setPrivateKey(privateKey: PrivateKey): Unit = { + this.privateKey = privateKey + } + + def getPublicKey: PublicKey = publicKey + + def setPublicKey(publicKey: PublicKey): Unit = { + this.publicKey = publicKey + } +} \ No newline at end of file diff --git a/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/KeyManager.scala b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/KeyManager.scala new file mode 100644 index 000000000..9a730f67d --- /dev/null +++ b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/KeyManager.scala @@ -0,0 +1,114 @@ +package org.sunbird.managers +import org.slf4j.LoggerFactory +import org.sunbird.common.Platform + +import java.io.FileInputStream +import java.nio.charset.StandardCharsets +import java.security.{KeyFactory, PrivateKey, PublicKey} +import java.security.spec.{PKCS8EncodedKeySpec, X509EncodedKeySpec} +import scala.collection.mutable.HashMap +import java.util.Base64 +import java.nio.file.{Files, Paths} + +class KeyManager(private val basePath: String, private val keyPrefix: String, private val keyCount: Int) { + private val log = LoggerFactory.getLogger(this.getClass) + + private val keyMap: HashMap[String, KeyData] = new HashMap[String, KeyData]() + + private val loadHardcodedKeys = Platform.getBoolean("useHardcodedKeys",true); + + init() + + + private def init(): Unit = { + loadKeys() + } + + private def loadKeys(): Unit = { + for (i <- 1 until keyCount) { + val keyId = keyPrefix + i + log.info("Private key loaded - " + basePath + keyId) + keyMap.put(keyId, new KeyData(keyId, getPrivateKey(basePath + keyId ), loadPublicKey(basePath + keyId + Platform.getString("public.key.suffix",".pub")))) + } + } + + private def loadPublicKey(path: String): PublicKey = { + try { + if (!loadHardcodedKeys) { + val in = new FileInputStream(path) + val keyBytes = new Array[Byte](in.available()) + in.read(keyBytes) + in.close() + + val publicKey = new String(keyBytes, StandardCharsets.UTF_8) + .replaceAll("(-+BEGIN RSA PUBLIC KEY-+\\r?\\n|-+END RSA PUBLIC KEY-+\\r?\\n?)", "") + .replaceAll("(-+BEGIN PUBLIC KEY-+\\r?\\n|-+END PUBLIC KEY-+\\r?\\n?)", "") + // .replaceAll("-", "") + .replaceAll("\\s", "") + + + val publicBytes: Array[Byte] = Base64.getMimeDecoder.decode(publicKey) + val keySpec: X509EncodedKeySpec = new X509EncodedKeySpec(publicBytes) + val keyFactory: KeyFactory = KeyFactory.getInstance("RSA") + log.info("Public key loaded from filesystem - " + path) + keyFactory.generatePublic(keySpec) + } else { + val publicKey = Platform.getString("api.jwt.publickey","") + .replaceAll("(-+BEGIN RSA PUBLIC KEY-+\\r?\\n|-+END RSA PUBLIC KEY-+\\r?\\n?)", "") + .replaceAll("(-+BEGIN PUBLIC KEY-+\\r?\\n|-+END PUBLIC KEY-+\\r?\\n?)", "") + // .replaceAll("-", "") + .replaceAll("\\s", "") + + val publicBytes = Base64.getDecoder.decode(publicKey) + val keySpec = new X509EncodedKeySpec(publicBytes) + val keyFactory = KeyFactory.getInstance("RSA") + keyFactory.generatePublic(keySpec) + } + } catch { + case e: Exception => + throw new Exception("failed to load public key", e) + } + } + + def getRandomKey(): KeyData = { + val keyId = keyPrefix + (Math.random() * keyCount).toInt + keyMap.getOrElse(keyId, null) + } + + private def getPrivateKey(path: String): PrivateKey = { + try { + if (!loadHardcodedKeys) { + val privateKeyContent: String = new String(Files.readAllBytes(Paths.get(path))) + val privateKeyPEM: String = privateKeyContent + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", "") + val keyBytesDecoded: Array[Byte] = Base64.getDecoder.decode(privateKeyPEM) + + val spec: PKCS8EncodedKeySpec = new PKCS8EncodedKeySpec(keyBytesDecoded) + val keyFactory: KeyFactory = KeyFactory.getInstance("RSA") + log.info("loading private key from filesystem", path) + keyFactory.generatePrivate(spec) + + } else { + var privateKey = Platform.getString("api.jwt.privatekey", "") + privateKey = privateKey + .replaceAll("(-+BEGIN RSA PRIVATE KEY-+\\r?\\n|-+END RSA PRIVATE KEY-+\\r?\\n?)", "") + .replaceAll("(-+BEGIN PRIVATE KEY-+\\r?\\n|-+END PRIVATE KEY-+\\r?\\n?)", "") + .replaceAll("\\s", "") + val privateBytes = Base64.getDecoder.decode(privateKey) + val keySpec = new PKCS8EncodedKeySpec(privateBytes) + val keyFactory = KeyFactory.getInstance("RSA") + keyFactory.generatePrivate(keySpec) + } + } catch { + case e: Exception => + throw new Exception("Failed to retrieve private key", e) + } + } + + def getValueFromKeyMap(keyId: String): KeyData = { + keyMap.getOrElse(keyId, throw new NoSuchElementException(s"KeyData not found for keyId: $keyId")) + } + +} diff --git a/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/UpdateHierarchyManager.scala b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/UpdateHierarchyManager.scala index 36ff04647..6e32758f3 100644 --- a/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/UpdateHierarchyManager.scala +++ b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/managers/UpdateHierarchyManager.scala @@ -1,8 +1,9 @@ package org.sunbird.managers +import com.fasterxml.jackson.databind.ObjectMapper + import java.util import java.util.concurrent.CompletionException - import org.apache.commons.collections4.{CollectionUtils, MapUtils} import org.apache.commons.lang3.StringUtils import org.sunbird.common.dto.{Request, Response, ResponseHandler} @@ -25,13 +26,23 @@ import scala.concurrent.{ExecutionContext, Future} object UpdateHierarchyManager { val neo4jCreateTypes: java.util.List[String] = Platform.getStringList("neo4j_objecttypes_enabled", List("Question").asJava) - + val mapper: ObjectMapper = new ObjectMapper() @throws[Exception] def updateHierarchy(request: Request)(implicit oec: OntologyEngineContext, ec: ExecutionContext): Future[Response] = { val (nodesModified, hierarchy) = validateRequest(request) val rootId: String = getRootId(nodesModified, hierarchy) request.getContext.put(HierarchyConstants.ROOT_ID, rootId) getValidatedRootNode(rootId, request).map(node => { + var mode = node.getMetadata.get(HierarchyConstants.EVAL).asInstanceOf[String] + if (nodesModified.get(rootId) != null) { + val updMode = nodesModified.get(rootId) + .asInstanceOf[java.util.LinkedHashMap[String, AnyRef]] + .get(HierarchyConstants.METADATA).asInstanceOf[java.util.LinkedHashMap[String, AnyRef]] + .get(HierarchyConstants.EVAL).asInstanceOf[String] + if (StringUtils.isNotEmpty(mode) && !mode.equals(updMode)) + throw new ClientException(ErrorCodes.ERR_BAD_REQUEST.name(), "QuestionSet evaluation mode status cannot be modified") + mode = updMode + } getExistingHierarchy(request, node).map(existingHierarchy => { val existingChildren = existingHierarchy.getOrElse(HierarchyConstants.CHILDREN, new java.util.ArrayList[java.util.HashMap[String, AnyRef]]()).asInstanceOf[java.util.List[java.util.Map[String, AnyRef]]] val nodes = List(node) diff --git a/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/CryptoUtil.scala b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/CryptoUtil.scala new file mode 100644 index 000000000..b74999b61 --- /dev/null +++ b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/CryptoUtil.scala @@ -0,0 +1,25 @@ +package org.sunbird.utils + +import java.nio.charset.StandardCharsets +import java.security._ +import javax.crypto.spec.SecretKeySpec +import javax.crypto.Mac +import scala.util.{Try, Either, Left, Right} +object CryptoUtil { + private val US_ASCII = StandardCharsets.US_ASCII + def generateRSASign(payLoad: String, key: PrivateKey, algorithm: String): Array[Byte] = { + val sign: Signature = Signature.getInstance(algorithm) + sign.initSign(key) + sign.update(payLoad.getBytes(StandardCharsets.US_ASCII)) + sign.sign() + } + + def verifyRSASign(payLoad: String, signature: Array[Byte], key: PublicKey, algorithm: String): Boolean = { + Try { + val sign = Signature.getInstance(algorithm) + sign.initVerify(key) + sign.update(payLoad.getBytes(StandardCharsets.US_ASCII)) + sign.verify(signature) + }.getOrElse(false) + } +} diff --git a/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/HierarchyConstants.scala b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/HierarchyConstants.scala index 25408886c..a65e62a13 100644 --- a/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/HierarchyConstants.scala +++ b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/HierarchyConstants.scala @@ -57,4 +57,21 @@ object HierarchyConstants { val SOURCE: String = "source" val PRE_CONDITION: String = "preCondition" val QUESTION_VISIBILITY: List[String] = List("Default", "Parent") + val MAXQUESTIONS: String = "maxQuestions" + val SERVEREVALUABLE: String = "serverEvaluable" + val TRUE: String = "true" + val FALSE: String = "false" + val SERVER: String = "server" + val EVAL: String = "evalMode" + val EVAL_MODE: String = "eval-mode" + val CLIENT: String = "client" + val CONTENTID: String = "contentID" + val COLLECTIONID: String = "collectionID" + val USERID: String = "userID" + val ATTEMPTID: String = "attemptID" + val QUESTIONLIST: String = "questionList" + val ROOTID: String = "rootId" + val QUESTIONSETTOKEN: String = "questionSetToken" + val QUESTIONSET: String = "questionSet" + val SHUFFLE: String = "shuffle"; } diff --git a/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/JWTTokenType.scala b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/JWTTokenType.scala new file mode 100644 index 000000000..b85279860 --- /dev/null +++ b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/JWTTokenType.scala @@ -0,0 +1,19 @@ +package org.sunbird.utils + + +sealed trait JWTokenType { + def algorithmName: String + def tokenType: String +} + +object JWTokenType { + case object HS256 extends JWTokenType { + val algorithmName: String = "HmacSHA256" + val tokenType: String = "HS256" + } + + case object RS256 extends JWTokenType { + val algorithmName: String = "SHA256withRSA" + val tokenType: String = "RS256" + } +} diff --git a/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/JsonUtil.scala b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/JsonUtil.scala new file mode 100644 index 000000000..241fffe56 --- /dev/null +++ b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/JsonUtil.scala @@ -0,0 +1,30 @@ +package org.sunbird.utils + +import com.fasterxml.jackson.databind.ObjectMapper + +import java.lang.reflect.Type +import java.util.Map + +object JsonUtil { + private val objectMapper: ObjectMapper = new ObjectMapper() + + def fromJson[C](json: String, classOfC: Class[C]): C = { + objectMapper.readValue(json, classOfC) + } + + def fromJson[T](json: String, `type`: Type): T = { + objectMapper.readValue(json, objectMapper.constructType(`type`)) + } + + def fromJson[T](json: String, classOfT: Class[T], exceptionMessage: String): T = { + objectMapper.readValue(json, classOfT) + } + + def fromMap[C](map: Map[_, _], classOfC: Class[C]): C = { + objectMapper.convertValue(map, classOfC) + } + + def toJson(obj: Any): String = { + objectMapper.writeValueAsString(obj) + } +} \ No newline at end of file diff --git a/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/JwtUtil.scala b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/JwtUtil.scala new file mode 100644 index 000000000..2868eb29c --- /dev/null +++ b/assessment-api/qs-hierarchy-manager/src/main/scala/org/sunbird/utils/JwtUtil.scala @@ -0,0 +1,71 @@ +package org.sunbird.utils + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.scala.DefaultScalaModule +import org.sunbird.managers.{KeyData, KeyManager} + +import java.nio.charset.StandardCharsets +import java.security.PrivateKey +import java.util +import java.util.{Base64, HashMap, Map} + +object JwtUtils { + + private val SEPARATOR = "." + private val objectMapper: ObjectMapper = new ObjectMapper().registerModule(DefaultScalaModule) + def createRS256Token(key: String, privateKey: PrivateKey, headerOptions: Map[String, String]): String = { + val tokenType = JWTokenType.RS256 + val payLoad = createHeader(tokenType, headerOptions) + SEPARATOR + createClaims(key) + val signature = encodeToBase64Uri(CryptoUtil.generateRSASign(payLoad, privateKey, tokenType.algorithmName)) + payLoad + SEPARATOR + signature + } + + + private def createHeader(tokenType: JWTokenType, headerOptions: Map[String, String]): String = { + val headerData = new HashMap[String, String]() + if (headerOptions != null) + headerData.putAll(headerOptions) + headerData.put("alg", tokenType.tokenType) + encodeToBase64Uri(JsonUtil.toJson(headerData).getBytes) + } + + private def createClaims(subject: String): String = { + val payloadData = new HashMap[String, Any]() + payloadData.put("data", subject) + payloadData.put("iat", System.currentTimeMillis / 1000) + encodeToBase64Uri(JsonUtil.toJson(payloadData).getBytes) + } + private def encodeToBase64Uri(data: Array[Byte]): String = { + Base64.getUrlEncoder.encodeToString(data) + } + def decodeFromBase64(data: String): Array[Byte] = { + Base64.getDecoder.decode(data) + } + def verifyRS256Token(token: String, keyManager: KeyManager): (Boolean, Map[String, Any])= { + val tokenElements = token.split("\\.") + val header = tokenElements(0) + val header_decode=payload(header) + val body = tokenElements(1) + val signature = tokenElements(2) + val payLoad = header + SEPARATOR + body + var keyData: KeyData = null + var isValid = false + keyData = keyManager.getValueFromKeyMap(header_decode.get("keyId").asInstanceOf[String]) + if (keyData != null) { + isValid = CryptoUtil.verifyRSASign(payLoad, decodeFromBase64(signature), keyData.getPublicKey, "SHA256withRSA") + } + if(isValid) + (isValid,payload(body)) + else + (isValid,new util.HashMap[String,Any]()) + } +// +// def decodeFromBase64(data: String): Array[Byte] = { +// Base64Util.decode(data, 11) +// } + + def payload(encodedPayload: String): Map[String, Any] = { + val decodedPayload = new String(Base64.getDecoder.decode(encodedPayload), StandardCharsets.UTF_8) + objectMapper.readValue(decodedPayload, classOf[Map[String, Any]]) + } +} diff --git a/schemas/question/1.1/schema.json b/schemas/question/1.1/schema.json index a3afec3e9..fe49a081a 100644 --- a/schemas/question/1.1/schema.json +++ b/schemas/question/1.1/schema.json @@ -676,6 +676,13 @@ "No" ] }, + "evalMode": { + "type": "string", + "enum": [ + "server", + "client" + ] + }, "allowAnonymousAccess": { "type": "string", "enum": [ diff --git a/schemas/questionset/1.1/schema.json b/schemas/questionset/1.1/schema.json index 3e2a36fe9..0e3a02243 100644 --- a/schemas/questionset/1.1/schema.json +++ b/schemas/questionset/1.1/schema.json @@ -650,6 +650,13 @@ "type": "object" } }, + "evalMode": { + "type": "string", + "enum": [ + "server", + "client" + ] + }, "origin": { "type": "string" }, @@ -706,6 +713,9 @@ "type": "string", "format": "url" }, + "questionSetToken": { + "type": "string" + }, "creator": { "type": "string" }