From 42c539ce1c239f918bdf06b17ebd9bd0b0e59a18 Mon Sep 17 00:00:00 2001
From: srosse <stephane.rosse@frentix.com>
Date: Fri, 11 Sep 2020 07:27:07 +0200
Subject: [PATCH] OO-4893: upload assessment docs to items in correction
 workflow

---
 .../java/org/olat/ims/qti21/QTI21Service.java |  13 ++
 .../ims/qti21/manager/QTI21ServiceImpl.java   |  12 ++
 .../QTI21ResultsExportMediaResource.java      |   8 +-
 .../qti21/ui/AssessmentResultController.java  |  56 ++++++-
 .../olat/ims/qti21/ui/ResourcesMapper.java    | 111 +++++++++----
 .../qti21/ui/_content/assessment_results.html |  11 +-
 .../qti21/ui/_i18n/LocalStrings_de.properties |   4 +
 .../qti21/ui/_i18n/LocalStrings_en.properties |   4 +
 .../qti21/ui/_i18n/LocalStrings_fr.properties |   2 +
 ...rectionIdentityInteractionsController.java | 148 +++++++++++++++++-
 .../_content/item_assessment_docs.html        |   7 +
 11 files changed, 335 insertions(+), 41 deletions(-)
 create mode 100644 src/main/java/org/olat/ims/qti21/ui/assessment/_content/item_assessment_docs.html

diff --git a/src/main/java/org/olat/ims/qti21/QTI21Service.java b/src/main/java/org/olat/ims/qti21/QTI21Service.java
index a37be98ddba..45d74e2bc04 100644
--- a/src/main/java/org/olat/ims/qti21/QTI21Service.java
+++ b/src/main/java/org/olat/ims/qti21/QTI21Service.java
@@ -520,6 +520,19 @@ public interface QTI21Service {
 	
 	public File importFileSubmission(AssessmentTestSession candidateSession, String filename, byte[] data);
 	
+	
+	public File getAssessmentDocumentsDirectory(AssessmentTestSession candidateSession);
+	
+	/**
+	 * The method allow to prevent creation of a lot of empty directories.
+	 * 
+	 * @param candidateSession The assessment test session
+	 * @param itemSession The assessment item session
+	 * @param createDirectory Create a directory, if false and the directory doesn't exist, return null
+	 * @return The directory or null if @createDirectory is false and the directory doesn't exist
+	 */
+	public File getAssessmentDocumentsDirectory(AssessmentTestSession candidateSession, AssessmentItemSession itemSession);
+	
 	/**
 	 * Returns the sum of the correction time set in metadata of the test. Only
 	 * the items proposed to the assessed user are counted (with or without response
diff --git a/src/main/java/org/olat/ims/qti21/manager/QTI21ServiceImpl.java b/src/main/java/org/olat/ims/qti21/manager/QTI21ServiceImpl.java
index 89b6d444854..c0504c3613c 100644
--- a/src/main/java/org/olat/ims/qti21/manager/QTI21ServiceImpl.java
+++ b/src/main/java/org/olat/ims/qti21/manager/QTI21ServiceImpl.java
@@ -1516,6 +1516,18 @@ public class QTI21ServiceImpl implements QTI21Service, UserDataDeletable, Initia
 			auditLogger.logCandidateOutcomes(candidateSession, outcomes);
 		}
 	}
+	
+	@Override
+	public File getAssessmentDocumentsDirectory(AssessmentTestSession candidateSession) {
+		File myStore = testSessionDao.getSessionStorage(candidateSession);
+        return new File(myStore, "assessmentdocs");
+	}
+	
+	@Override
+	public File getAssessmentDocumentsDirectory(AssessmentTestSession candidateSession, AssessmentItemSession itemSession) {
+        File assessmentDocsDir = getAssessmentDocumentsDirectory(candidateSession);
+        return new File(assessmentDocsDir, itemSession.getKey().toString());
+	}
 
 	@Override
 	public File getSubmissionDirectory(AssessmentTestSession candidateSession) {
diff --git a/src/main/java/org/olat/ims/qti21/resultexport/QTI21ResultsExportMediaResource.java b/src/main/java/org/olat/ims/qti21/resultexport/QTI21ResultsExportMediaResource.java
index 7bd4e0b8a51..c704335c285 100644
--- a/src/main/java/org/olat/ims/qti21/resultexport/QTI21ResultsExportMediaResource.java
+++ b/src/main/java/org/olat/ims/qti21/resultexport/QTI21ResultsExportMediaResource.java
@@ -223,7 +223,7 @@ public class QTI21ResultsExportMediaResource implements MediaResource {
 		qaf.exportCourseElement(exportFolderName + "/" + label, zout);
 	}
 	
-	private List<ResultDetail> createResultDetail (Identity identity, ZipOutputStream zout, String idDir) throws IOException {
+	private List<ResultDetail> createResultDetail(Identity identity, ZipOutputStream zout, String idDir) throws IOException {
 		List<ResultDetail> assessments = new ArrayList<>();
 		List<AssessmentTestSession> sessions = qtiService.getAssessmentTestSessions(entry, courseNode.getIdent(), identity, true);
 		for (AssessmentTestSession session : sessions) {
@@ -267,6 +267,12 @@ public class QTI21ResultsExportMediaResource implements MediaResource {
 			File submissionDir = qtiService.getSubmissionDirectory(session);
 			String baseDir = idPath + "submissions";
 			ZipUtil.addDirectoryToZip(submissionDir.toPath(), baseDir, zout);
+
+			File assessmentDocsDir = qtiService.getAssessmentDocumentsDirectory(session);
+			if(assessmentDocsDir.exists()) {
+				String assessmentDocsBaseDir = idPath + "assessmentdocs";
+				ZipUtil.addDirectoryToZip(assessmentDocsDir.toPath(), assessmentDocsBaseDir, zout);
+			}
 		}
 		return assessments;
 	}
diff --git a/src/main/java/org/olat/ims/qti21/ui/AssessmentResultController.java b/src/main/java/org/olat/ims/qti21/ui/AssessmentResultController.java
index d407bc46737..797b286bc6b 100644
--- a/src/main/java/org/olat/ims/qti21/ui/AssessmentResultController.java
+++ b/src/main/java/org/olat/ims/qti21/ui/AssessmentResultController.java
@@ -51,6 +51,7 @@ import org.olat.core.gui.media.NotFoundMediaResource;
 import org.olat.core.id.Identity;
 import org.olat.core.util.CodeHelper;
 import org.olat.core.util.StringHelper;
+import org.olat.core.util.io.SystemFileFilter;
 import org.olat.course.CourseFactory;
 import org.olat.course.ICourse;
 import org.olat.course.assessment.AssessmentHelper;
@@ -215,6 +216,9 @@ public class AssessmentResultController extends FormBasicController {
 		if(formLayout instanceof FormLayoutContainer) {
 			FormLayoutContainer layoutCont = (FormLayoutContainer)formLayout;
 			layoutCont.contextPut("options", options);
+			layoutCont.contextPut("mapperUri", mapperUri);
+			// mapperUri is the default
+			layoutCont.contextPut("submissionMapperUri", submissionMapperUri == null ? mapperUri : submissionMapperUri);
 			if(assessedIdentity != null) {
 				layoutCont.contextPut("userDisplayName", userMgr.getUserDisplayName(assessedIdentity.getKey()));
 			} else {
@@ -234,6 +238,7 @@ public class AssessmentResultController extends FormBasicController {
 			} else {
 				
 				if (candidateSession != null) {
+					layoutCont.contextPut("candidateSessionKey", candidateSession.getKey());
 					// Add some meta information about the context of this assessment
 					RepositoryEntry contextRE = candidateSession.getRepositoryEntry();
 					RepositoryEntry testRE = candidateSession.getTestEntry();
@@ -249,9 +254,8 @@ public class AssessmentResultController extends FormBasicController {
 					
 					if (testRE != null) {
 						layoutCont.contextPut("testTitle", testRE.getDisplayname());
-						layoutCont.contextPut("testId", testRE.getResourceableId());						
-						layoutCont.contextPut("testExternalRef", testRE.getExternalRef());						
-						
+						layoutCont.contextPut("testId", testRE.getResourceableId());
+						layoutCont.contextPut("testExternalRef", testRE.getExternalRef());
 					}
 				}
 				
@@ -418,6 +422,18 @@ public class AssessmentResultController extends FormBasicController {
 				r.setManualScore(itemSession.getManualScore());
 			}
 			r.setComment(itemSession.getCoachComment());
+			r.setItemSessionKey(itemSession.getKey());
+			
+			File assessmentDocsDir = qtiService.getAssessmentDocumentsDirectory(candidateSession, itemSession);
+			if(assessmentDocsDir != null && assessmentDocsDir.exists()) {
+				File[] assessmentDocs = assessmentDocsDir.listFiles(SystemFileFilter.FILES_ONLY);
+				if(assessmentDocs != null) {
+					for(File assessmentDoc:assessmentDocs) {
+						DocumentWrapper dw = new DocumentWrapper(assessmentDoc.getName());
+						r.getAssessmentDocuments().add(dw);
+					}
+				}
+			}
 		}
 		
 		//update max score of section
@@ -561,7 +577,7 @@ public class AssessmentResultController extends FormBasicController {
 	}
 
 	private void doPrint(UserRequest ureq) {
-		ControllerCreator creator = getResultControllerCtreator();
+		ControllerCreator creator = getResultControllerCreator();
 		openInNewBrowserWindow(ureq, creator);
 	}
 	
@@ -607,7 +623,7 @@ public class AssessmentResultController extends FormBasicController {
 		return filename;
 	}
 	
-	private ControllerCreator getResultControllerCtreator() {
+	private ControllerCreator getResultControllerCreator() {
 		return (uureq, wwControl) -> {
 			AssessmentResultController printViewCtrl = new AssessmentResultController(uureq, wwControl, assessedIdentity, anonym,
 					candidateSession, fUnzippedDirRoot, mapperUri, submissionMapperUri, options, false, true, false);
@@ -658,6 +674,8 @@ public class AssessmentResultController extends FormBasicController {
 		
 		private SessionStatus sessionStatus;
 		
+		private Long itemSessionKey;
+		
 		private int numberOfSections = 0;
 		private int numberOfQuestions = 0;
 		private int numberOfAnsweredQuestions = 0;
@@ -669,6 +687,7 @@ public class AssessmentResultController extends FormBasicController {
 		private final List<FlowFormItem> rubrics;
 		private InteractionResults interactionResults;
 		private final List<Results> subResults = new ArrayList<>();
+		private final List<DocumentWrapper> assessmentDocuments = new ArrayList<>(1);
 		
 		public Results(boolean deleted, String title) {
 			this.deleted = deleted;
@@ -700,7 +719,15 @@ public class AssessmentResultController extends FormBasicController {
 		}
 		
 		public String getItemIdentifier() {
-			return this.itemIdentifier;
+			return itemIdentifier;
+		}
+
+		public Long getItemSessionKey() {
+			return itemSessionKey;
+		}
+
+		public void setItemSessionKey(Long itemSessionKey) {
+			this.itemSessionKey = itemSessionKey;
 		}
 
 		public void setSessionState(ControlObjectSessionState sessionState) {
@@ -980,6 +1007,23 @@ public class AssessmentResultController extends FormBasicController {
 		public void setInteractionResults(InteractionResults interactionResults) {
 			this.interactionResults = interactionResults;
 		}
+		
+		public List<DocumentWrapper> getAssessmentDocuments() {
+			return assessmentDocuments;
+		}
+	}
+	
+	public class DocumentWrapper {
+		
+		private final String filename;
+		
+		public DocumentWrapper(String filename) {
+			this.filename = filename;
+		}
+		
+		public String getFilename() {
+			return filename;
+		}
 	}
 	
 	public class SignatureMapper implements Mapper {
diff --git a/src/main/java/org/olat/ims/qti21/ui/ResourcesMapper.java b/src/main/java/org/olat/ims/qti21/ui/ResourcesMapper.java
index 2a6a8a2c5ee..274c4c97f31 100644
--- a/src/main/java/org/olat/ims/qti21/ui/ResourcesMapper.java
+++ b/src/main/java/org/olat/ims/qti21/ui/ResourcesMapper.java
@@ -45,6 +45,7 @@ public class ResourcesMapper implements Mapper {
 	
 	private static final Logger log = Tracing.createLoggerFor(ResourcesMapper.class);
 	private static final String SUBMISSION_SUBPATH = "submissions/";
+	private static final String ASSESSMENTDOCS_SUBPATH = "assessmentdocs/";
 	
 	private final URI assessmentObjectUri;
 	private final File submissionDirectory;
@@ -97,38 +98,10 @@ public class ResourcesMapper implements Mapper {
 				String realPath = request.getServletContext().getRealPath("/static/images/transparent.gif");
 				resource = new FileMediaResource(new File(realPath), true);
 			} else {
-				String submissionName = null;
-				File storage = null;
 				if(filename != null && filename.contains(SUBMISSION_SUBPATH)) {
-					int submissionIndex = filename.indexOf(SUBMISSION_SUBPATH) + SUBMISSION_SUBPATH.length();
-					String submission = filename.substring(submissionIndex);
-					int candidateSessionIndex = submission.indexOf('/');
-					if(candidateSessionIndex > 0) {
-						submissionName = submission.substring(candidateSessionIndex + 1);
-						if(submissionDirectory != null) {
-							storage = submissionDirectory;
-						} else if(submissionDirectoryMaps != null) {
-							String sessionKey = submission.substring(0, candidateSessionIndex);
-							if(StringHelper.isLong(sessionKey)) {
-								try {
-									storage = submissionDirectoryMaps.get(Long.valueOf(sessionKey));
-								} catch (Exception e) {
-									log.error("", e);
-								}
-							}
-						}
-					}
-				}
-				
-				if(storage != null && StringHelper.containsNonWhitespace(submissionName)) {
-					File submissionFile = new File(storage, submissionName);
-					if(submissionFile.exists()) {
-						resource = new FileMediaResource(submissionFile, true);
-					} else {
-						resource = new NotFoundMediaResource();
-					}
-				} else {
-					resource = new NotFoundMediaResource();
+					resource = submission(filename);
+				} else if(filename != null && filename.contains(ASSESSMENTDOCS_SUBPATH)) {
+					resource = assessmentDocuments(filename);
 				}
 			}
 		} catch (Exception e) {
@@ -137,4 +110,80 @@ public class ResourcesMapper implements Mapper {
 		}
 		return resource;
 	}
+	
+	private MediaResource submission(String filename) {
+		int submissionIndex = filename.indexOf(SUBMISSION_SUBPATH) + SUBMISSION_SUBPATH.length();
+		String submission = filename.substring(submissionIndex);
+		int candidateSessionIndex = submission.indexOf('/');
+		
+		File storage = null;
+		String submissionName = null;
+		if(candidateSessionIndex > 0) {
+			submissionName = submission.substring(candidateSessionIndex + 1);
+			if(submissionDirectory != null) {
+				storage = submissionDirectory;
+			} else if(submissionDirectoryMaps != null) {
+				String sessionKey = submission.substring(0, candidateSessionIndex);
+				storage = getStorageInMap(sessionKey);
+			}
+		}
+		return toResource(storage, submissionName);
+	}
+	
+	private MediaResource assessmentDocuments(String filename) {
+		int assessmentIndex = filename.indexOf(ASSESSMENTDOCS_SUBPATH) + ASSESSMENTDOCS_SUBPATH.length();
+		String assessment = filename.substring(assessmentIndex);
+		int testSessionIndex = assessment.indexOf('/');
+		if(testSessionIndex < 0) {
+			return new NotFoundMediaResource();
+		}
+		int itemSessionIndex = assessment.indexOf('/', testSessionIndex + 1);
+		if(itemSessionIndex < 0 || itemSessionIndex <= testSessionIndex) {
+			return new NotFoundMediaResource();
+		}
+		
+		File storage = null;
+		String submissionName = null;
+		if(testSessionIndex > 0) {
+			File submissionDir = null;
+			if(submissionDirectory != null) {
+				submissionDir = submissionDirectory;
+			} else if(submissionDirectoryMaps != null) {
+				String testSession = assessment.substring(0, testSessionIndex);
+				submissionDir = getStorageInMap(testSession);
+			}
+			
+			if(submissionDir != null) {
+				submissionName = assessment.substring(itemSessionIndex + 1);
+				String itemSession = assessment.substring(testSessionIndex + 1, itemSessionIndex);
+				storage = new File(submissionDir.getParentFile(), "assessmentdocs");
+				storage = new File(storage, itemSession);
+			}
+		}
+
+		return toResource(storage, submissionName);
+	}
+	
+	private MediaResource toResource(File storage, String filename) {
+		if(storage != null && StringHelper.containsNonWhitespace(filename)) {
+			File submissionFile = new File(storage, filename);
+			if(submissionFile.exists()) {
+				return new FileMediaResource(submissionFile, true);
+			}
+		}
+		return new NotFoundMediaResource();
+	}
+	
+	private File getStorageInMap(String sessionKey) {
+		File storage = null;
+		if(StringHelper.isLong(sessionKey)) {
+			try {
+				storage = submissionDirectoryMaps.get(Long.valueOf(sessionKey));
+			} catch (Exception e) {
+				log.error("", e);
+				
+			}
+		}
+		return storage;
+	}
 }
\ No newline at end of file
diff --git a/src/main/java/org/olat/ims/qti21/ui/_content/assessment_results.html b/src/main/java/org/olat/ims/qti21/ui/_content/assessment_results.html
index eb2979d8f7c..eb5c2973fdb 100644
--- a/src/main/java/org/olat/ims/qti21/ui/_content/assessment_results.html
+++ b/src/main/java/org/olat/ims/qti21/ui/_content/assessment_results.html
@@ -467,7 +467,7 @@
 					<i class="o_icon o_icon_passed"></i> 
 					$r.translate("passed.yes")
 				#elseif(!${itemResult.getPass().booleanValue()})		
-					<i class="o_icon o_icon_failed"></i> 
+					<i class="o_icon o_icon_failed"> </i> 
 					$r.translate("passed.no")
 				#else
 					$r.translateWithPackage("org.olat.course.nodes.st", "passed.noinfo")
@@ -475,6 +475,15 @@
 				</td>
 			</tr>
 			#end
+			#if($r.isNotEmpty(${itemResult.getAssessmentDocuments()}))
+			<tr>
+				<th scope="row">$r.translate("assessment.item.docs")</th>
+				<td><ul class="list-unstyled">#foreach($doc in ${itemResult.getAssessmentDocuments()})
+					<li><a href="$submissionMapperUri/assessmentdocs/${itemResult.itemSessionKey}/${doc.filename}?href=assessmentdocs/${candidateSessionKey}/${itemResult.itemSessionKey}/${doc.filename}" target="_blank"><i class="o_icon o_icon-fw $r.getFiletypeIconCss($doc.filename)"> </i> $r.escapeHtml($doc.filename)</a></li>
+				#end</ul>
+				</td>
+			</tr>
+			#end
 		</tbody></table>
 		#end  ## END #if($itemResult.metadataVisible)
 		
diff --git a/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_de.properties b/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_de.properties
index e0831e8678f..be8fb77984c 100644
--- a/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_de.properties
+++ b/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_de.properties
@@ -19,6 +19,8 @@ archive.table.header.section=Sektion "{0}"
 archive.table.header.test=Test
 assessment.comment.legend=Pers\u00F6nliche Notizen
 assessment.comment.legend.help=Nutzen Sie die Textbox f\u00FCr pers\u00F6nliche Notizen und Kommentare. Diese Notizen sind nur f\u00FCr Sie sichtbar und werden nicht in der Pr\u00FCfung ber\u00FCcksichtigt.
+assessment.item.docs=Bewertungsdokument
+assessment.item.docs.upload=Bewertungsdokument
 assessment.item.mark=F\u00FCgen Sie eine private Markierung hinzu als Erinnerung um diese Frage sp\u00E4ter noch einmal anzuschauen
 assessment.item.point={0} Punkt
 assessment.item.points={0} Punkte
@@ -295,6 +297,8 @@ upload.explanation=Datei auf lokalem Computer f\u00FCr \u00DCbertragung w\u00E4h
 validate.xml.signature=Testquittung validieren
 validate.xml.signature.file=XML Datei
 validate.xml.signature.ok=Testquittung und Datei konnte erfolgreich validiert werden.
+warning.assessment.docs.delete.title=Bewertungsdokument l\u00F6schen
+warning.assessment.docs.delete.text=Wollen Sie dieses Bewertungsdokument "{0}" wirklich l\u00F6schen?
 warning.assignment.done=Die Korrektur dieses Tests ist bereits abgeschlossen. Wird diese Test-Session als ung\u00FCltig markiert (annulliert), so gehen alle bestehenden Korrekturen verloren.
 warning.assignment.inProcess=Dieser Test befindet sich bereits in Korrektur. Wird diese Test-Session als ung\u00FCltig markiert (annulliert), so gehen alle Korrekturen verloren.
 warning.download.log=Es gibt leider kein Logdatei f\u00FCr diesen Test.
diff --git a/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_en.properties b/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_en.properties
index a0113150961..96315cb7c7a 100644
--- a/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_en.properties
+++ b/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_en.properties
@@ -19,6 +19,8 @@ archive.table.header.section=Section "{0}"
 archive.table.header.test=Test
 assessment.comment.legend=Personal notes
 assessment.comment.legend.help=Please use the following text box for any personal notes during this test. These notes are only visible to you and will not be taken in account for the exam.
+assessment.item.docs=Assessment documents
+assessment.item.docs.upload=Assessment documents
 assessment.item.mark=Add personal marking as a reminder to review this question
 assessment.item.point={0} point
 assessment.item.points={0} points
@@ -295,6 +297,8 @@ upload.explanation=Select a file from your computer to upload it
 validate.xml.signature=Validate test receipt
 validate.xml.signature.file=XML file
 validate.xml.signature.ok=Test receipt and results was successfully validated.
+warning.assessment.docs.delete.text=Do you really want to delete this assessment document "{0}"?
+warning.assessment.docs.delete.title=Delete an assessment document
 warning.assignment.done=The grading of this test has already been completed. If this test session is marked as invalid, all existing corrections will be lost.
 warning.assignment.inProcess=The grading of the test has already started. If this test session is marked as invalid, all existing corrections will be lost.
 warning.download.log=There is not a log file for this test.
diff --git a/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_fr.properties b/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_fr.properties
index c0f5931c698..eec9868de6f 100644
--- a/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_fr.properties
+++ b/src/main/java/org/olat/ims/qti21/ui/_i18n/LocalStrings_fr.properties
@@ -296,6 +296,8 @@ upload.explanation=Choisir un fichier depuis votre ordinateur pour le t\u00E9l\u
 validate.xml.signature=Valider la signature
 validate.xml.signature.file=Signature XML
 validate.xml.signature.ok=La signature et les r\u00E9sultats ont \u00E9t\u00E9 valid\u00E9 avec succ\u00E8s.
+warning.assessment.docs.delete.text=Voulez-vous r\u00E9ellement effacer ce document "{0}"?
+warning.assessment.docs.delete.title=Effacer le document
 warning.assignment.done=La correction de ce test est termin\u00E9e. Si cette session de test est marqu\u00E9e comme invalide, les corrections existantes seront perdues.
 warning.assignment.inProcess=La correction de ce test a d\u00E9j\u00E0 commenc\u00E9. Si cette session de test est marqu\u00E9e comme invalide, toutes les corrections seront perdues.
 warning.download.log=Il n'y a malheureusement pas de fichier journal pour ce test.
diff --git a/src/main/java/org/olat/ims/qti21/ui/assessment/CorrectionIdentityInteractionsController.java b/src/main/java/org/olat/ims/qti21/ui/assessment/CorrectionIdentityInteractionsController.java
index 013f21af421..461902017c3 100644
--- a/src/main/java/org/olat/ims/qti21/ui/assessment/CorrectionIdentityInteractionsController.java
+++ b/src/main/java/org/olat/ims/qti21/ui/assessment/CorrectionIdentityInteractionsController.java
@@ -20,8 +20,11 @@
 package org.olat.ims.qti21.ui.assessment;
 
 import java.io.File;
+import java.io.IOException;
 import java.math.BigDecimal;
 import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -30,6 +33,7 @@ import java.util.Map;
 import org.olat.core.gui.UserRequest;
 import org.olat.core.gui.components.form.flexible.FormItem;
 import org.olat.core.gui.components.form.flexible.FormItemContainer;
+import org.olat.core.gui.components.form.flexible.elements.FileElement;
 import org.olat.core.gui.components.form.flexible.elements.FormLink;
 import org.olat.core.gui.components.form.flexible.elements.MultipleSelectionElement;
 import org.olat.core.gui.components.form.flexible.elements.RichTextElement;
@@ -48,10 +52,15 @@ import org.olat.core.gui.control.Controller;
 import org.olat.core.gui.control.Event;
 import org.olat.core.gui.control.WindowControl;
 import org.olat.core.gui.control.generic.closablewrapper.CloseableCalloutWindowController;
+import org.olat.core.gui.control.generic.modal.DialogBoxController;
+import org.olat.core.gui.control.generic.modal.DialogBoxUIFactory;
 import org.olat.core.gui.control.winmgr.JSCommand;
 import org.olat.core.util.CodeHelper;
+import org.olat.core.util.FileUtils;
+import org.olat.core.util.Formatter;
 import org.olat.core.util.StringHelper;
 import org.olat.core.util.Util;
+import org.olat.core.util.io.SystemFileFilter;
 import org.olat.course.assessment.AssessmentHelper;
 import org.olat.fileresource.FileResourceManager;
 import org.olat.fileresource.types.ImsQTI21Resource;
@@ -113,11 +122,15 @@ public class CorrectionIdentityInteractionsController extends FormBasicControlle
 	private FormLink viewSolutionButton;
 	private FormLink overrideScoreButton;
 	private FormLink viewCorrectSolutionButton;
+	private FileElement uploadDocsEl;
 	private ItemBodyResultFormItem solutionItem;
 	private FeedbackResultFormItem correctSolutionItem;
 	private MultipleSelectionElement toReviewEl;
 	private FormLayoutContainer overrideScoreCont;
-	
+	private FormLayoutContainer docsLayoutCont;
+	private FormLayoutContainer scoreCont;
+
+	private DialogBoxController confirmDeleteDocCtrl;
 	private OverrideScoreController overrideScoreCtrl;
 	private CloseableCalloutWindowController overrideScoreCalloutCtrl;
 	
@@ -171,6 +184,7 @@ public class CorrectionIdentityInteractionsController extends FormBasicControlle
 		assessmentObjectUri = qtiService.createAssessmentTestUri(fUnzippedDirRoot);
 		
 		initForm(ureq);
+		reloadAssessmentDocs();
 	}
 
 	@Override
@@ -225,7 +239,7 @@ public class CorrectionIdentityInteractionsController extends FormBasicControlle
 			coachComment = itemSession.getCoachComment();
 		}
 		
-		FormLayoutContainer scoreCont = FormLayoutContainer.createDefaultFormLayout("score.container", getTranslator());
+		scoreCont = FormLayoutContainer.createDefaultFormLayout("score.container", getTranslator());
 		formLayout.add("score.container", scoreCont);
 		
 		statusEl = uifactory.addStaticTextElement("status", "status", "", scoreCont);
@@ -268,6 +282,16 @@ public class CorrectionIdentityInteractionsController extends FormBasicControlle
 		IdentityAssessmentItemWrapper wrapper = new IdentityAssessmentItemWrapper(fullname, assessmentItem, correction, responseItems,
 				scoreEl, commentEl, statusEl);
 		
+		String page = velocity_root + "/item_assessment_docs.html"; 
+		docsLayoutCont = FormLayoutContainer.createCustomFormLayout("assessment.item.docs", getTranslator(), page);
+		docsLayoutCont.setLabel("assessment.item.docs", null);
+		docsLayoutCont.contextPut("mapperUri", mapperUri);
+		scoreCont.add(docsLayoutCont);
+
+		uploadDocsEl = uifactory.addFileElement(getWindowControl(), "assessment.item.docs.upload", "assessment.item.docs.upload", scoreCont);
+		uploadDocsEl.addActionListener(FormEvent.ONCHANGE);
+		uploadDocsEl.setVisible(!readOnly);
+		
 		toReviewEl = uifactory.addCheckboxesHorizontal("to.review", "to.review", scoreCont, onKeys, new String[] { "" });
 		toReviewEl.setEnabled(!readOnly);
 		if(itemSession != null && itemSession.isToReview()) {
@@ -515,6 +539,19 @@ public class CorrectionIdentityInteractionsController extends FormBasicControlle
 			doToggleSolution();
 		} else if(viewCorrectSolutionButton == source) {
 			doToggleCorrectSolution();
+		} else if(uploadDocsEl == source) {
+			if(uploadDocsEl.getUploadFile() != null && StringHelper.containsNonWhitespace(uploadDocsEl.getUploadFileName())) {
+				doUploadAssessmentDocument(uploadDocsEl.getUploadFile(), uploadDocsEl.getUploadFileName());
+				reloadAssessmentDocs();
+				uploadDocsEl.reset();
+			}
+		} else if(source instanceof FormLink) {
+			FormLink link = (FormLink)source;
+			Object uobject = link.getUserObject();
+			if(link.getCmd() != null && link.getCmd().startsWith("delete_doc_") && uobject instanceof DocumentWrapper) {
+				DocumentWrapper wrapper = (DocumentWrapper)uobject;
+				doConfirmDeleteAssessmentDocument(ureq, wrapper.getDocument());
+			}
 		}
 		super.formInnerEvent(ureq, source, event);
 	}
@@ -530,6 +567,12 @@ public class CorrectionIdentityInteractionsController extends FormBasicControlle
 		} else if(overrideScoreCalloutCtrl == source) {
 			overrideScoreCalloutCtrl.deactivate();
 			cleanUp();
+		} else if(source == confirmDeleteDocCtrl) {
+			if(DialogBoxUIFactory.isOkEvent(event) || DialogBoxUIFactory.isYesEvent(event)) {
+				File documentToDelete = (File)confirmDeleteDocCtrl.getUserObject();
+				doDeleteAssessmentDocument(documentToDelete);
+				reloadAssessmentDocs();
+			}
 		}
 		super.event(ureq, source, event);
 	}
@@ -602,6 +645,76 @@ public class CorrectionIdentityInteractionsController extends FormBasicControlle
 		return Double.parseDouble(scoreStr);
 	}
 	
+	private void doUploadAssessmentDocument(File uploadedFile, String filename) {
+		File directory = qtiService.getAssessmentDocumentsDirectory(correction.getTestSession(), correction.getItemSession());
+		if(directory != null) {
+			if(!directory.exists()) {
+				directory.mkdirs();
+			}
+			
+			File targetFile = new File(directory, filename);
+			if(targetFile.exists()) {
+				String newName = FileUtils.rename(targetFile);
+				targetFile = new File(directory, newName);
+			}
+			try {
+				Files.copy(uploadedFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
+			} catch (IOException e) {
+				logError("", e);
+			}
+		}
+	}
+	
+	private void reloadAssessmentDocs() {
+		if(docsLayoutCont == null) return;
+
+		List<DocumentWrapper> wrappers = new ArrayList<>();
+		File directory = qtiService.getAssessmentDocumentsDirectory(correction.getTestSession(), correction.getItemSession());
+		if(directory != null && directory.exists()) {
+			File[] documents = directory.listFiles(SystemFileFilter.FILES_ONLY);
+			if(documents != null) {
+				for (File document : documents) {
+					DocumentWrapper wrapper = new DocumentWrapper(document);
+					wrappers.add(wrapper);
+					
+					if(!readOnly) {
+						FormLink deleteButton = uifactory.addFormLink("delete_doc_" + (++count), "delete", null, docsLayoutCont, Link.BUTTON_XSMALL);
+						deleteButton.setEnabled(true);  
+						deleteButton.setVisible(true);
+						wrapper.setDeleteButton(deleteButton);
+					}
+				}
+			}
+		}
+		
+		docsLayoutCont.contextPut("documents", wrappers);
+		docsLayoutCont.contextPut("itemSessionKey", correction.getItemSession().getKey());
+		docsLayoutCont.contextPut("testSessionKey", correction.getTestSession().getKey());
+		docsLayoutCont.contextPut("documents", wrappers);
+		docsLayoutCont.setVisible(!wrappers.isEmpty());
+
+		if(uploadDocsEl != null && uploadDocsEl.isVisible()) {
+			if(wrappers.isEmpty()) {
+				uploadDocsEl.setLabel("assessment.item.docs.upload", null);
+				scoreCont.setDirty(true);
+			} else {
+				uploadDocsEl.setLabel(null, null);
+			}
+		}
+	}
+	
+	private void doConfirmDeleteAssessmentDocument(UserRequest ureq, File document) {
+		String title = translate("warning.assessment.docs.delete.title");
+		String text = translate("warning.assessment.docs.delete.text",
+				new String[] { StringHelper.escapeHtml(document.getName()) });
+		confirmDeleteDocCtrl = activateOkCancelDialog(ureq, title, text, confirmDeleteDocCtrl);
+		confirmDeleteDocCtrl.setUserObject(document);
+	}
+	
+	private void doDeleteAssessmentDocument(File document) {
+		FileUtils.deleteFile(document);
+	}
+	
 	private void doToggleSolution() {
 		if(solutionItem.isVisible()) {
 			viewSolutionButton.setIconLeftCSS("o_icon o_icon_open_togglebox");
@@ -703,4 +816,35 @@ public class CorrectionIdentityInteractionsController extends FormBasicControlle
 			fireEvent(ureq, Event.CANCELLED_EVENT);
 		}
 	}
+	
+	public static class DocumentWrapper {
+		
+		private final File document;
+		private FormLink deleteButton;
+		
+		public DocumentWrapper(File document) {
+			this.document = document;
+		}
+		
+		public String getFilename() {
+			return document.getName();
+		}
+		
+		public String getLabel() {
+			return document.getName() + " (" + Formatter.formatBytes(document.length()) + ")";
+		}
+		
+		public File getDocument() {
+			return document;
+		}
+
+		public FormLink getDeleteButton() {
+			return deleteButton;
+		}
+
+		public void setDeleteButton(FormLink deleteButton) {
+			this.deleteButton = deleteButton;
+			deleteButton.setUserObject(this);
+		}
+	}
 }
diff --git a/src/main/java/org/olat/ims/qti21/ui/assessment/_content/item_assessment_docs.html b/src/main/java/org/olat/ims/qti21/ui/assessment/_content/item_assessment_docs.html
new file mode 100644
index 00000000000..03f05754be0
--- /dev/null
+++ b/src/main/java/org/olat/ims/qti21/ui/assessment/_content/item_assessment_docs.html
@@ -0,0 +1,7 @@
+#if($r.isNotEmpty($documents))
+<ul class="form-static list-unstyled o_assessment_docs"">
+	#foreach($document in $documents)
+	<li><a href="$mapperUri/assessmentdocs/$r.encodeUrl($document.filename)?href=assessmentdocs/$testSessionKey/$itemSessionKey/$r.encodeUrl($document.filename)" target="_blank"><i class="o_icon o_icon-fw $r.getFiletypeIconCss($document.filename)"> </i> $r.escapeHtml($document.label)</a> #if($r.visible($document.deleteButton))$r.render($document.deleteButton)#end</li>
+	#end
+</ul>
+#end
\ No newline at end of file
-- 
GitLab