/** * <a href="http://www.openolat.org"> * OpenOLAT - Online Learning and Training</a><br> * <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 the * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a> * <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> * Initial code contributed and copyrighted by<br> * frentix GmbH, http://www.frentix.com * <p> */ package org.olat.ims.qti21.resultexport; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.TimeZone; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import javax.servlet.http.HttpServletResponse; import org.apache.velocity.VelocityContext; import org.olat.core.gui.GlobalSettings; import org.olat.core.gui.UserRequest; import org.olat.core.gui.components.Component; import org.olat.core.gui.components.velocity.VelocityContainer; import org.olat.core.gui.control.Controller; import org.olat.core.gui.control.WindowControl; import org.olat.core.gui.control.winmgr.AJAXFlags; import org.olat.core.gui.media.MediaResource; import org.olat.core.gui.render.RenderResult; import org.olat.core.gui.render.Renderer; import org.olat.core.gui.render.StringOutput; import org.olat.core.gui.render.URLBuilder; import org.olat.core.gui.render.velocity.VelocityHelper; import org.olat.core.gui.translator.Translator; import org.olat.core.gui.util.WindowControlMocker; import org.olat.core.id.Identity; import org.olat.core.id.UserConstants; import org.olat.core.logging.OLog; import org.olat.core.logging.Tracing; 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.WebappHelper; import org.olat.core.util.ZipUtil; import org.olat.course.nodes.ArchiveOptions; import org.olat.course.nodes.QTICourseNode; import org.olat.course.run.environment.CourseEnvironment; import org.olat.fileresource.FileResourceManager; import org.olat.ims.qti.resultexport.AssessedMember; import org.olat.ims.qti.resultexport.ResultDetail; import org.olat.ims.qti21.AssessmentTestSession; import org.olat.ims.qti21.QTI21AssessmentResultsOptions; import org.olat.ims.qti21.QTI21Service; import org.olat.ims.qti21.manager.archive.QTI21ArchiveFormat; import org.olat.ims.qti21.model.QTI21StatisticSearchParams; import org.olat.ims.qti21.ui.AssessmentResultController; import org.olat.repository.RepositoryEntry; public class QTI21ResultsExportMediaResource implements MediaResource { private static final OLog log = Tracing.createLoggerFor(QTI21ResultsExportMediaResource.class); private static final String DATA = "userdata/"; private static final String SEP = File.separator; private static final SimpleDateFormat assessmentDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); private static final SimpleDateFormat displayDateFormat = new SimpleDateFormat("HH:mm:ss"); static { displayDateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); } private VelocityHelper velocityHelper; private List<Identity> identities; private QTICourseNode courseNode; private QTI21Service qtiService; private String title, exportFolderName; private Translator translator; private RepositoryEntry entry; private UserRequest ureq; private final Set<RepositoryEntry> testEntries = new HashSet<>(); public QTI21ResultsExportMediaResource(CourseEnvironment courseEnv, List<Identity> identities, QTICourseNode courseNode, QTI21Service qtiService, UserRequest ureq) { this.title = "qti21export"; this.courseNode = courseNode; this.identities = identities; this.velocityHelper = VelocityHelper.getInstance(); this.qtiService = qtiService; this.ureq = ureq; this.entry = courseEnv.getCourseGroupManager().getCourseEntry(); translator = Util.createPackageTranslator(QTI21ResultsExportMediaResource.class, ureq.getLocale()); } public QTI21ResultsExportMediaResource(CourseEnvironment courseEnv, List<Identity> identities, QTICourseNode courseNode, QTI21Service qtiService, UserRequest ureq, Locale locale) { this.title = "qti21export"; this.courseNode = courseNode; this.identities = identities; this.velocityHelper = VelocityHelper.getInstance(); this.qtiService = qtiService; this.ureq = ureq; this.entry = courseEnv.getCourseGroupManager().getCourseEntry(); this.translator = Util.createPackageTranslator(QTI21ResultsExportMediaResource.class, locale); this.exportFolderName = translator.translate("export.folder.name"); } @Override public boolean acceptRanges() { return false; } @Override public String getContentType() { return "application/zip"; } @Override public Long getSize() { return null; } @Override public InputStream getInputStream() { return null; } @Override public Long getLastModified() { return null; } @Override public void prepare(HttpServletResponse hres) { //init package translator exportFolderName = translator.translate("export.folder.name"); String label = StringHelper.transformDisplayNameToFileSystemName(title); if (label != null && !label.toLowerCase().endsWith(".zip")) { label += ".zip"; } String urlEncodedLabel = StringHelper.urlEncodeUTF8(label); hres.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + urlEncodedLabel); hres.setHeader("Content-Description", urlEncodedLabel); try { ZipOutputStream zout = new ZipOutputStream(hres.getOutputStream()); zout.setLevel(9); exportTestResults(zout); for(RepositoryEntry testEntry:testEntries) { exportExcelResults(testEntry, zout); } zout.close(); } catch (Exception e) { log.error("Unknown error while assessment result resource export", e); } } /** * Adds the result export to existing zip stream. * * @throws IOException */ public void exportTestResults(ZipOutputStream zout) throws IOException { List<AssessedMember> assessedMembers = createAssessedMembersDetail(zout); //convert velocity template to zip entry String membersHTML = createMemberListingHTML(assessedMembers); convertToZipEntry(zout, exportFolderName + "/index.html", membersHTML); //Copy resource files or file trees to export file tree File sasstheme = new File(WebappHelper.getContextRealPath("/static/offline/qti")); ZipUtil.addDirectoryToZip(sasstheme.toPath(), exportFolderName + "/css/offline/qti/", zout); File fontawesome = new File(WebappHelper.getContextRealPath("/static/font-awesome")); ZipUtil.addDirectoryToZip(fontawesome.toPath(), exportFolderName + "/css/font-awesome/", zout); File qtiJs = new File(WebappHelper.getContextRealPath("/static/js/jquery/")); ZipUtil.addDirectoryToZip(qtiJs.toPath(), exportFolderName + "/js/jquery/", zout); //materials for(RepositoryEntry testEntry:testEntries) { copyTestMaterials(testEntry, zout); } } private void exportExcelResults(RepositoryEntry testEntry, ZipOutputStream zout) { ArchiveOptions options = new ArchiveOptions(); options.setIdentities(identities); QTI21StatisticSearchParams searchParams = new QTI21StatisticSearchParams(options, testEntry, entry, courseNode.getIdent()); searchParams.setLimitToIdentities(identities); QTI21ArchiveFormat qaf = new QTI21ArchiveFormat(translator.getLocale(), searchParams); String label = StringHelper.transformDisplayNameToFileSystemName(courseNode.getShortName() + "_" + testEntry.getDisplayname()) + "_" + Formatter.formatDatetimeWithMinutes(new Date()) + ".xlsx"; qaf.exportCourseElement(exportFolderName + "/" + label, zout); } private List<ResultDetail> createResultDetail (Identity identity, ZipOutputStream zout, String idDir) throws IOException { List<ResultDetail> assessments = new ArrayList<ResultDetail>(); List<AssessmentTestSession> sessions = qtiService.getAssessmentTestSessions(entry, courseNode.getIdent(), identity); for (AssessmentTestSession session : sessions) { Long assessmentID = session.getKey(); String idPath = idDir + translator.translate("table.user.attempt") + (sessions.indexOf(session)+1) + SEP; createZipDirectory(zout, idPath); // content of result table ResultDetail resultDetail = new ResultDetail(assessmentID.toString(), assessmentDateFormat.format(session.getCreationDate()), displayDateFormat.format(new Date(session.getDuration())), session.getScore().floatValue(), createPassedIcons(session.getPassed() == null ? true : session.getPassed()), idPath.replace(idDir, "") + assessmentID + ".html"); assessments.add(resultDetail); //WindowControlMocker needed because this is not a controller WindowControl mockwControl = new WindowControlMocker(); FileResourceManager frm = FileResourceManager.getInstance(); RepositoryEntry testEntry = session.getTestEntry(); testEntries.add(testEntry); File fUnzippedDirRoot = frm.unzipFileResource(testEntry.getOlatResource()); String mapperUri = "../../../test" + testEntry.getKey() + "/";//add test repo key String submissionMapperUri = "."; String exportUri = "../" + translator.translate("table.user.attempt") + (sessions.indexOf(session)+1); Controller assessmentResultController = new AssessmentResultController(ureq, mockwControl, identity, false, session, fUnzippedDirRoot, mapperUri, submissionMapperUri, QTI21AssessmentResultsOptions.allOptions(), false, true, exportUri); Component component = assessmentResultController.getInitialComponent(); String componentHTML = createResultHTML(component); convertToZipEntry(zout, idPath + assessmentID +".html", componentHTML); File resultXML = qtiService.getAssessmentResultFile(session); convertToZipEntry(zout, idPath + assessmentID +".xml", resultXML); File signatureXML = qtiService.getAssessmentResultSignature(session); if (signatureXML != null) { convertToZipEntry(zout, idPath + "assessmentResultSignature.xml", signatureXML); } File submissionDir = qtiService.getSubmissionDirectory(session); String baseDir = idPath + "submissions/"; ZipUtil.addDirectoryToZip(submissionDir.toPath(), baseDir, zout); } return assessments; } private List<AssessedMember> createAssessedMembersDetail (ZipOutputStream zout) throws IOException { List<AssessedMember> assessedMembers = new ArrayList<>(); for (Identity identity : identities) { String idDir = exportFolderName + "/" + DATA + identity.getName(); idDir = idDir.endsWith(SEP) ? idDir : idDir + SEP; createZipDirectory(zout, idDir); //content of single assessed member String userName = identity.getName(); String firstName = identity.getUser().getProperty(UserConstants.FIRSTNAME, null); String lastName = identity.getUser().getProperty(UserConstants.LASTNAME, null); String memberEmail = identity.getUser().getProperty(UserConstants.EMAIL, null); AssessedMember assessedMember = new AssessedMember (userName, lastName, firstName, memberEmail, null); List<ResultDetail> assessments = createResultDetail(identity, zout, idDir); String singleUserInfoHTML = createResultListingHTML(assessments, assessedMember); convertToZipEntry(zout, exportFolderName + "/" + DATA + identity.getName() + "/index.html", singleUserInfoHTML); String linkToUser = idDir.replace(exportFolderName + "/", "") + "index.html"; //content of assessed members table AssessedMember member = new AssessedMember(); member.setUsername(createLink(identity.getName(), linkToUser, false)); member.setLastname(createLink(identity.getUser().getProperty(UserConstants.LASTNAME, null),linkToUser,false)); member.setFirstname(createLink(identity.getUser().getProperty(UserConstants.FIRSTNAME, null),linkToUser,false)); member.setTries(String.valueOf(assessments.size())); assessedMembers.add(member); } return assessedMembers; } private void copyTestMaterials(RepositoryEntry testEntry, ZipOutputStream zout) { FileResourceManager frm = FileResourceManager.getInstance(); File fUnzippedDirRoot = frm.unzipFileResource(testEntry.getOlatResource()); String baseDir = exportFolderName + "/test" + testEntry.getKey(); ZipUtil.addDirectoryToZip(fUnzippedDirRoot.toPath(), baseDir, zout); } private String createLink(String name, String href, boolean userview) { String targetLink = userview ? "_blank" : "_self"; return "<a href='" + href + "' target='" + targetLink + "' class='userLink'>" + name + "</a>"; } private String createPassedIcons(boolean passed) { String icon = passed ? "<i class='o_icon o_passed o_icon_passed text-success'></i>" : "<i class='o_icon o_failed o_icon_failed text-danger'></i>"; return icon; } private String createResultHTML (Component results){ StringOutput sb = new StringOutput(32000); String pagePath = Util.getPackageVelocityRoot(this.getClass()) + "/qti21results.html"; URLBuilder ubu = new URLBuilder("auth", "1", "0"); //generate VelocityContainer and put Component VelocityContainer mainVC = new VelocityContainer("html", pagePath, translator, null); mainVC.contextPut("rootTitle", translator.translate("table.grading")); mainVC.put("results", results); //render VelocityContainer to StringOutPut Renderer renderer = Renderer.getInstance(mainVC, translator, ubu, new RenderResult(), new EmptyGlobalSettings()); renderer.render(sb, mainVC, null); return sb.toString(); } private String createResultListingHTML (List<ResultDetail> assessments,AssessedMember assessedMember){ // now put values to velocityContext VelocityContext ctx = new VelocityContext(); ctx.put("t", translator); ctx.put("title", translator.translate("table.overview")); ctx.put("return", translator.translate("button.return")); ctx.put("assessments", assessments); ctx.put("assessedMember", assessedMember); if (assessments.size() > 0) ctx.put("hasResults", Boolean.TRUE); String template = FileUtils.load(QTI21ResultsExportMediaResource.class .getResourceAsStream("_content/qtiListing.html"), "utf-8"); return velocityHelper.evaluateVTL(template, ctx); } private String createMemberListingHTML(List<AssessedMember> assessedMembers) { Collections.sort(assessedMembers, new Comparator<AssessedMember>() { @Override public int compare(AssessedMember o1, AssessedMember o2) { return o1.getUsername().compareTo(o2.getUsername()); } }); // now put values to velocityContext VelocityContext ctx = new VelocityContext(); ctx.put("t", translator); ctx.put("rootTitle", translator.translate("table.overview")); ctx.put("assessedMembers", assessedMembers); String template = FileUtils.load(QTI21ResultsExportMediaResource.class .getResourceAsStream("_content/qtiUserlisting.html"), "utf-8"); return velocityHelper.evaluateVTL(template, ctx); } private void convertToZipEntry(ZipOutputStream zout, String link, File file) throws IOException { zout.putNextEntry(new ZipEntry(link)); try (InputStream in = new FileInputStream(file)) { FileUtils.copy(in, zout); } catch (Exception e) { log.error("Error during copy of resource export", e); } finally { zout.closeEntry(); } } private void convertToZipEntry(ZipOutputStream zout, String link, String content) throws IOException { zout.putNextEntry(new ZipEntry(link)); try (InputStream in = new ByteArrayInputStream(content.getBytes())) { FileUtils.copy(in, zout); } catch (Exception e) { log.error("Error during copy of resource export", e); } finally { zout.closeEntry(); } } private void createZipDirectory (ZipOutputStream zout, String dir) throws IOException{ dir = dir.endsWith(SEP) ? dir : dir + SEP; zout.putNextEntry(new ZipEntry(dir)); zout.closeEntry(); } @Override public void release() { // } private static class EmptyAJAXFlags extends AJAXFlags { public EmptyAJAXFlags() { super(null); } @Override public boolean isIframePostEnabled() { return false; } } private static class EmptyGlobalSettings implements GlobalSettings { @Override public int getFontSize() { return 100; } @Override public AJAXFlags getAjaxFlags() { return new EmptyAJAXFlags(); } @Override public boolean isIdDivsForced() { return false; } }; }