From dce48a1d39b0e18a2557e5f123028857dc026d2d Mon Sep 17 00:00:00 2001
From: srosse <none@none>
Date: Tue, 30 Aug 2016 11:16:43 +0200
Subject: [PATCH] OO-1593: log some action start / close / edit around the QTI
 2.1 editor and runtime

---
 .../activity/OlatResourceableType.java        |   3 +
 .../iq/QTI21AssessmentRunController.java      |  21 +++-
 .../olat/ims/qti21/QTI21LoggingAction.java    | 114 ++++++++++++++++++
 .../ui/AssessmentEntryOutcomesListener.java   |  22 +++-
 .../ui/AssessmentTestDisplayController.java   |  15 ++-
 .../AssessmentTestComposerController.java     |   7 ++
 .../MultipleChoiceEditorController.java       |  10 +-
 .../SingleChoiceEditorController.java         |  10 +-
 .../logging/activity/LoggingResourceable.java |   5 +
 9 files changed, 191 insertions(+), 16 deletions(-)
 create mode 100644 src/main/java/org/olat/ims/qti21/QTI21LoggingAction.java

diff --git a/src/main/java/org/olat/core/logging/activity/OlatResourceableType.java b/src/main/java/org/olat/core/logging/activity/OlatResourceableType.java
index 91d9955157f..6d9e11f8177 100644
--- a/src/main/java/org/olat/core/logging/activity/OlatResourceableType.java
+++ b/src/main/java/org/olat/core/logging/activity/OlatResourceableType.java
@@ -65,6 +65,9 @@ public enum OlatResourceableType implements ILoggingResourceableType {
 	/** represents a content package **/
 	cp,
 	
+	/** this represents a QTi test **/
+	test, 
+	
 	/** represents a shared folder **/
 	sharedFolder,
 
diff --git a/src/main/java/org/olat/course/nodes/iq/QTI21AssessmentRunController.java b/src/main/java/org/olat/course/nodes/iq/QTI21AssessmentRunController.java
index 2c72de65cfc..b32f18e9b70 100644
--- a/src/main/java/org/olat/course/nodes/iq/QTI21AssessmentRunController.java
+++ b/src/main/java/org/olat/course/nodes/iq/QTI21AssessmentRunController.java
@@ -22,6 +22,7 @@ package org.olat.course.nodes.iq;
 import java.text.DateFormat;
 import java.util.Date;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import org.olat.core.commons.fullWebApp.LayoutMain3ColsController;
 import org.olat.core.gui.UserRequest;
@@ -57,6 +58,7 @@ import org.olat.ims.qti.process.AssessmentInstance;
 import org.olat.ims.qti21.OutcomesListener;
 import org.olat.ims.qti21.QTI21DeliveryOptions;
 import org.olat.ims.qti21.QTI21DeliveryOptions.ShowResultsOnFinish;
+import org.olat.ims.qti21.QTI21LoggingAction;
 import org.olat.ims.qti21.QTI21Service;
 import org.olat.ims.qti21.ui.AssessmentTestDisplayController;
 import org.olat.ims.qti21.ui.QTI21Event;
@@ -97,6 +99,7 @@ public class QTI21AssessmentRunController extends BasicController implements Gen
 	
 	private AssessmentTestDisplayController displayCtrl;
 	private LayoutMain3ColsController displayContainerController;
+	private AtomicBoolean incrementAttempts = new AtomicBoolean(true);
 	
 	@Autowired
 	private QTI21Service qtiService;
@@ -115,6 +118,8 @@ public class QTI21AssessmentRunController extends BasicController implements Gen
 		singleUserEventCenter = userSession.getSingleUserEventCenter();
 		mainVC = createVelocityContainer("assessment_run");
 		
+		addLoggingResourceable(LoggingResourceable.wrap(courseNode));
+		
 		if(courseNode instanceof IQTESTCourseNode) {
 			mainVC.contextPut("type", "test");
 		} else if(courseNode instanceof IQSELFCourseNode) {
@@ -321,7 +326,9 @@ public class QTI21AssessmentRunController extends BasicController implements Gen
 
 			assessmentStopped = false;		
 			singleUserEventCenter.registerFor(this, getIdentity(), assessmentInstanceOres);
-			singleUserEventCenter.fireEventToListenersOf(new AssessmentEvent(AssessmentEvent.TYPE.STARTED, ureq.getUserSession()), assessmentEventOres);						
+			singleUserEventCenter.fireEventToListenersOf(new AssessmentEvent(AssessmentEvent.TYPE.STARTED, ureq.getUserSession()), assessmentEventOres);
+			
+			ThreadLocalUserActivityLogger.log(QTI21LoggingAction.QTI_START_IN_COURSE, getClass());
 		}
 	}
 	
@@ -385,9 +392,17 @@ public class QTI21AssessmentRunController extends BasicController implements Gen
 				assessmentStatus = AssessmentEntryStatus.done;
 			}
 			ScoreEvaluation sceval = new ScoreEvaluation(score, pass, assessmentStatus, Boolean.TRUE, assessmentId);
-			((IQTESTCourseNode)courseNode).updateUserScoreEvaluation(sceval, userCourseEnv, getIdentity(), true);
+			
+			boolean increment = incrementAttempts.getAndSet(false);
+			((IQTESTCourseNode)courseNode).updateUserScoreEvaluation(sceval, userCourseEnv, getIdentity(), increment);
+			if(increment) {
+				ThreadLocalUserActivityLogger.log(QTI21LoggingAction.QTI_CLOSE_IN_COURSE, getClass());
+			}
 		} else if(courseNode instanceof IQSELFCourseNode) {
-			((IQSELFCourseNode)courseNode).incrementUserAttempts(userCourseEnv);
+			boolean increment = incrementAttempts.getAndSet(false);
+			if(increment) {
+				((IQSELFCourseNode)courseNode).incrementUserAttempts(userCourseEnv);
+			}
 		}
 	}
 }
diff --git a/src/main/java/org/olat/ims/qti21/QTI21LoggingAction.java b/src/main/java/org/olat/ims/qti21/QTI21LoggingAction.java
new file mode 100644
index 00000000000..6172525463d
--- /dev/null
+++ b/src/main/java/org/olat/ims/qti21/QTI21LoggingAction.java
@@ -0,0 +1,114 @@
+/**
+* OLAT - Online Learning and Training<br>
+* http://www.olat.org
+* <p>
+* Licensed under the Apache License, Version 2.0 (the "License"); <br>
+* you may not use this file except in compliance with the License.<br>
+* You may obtain a copy of the License at
+* <p>
+* http://www.apache.org/licenses/LICENSE-2.0
+* <p>
+* Unless required by applicable law or agreed to in writing,<br>
+* software distributed under the License is distributed on an "AS IS" BASIS, <br>
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
+* See the License for the specific language governing permissions and <br>
+* limitations under the License.
+* <p>
+* Copyright (c) since 2004 at Multimedia- & E-Learning Services (MELS),<br>
+* University of Zurich, Switzerland.
+* <hr>
+* <a href="http://www.openolat.org">
+* OpenOLAT - Online Learning and Training</a><br>
+* This file has been modified by the OpenOLAT community. Changes are licensed
+* under the Apache 2.0 license as the original file.  
+* <p>
+*/  
+
+package org.olat.ims.qti21;
+
+import java.lang.reflect.Field;
+
+import org.olat.core.logging.activity.ActionObject;
+import org.olat.core.logging.activity.ActionType;
+import org.olat.core.logging.activity.ActionVerb;
+import org.olat.core.logging.activity.BaseLoggingAction;
+import org.olat.core.logging.activity.CrudAction;
+import org.olat.core.logging.activity.ILoggingAction;
+import org.olat.core.logging.activity.OlatResourceableType;
+import org.olat.core.logging.activity.ResourceableTypeList;
+
+/**
+ * 
+ * Initial date: 30.08.2016<br>
+ * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com
+ *
+ */
+public class QTI21LoggingAction extends BaseLoggingAction {
+
+	// the following is a user clicking within a test
+	public static final ILoggingAction QTI_START_IN_COURSE = 
+		new QTI21LoggingAction(ActionType.statistic, CrudAction.update, ActionVerb.launch, ActionObject.test).setTypeList(
+				new ResourceableTypeList().
+					addMandatory(OlatResourceableType.course, OlatResourceableType.node)
+		);
+	
+	public static final ILoggingAction QTI_CLOSE_IN_COURSE = 
+		new QTI21LoggingAction(ActionType.statistic, CrudAction.update, ActionVerb.close , ActionObject.test).setTypeList(
+				new ResourceableTypeList().
+					addMandatory(OlatResourceableType.course, OlatResourceableType.node)
+		);
+	
+	public static final ILoggingAction QTI_START_AS_RESOURCE = 
+			new QTI21LoggingAction(ActionType.statistic, CrudAction.update, ActionVerb.launch, ActionObject.test).setTypeList(
+					new ResourceableTypeList().
+						addMandatory(OlatResourceableType.test)
+			);
+		
+	public static final ILoggingAction QTI_CLOSE_AS_RESOURCE = 
+		new QTI21LoggingAction(ActionType.statistic, CrudAction.update, ActionVerb.close , ActionObject.test).setTypeList(
+				new ResourceableTypeList().
+					addMandatory(OlatResourceableType.test)
+		);
+	
+	public static final ILoggingAction QTI_EDIT_RESOURCE = 
+			new QTI21LoggingAction(ActionType.statistic, CrudAction.update, ActionVerb.edit , ActionObject.test).setTypeList(
+					new ResourceableTypeList().
+						addMandatory(OlatResourceableType.test)
+			);
+
+	
+	/**
+	 * This static constructor's only use is to set the javaFieldIdForDebug
+	 * on all of the LoggingActions defined in this class.
+	 * <p>
+	 * This is used to simplify debugging - as it allows to issue (technical) log
+	 * statements where the name of the LoggingAction Field is written.
+	 */
+	static {
+		Field[] fields = QTI21LoggingAction.class.getDeclaredFields();
+		if (fields!=null) {
+			for (int i = 0; i < fields.length; i++) {
+				Field field = fields[i];
+				if (field.getType()==QTI21LoggingAction.class) {
+					try {
+						QTI21LoggingAction aLoggingAction = (QTI21LoggingAction)field.get(null);
+						aLoggingAction.setJavaFieldIdForDebug(field.getName());
+					} catch (IllegalArgumentException e) {
+						e.printStackTrace();
+					} catch (IllegalAccessException e) {
+						e.printStackTrace();
+					}
+				}
+			}
+		}
+	}
+	
+	/**
+	 * Simple wrapper calling super<init>
+	 * @see BaseLoggingAction#BaseLoggingAction(ActionType, CrudAction, ActionVerb, String)
+	 */
+	QTI21LoggingAction(ActionType resourceActionType, CrudAction action, ActionVerb actionVerb, ActionObject actionObject) {
+		super(resourceActionType, action, actionVerb, actionObject.name());
+	}
+	
+}
diff --git a/src/main/java/org/olat/ims/qti21/ui/AssessmentEntryOutcomesListener.java b/src/main/java/org/olat/ims/qti21/ui/AssessmentEntryOutcomesListener.java
index c5cde952f00..9a5d76044d1 100644
--- a/src/main/java/org/olat/ims/qti21/ui/AssessmentEntryOutcomesListener.java
+++ b/src/main/java/org/olat/ims/qti21/ui/AssessmentEntryOutcomesListener.java
@@ -20,8 +20,11 @@
 package org.olat.ims.qti21.ui;
 
 import java.math.BigDecimal;
+import java.util.concurrent.atomic.AtomicBoolean;
 
+import org.olat.core.logging.activity.ThreadLocalUserActivityLogger;
 import org.olat.ims.qti21.OutcomesListener;
+import org.olat.ims.qti21.QTI21LoggingAction;
 import org.olat.modules.assessment.AssessmentEntry;
 import org.olat.modules.assessment.AssessmentService;
 import org.olat.modules.assessment.model.AssessmentEntryStatus;
@@ -36,11 +39,18 @@ public class AssessmentEntryOutcomesListener implements OutcomesListener {
 	
 	private AssessmentEntry assessmentEntry;
 	private final AssessmentService assessmentService;
+	
+	private final boolean authorMode;
 	private final boolean needManualCorrection;
+
+	private AtomicBoolean start = new AtomicBoolean(true);
+	private AtomicBoolean close = new AtomicBoolean(true);
 	
-	public AssessmentEntryOutcomesListener(AssessmentEntry assessmentEntry, boolean needManualCorrection, AssessmentService assessmentService) {
+	public AssessmentEntryOutcomesListener(AssessmentEntry assessmentEntry, boolean needManualCorrection,
+			AssessmentService assessmentService, boolean authorMode) {
 		this.assessmentEntry = assessmentEntry;
 		this.assessmentService = assessmentService;
+		this.authorMode = authorMode;
 		this.needManualCorrection = needManualCorrection;
 	}
 	
@@ -49,6 +59,11 @@ public class AssessmentEntryOutcomesListener implements OutcomesListener {
 		AssessmentEntryStatus assessmentStatus = AssessmentEntryStatus.inProgress;
 		assessmentEntry.setAssessmentStatus(assessmentStatus);
 		assessmentEntry = assessmentService.updateAssessmentEntry(assessmentEntry);
+		
+		boolean firstStart = start.getAndSet(false);
+		if(firstStart && !authorMode) {
+			ThreadLocalUserActivityLogger.log(QTI21LoggingAction.QTI_START_AS_RESOURCE, getClass());
+		}
 	}
 
 	@Override
@@ -68,5 +83,10 @@ public class AssessmentEntryOutcomesListener implements OutcomesListener {
 		assessmentEntry.setPassed(submittedPass);
 		assessmentEntry.setAssessmentId(assessmentId);
 		assessmentEntry = assessmentService.updateAssessmentEntry(assessmentEntry);
+		
+		boolean firstClose = close.getAndSet(false);
+		if(firstClose && !authorMode) {
+			ThreadLocalUserActivityLogger.log(QTI21LoggingAction.QTI_CLOSE_AS_RESOURCE, getClass());
+		}
 	}
 }
diff --git a/src/main/java/org/olat/ims/qti21/ui/AssessmentTestDisplayController.java b/src/main/java/org/olat/ims/qti21/ui/AssessmentTestDisplayController.java
index 1c372f7a7bf..7aa9fb28462 100644
--- a/src/main/java/org/olat/ims/qti21/ui/AssessmentTestDisplayController.java
+++ b/src/main/java/org/olat/ims/qti21/ui/AssessmentTestDisplayController.java
@@ -91,6 +91,7 @@ import org.olat.ims.qti21.ui.event.RetrieveAssessmentTestSessionEvent;
 import org.olat.modules.assessment.AssessmentEntry;
 import org.olat.modules.assessment.AssessmentService;
 import org.olat.repository.RepositoryEntry;
+import org.olat.util.logging.activity.LoggingResourceable;
 import org.springframework.beans.factory.annotation.Autowired;
 
 import uk.ac.ed.ph.jqtiplus.JqtiPlus;
@@ -218,6 +219,12 @@ public class AssessmentTestDisplayController extends BasicController implements
 			anonymousIdentifier = null;
 		}
 		
+		if(testEntry == entry) {
+			// Limit to the case where the test is launched as resource,
+			// within course is this task delegated to the QTI21AssessmentRunController
+			addLoggingResourceable(LoggingResourceable.wrapTest(entry));
+		}
+		
 		FileResourceManager frm = FileResourceManager.getInstance();
 		fUnzippedDirRoot = frm.unzipFileResource(testEntry.getOlatResource());
 		resolvedAssessmentTest = qtiService.loadAndResolveAssessmentTest(fUnzippedDirRoot, false, false);
@@ -272,7 +279,7 @@ public class AssessmentTestDisplayController extends BasicController implements
 		AssessmentEntry assessmentEntry = assessmentService.getOrCreateAssessmentEntry(assessedIdentity, anonymousIdentifier, entry, subIdent, testEntry);
 		if(outcomesListener == null) {
 			boolean manualCorrections = AssessmentTestHelper.needManualCorrection(resolvedAssessmentTest);
-			outcomesListener = new AssessmentEntryOutcomesListener(assessmentEntry, manualCorrections, assessmentService);
+			outcomesListener = new AssessmentEntryOutcomesListener(assessmentEntry, manualCorrections, assessmentService, authorMode);
 		}
 
 		AssessmentTestSession lastSession = null;
@@ -877,11 +884,11 @@ public class AssessmentTestDisplayController extends BasicController implements
         testSessionController.endCurrentTestPart(requestTimestamp);
         
         TestSessionState testSessionState = testSessionController.getTestSessionState();
+        TestPlanNode nextTestPart = testSessionController.findNextEnterableTestPart();
         
         // Record current result state
-	    final AssessmentResult assessmentResult = computeAndRecordTestAssessmentResult(ureq, testSessionState, false);
-        
-        if(testSessionController.findNextEnterableTestPart() == null) {
+	    final AssessmentResult assessmentResult = computeAndRecordTestAssessmentResult(ureq, testSessionState, nextTestPart == null);
+        if(nextTestPart == null) {
         	candidateSession = qtiService.finishTestSession(candidateSession, testSessionState, assessmentResult, requestTimestamp);
         }
 	}
diff --git a/src/main/java/org/olat/ims/qti21/ui/editor/AssessmentTestComposerController.java b/src/main/java/org/olat/ims/qti21/ui/editor/AssessmentTestComposerController.java
index 26375917801..6f098a9da46 100644
--- a/src/main/java/org/olat/ims/qti21/ui/editor/AssessmentTestComposerController.java
+++ b/src/main/java/org/olat/ims/qti21/ui/editor/AssessmentTestComposerController.java
@@ -62,6 +62,7 @@ import org.olat.core.gui.control.generic.wizard.StepsMainRunController;
 import org.olat.core.gui.control.generic.wizard.StepsRunContext;
 import org.olat.core.gui.media.MediaResource;
 import org.olat.core.helpers.Settings;
+import org.olat.core.logging.activity.ThreadLocalUserActivityLogger;
 import org.olat.core.util.Formatter;
 import org.olat.core.util.Util;
 import org.olat.core.util.coordinate.CoordinatorManager;
@@ -69,6 +70,7 @@ import org.olat.core.util.coordinate.LockResult;
 import org.olat.core.util.vfs.VFSContainer;
 import org.olat.fileresource.FileResourceManager;
 import org.olat.ims.qti21.QTI21Constants;
+import org.olat.ims.qti21.QTI21LoggingAction;
 import org.olat.ims.qti21.QTI21Service;
 import org.olat.ims.qti21.manager.openxml.QTI21WordExport;
 import org.olat.ims.qti21.model.IdentifierGenerator;
@@ -103,6 +105,7 @@ import org.olat.modules.qpool.ui.events.QItemViewEvent;
 import org.olat.repository.RepositoryEntry;
 import org.olat.repository.ui.RepositoryEntryRuntimeController.ToolbarAware;
 import org.olat.user.UserManager;
+import org.olat.util.logging.activity.LoggingResourceable;
 import org.springframework.beans.factory.annotation.Autowired;
 
 import uk.ac.ed.ph.jqtiplus.node.QtiNode;
@@ -195,6 +198,8 @@ public class AssessmentTestComposerController extends MainLayoutBasicController
 			return;
 		}
 		
+		addLoggingResourceable(LoggingResourceable.wrapTest(testEntry));
+		
 		// test structure
 		menuTree = new MenuTree("atTree");
 		menuTree.setExpandSelectedNode(false);
@@ -866,6 +871,8 @@ public class AssessmentTestComposerController extends MainLayoutBasicController
 		URI testURI = resolvedAssessmentTest.getTestLookup().getSystemId();
 		File testFile = new File(testURI);
 		qtiService.updateAssesmentObject(testFile, resolvedAssessmentTest);
+
+		ThreadLocalUserActivityLogger.log(QTI21LoggingAction.QTI_EDIT_RESOURCE, getClass());
 	}
 	
 	private void recalculateMaxScoreAssessmentTest() {
diff --git a/src/main/java/org/olat/ims/qti21/ui/editor/interactions/MultipleChoiceEditorController.java b/src/main/java/org/olat/ims/qti21/ui/editor/interactions/MultipleChoiceEditorController.java
index f752b3c6581..2475feede0a 100644
--- a/src/main/java/org/olat/ims/qti21/ui/editor/interactions/MultipleChoiceEditorController.java
+++ b/src/main/java/org/olat/ims/qti21/ui/editor/interactions/MultipleChoiceEditorController.java
@@ -218,10 +218,12 @@ public class MultipleChoiceEditorController extends FormBasicController {
 		}
 		
 		answersCont.clearError();
-		String[] correctAnswers = ureq.getHttpReq().getParameterValues("correct");
-		if(correctAnswers == null || correctAnswers.length == 0) {
-			answersCont.setErrorKey("error.need.correct.answer", null);
-			allOk &= false;
+		if(!restrictedEdit) {
+			String[] correctAnswers = ureq.getHttpReq().getParameterValues("correct");
+			if(correctAnswers == null || correctAnswers.length == 0) {
+				answersCont.setErrorKey("error.need.correct.answer", null);
+				allOk &= false;
+			}
 		}
 		
 		return allOk & super.validateFormLogic(ureq);
diff --git a/src/main/java/org/olat/ims/qti21/ui/editor/interactions/SingleChoiceEditorController.java b/src/main/java/org/olat/ims/qti21/ui/editor/interactions/SingleChoiceEditorController.java
index 998eca4e51d..82a0d3d55d1 100644
--- a/src/main/java/org/olat/ims/qti21/ui/editor/interactions/SingleChoiceEditorController.java
+++ b/src/main/java/org/olat/ims/qti21/ui/editor/interactions/SingleChoiceEditorController.java
@@ -214,10 +214,12 @@ public class SingleChoiceEditorController extends FormBasicController {
 		}
 		
 		answersCont.clearError();
-		String correctAnswer = ureq.getParameter("correct");
-		if(!StringHelper.containsNonWhitespace(correctAnswer)) {
-			allOk &= false;
-			answersCont.setErrorKey("error.need.correct.answer", null);
+		if(!restrictedEdit) {
+			String correctAnswer = ureq.getParameter("correct");
+			if(!StringHelper.containsNonWhitespace(correctAnswer)) {
+				allOk &= false;
+				answersCont.setErrorKey("error.need.correct.answer", null);
+			}
 		}
 		
 		return allOk & super.validateFormLogic(ureq);
diff --git a/src/main/java/org/olat/util/logging/activity/LoggingResourceable.java b/src/main/java/org/olat/util/logging/activity/LoggingResourceable.java
index 2b047a83a55..2c2558a55ef 100644
--- a/src/main/java/org/olat/util/logging/activity/LoggingResourceable.java
+++ b/src/main/java/org/olat/util/logging/activity/LoggingResourceable.java
@@ -456,6 +456,11 @@ public class LoggingResourceable implements ILoggingResourceable {
 				String.valueOf(course.getResourceableId()), course.getCourseTitle(), false);
 	}
 	
+	public static LoggingResourceable wrapTest(RepositoryEntry entry) {
+		return new LoggingResourceable(entry, OlatResourceableType.test, entry.getOlatResource().getResourceableTypeName(), 
+				String.valueOf(entry.getOlatResource().getResourceableId()), entry.getDisplayname(), false);
+	}
+	
 	/**
 	 * Wraps a CourseNode into a LoggingResourceable - setting type/id/name accordingly 
 	 * @param node the node to be wrapped
-- 
GitLab