diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/AssignmentFacade.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/AssignmentFacade.java index b76ae5bf80..091175fb14 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/AssignmentFacade.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/AssignmentFacade.java @@ -506,7 +506,7 @@ public Response getAssignmentProgressDownloadCSV(@Context final HttpServletReque questionPageIds.add(questionPage.getId()); } Map>>> questionAttempts; - questionAttempts = this.questionManager.getMatchingQuestionAttempts(groupMembers, questionPageIds); + questionAttempts = this.questionManager.getMatchingLightweightQuestionAttempts(groupMembers, questionPageIds); Map>>> questionAttemptsForAllUsersOfInterest = new HashMap<>(); @@ -733,7 +733,7 @@ public Response getGroupAssignmentsProgressDownloadCSV(@Context final HttpServle List questionPageIds = gameboardItems.stream().map(GameboardItem::getId).collect(Collectors.toList()); Map>>> questionAttempts; try { - questionAttempts = this.questionManager.getMatchingQuestionAttempts(groupMembers, questionPageIds); + questionAttempts = this.questionManager.getMatchingLightweightQuestionAttempts(groupMembers, questionPageIds); } catch (IllegalArgumentException e) { questionAttempts = new HashMap<>(); } diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/GameboardsFacade.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/GameboardsFacade.java index 2796aac8dd..997dd5db02 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/GameboardsFacade.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/GameboardsFacade.java @@ -30,9 +30,11 @@ import uk.ac.cam.cl.dtg.isaac.api.managers.NoWildcardException; import uk.ac.cam.cl.dtg.isaac.dos.GameboardCreationMethod; import uk.ac.cam.cl.dtg.isaac.dos.IsaacWildcard; +import uk.ac.cam.cl.dtg.isaac.dos.LightweightQuestionValidationResponse; import uk.ac.cam.cl.dtg.isaac.dto.GameboardDTO; import uk.ac.cam.cl.dtg.isaac.dto.GameboardItem; import uk.ac.cam.cl.dtg.isaac.dto.GameboardListDTO; +import uk.ac.cam.cl.dtg.isaac.dto.users.AnonymousUserDTO; import uk.ac.cam.cl.dtg.segue.api.managers.QuestionManager; import uk.ac.cam.cl.dtg.segue.api.managers.UserAccountManager; import uk.ac.cam.cl.dtg.segue.api.managers.UserAssociationManager; @@ -67,6 +69,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import static com.google.common.collect.Maps.immutableEntry; import static uk.ac.cam.cl.dtg.isaac.api.Constants.*; @@ -239,8 +242,6 @@ public final Response getGameboard(@Context final Request request, GameboardDTO gameboard; AbstractSegueUserDTO randomUser = this.userManager.getCurrentUser(httpServletRequest); - Map>> userQuestionAttempts = this.questionManager - .getQuestionAttemptsByUser(randomUser); GameboardDTO unAugmentedGameboard = gameManager.getGameboard(gameboardId); if (null == unAugmentedGameboard) { @@ -248,6 +249,15 @@ public final Response getGameboard(@Context final Request request, .toResponse(); } + Map>> userQuestionAttempts; + + if (randomUser instanceof AnonymousUserDTO) { + userQuestionAttempts = this.questionManager.getQuestionAttemptsByUser(randomUser); + } else { + List gameboardPageIds = unAugmentedGameboard.getContents().stream().map(GameboardItem::getId).collect(Collectors.toList()); + userQuestionAttempts = this.questionManager.getMatchingLightweightQuestionAttempts((RegisteredUserDTO) randomUser, gameboardPageIds); + } + // Calculate the ETag EntityTag etag = new EntityTag(unAugmentedGameboard.toString().hashCode() + userQuestionAttempts.toString().hashCode() + ""); diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/PagesFacade.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/PagesFacade.java index 1eca4d9b1c..b308381305 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/PagesFacade.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/PagesFacade.java @@ -28,19 +28,13 @@ import uk.ac.cam.cl.dtg.isaac.api.managers.GameManager; import uk.ac.cam.cl.dtg.isaac.api.managers.URIManager; import uk.ac.cam.cl.dtg.isaac.dos.IsaacTopicSummaryPage; +import uk.ac.cam.cl.dtg.isaac.dos.LightweightQuestionValidationResponse; +import uk.ac.cam.cl.dtg.isaac.dos.QuestionValidationResponse; +import uk.ac.cam.cl.dtg.isaac.dos.content.Content; import uk.ac.cam.cl.dtg.isaac.dto.GameboardDTO; import uk.ac.cam.cl.dtg.isaac.dto.IsaacPageFragmentDTO; import uk.ac.cam.cl.dtg.isaac.dto.IsaacQuestionPageDTO; import uk.ac.cam.cl.dtg.isaac.dto.IsaacTopicSummaryPageDTO; -import uk.ac.cam.cl.dtg.segue.api.managers.QuestionManager; -import uk.ac.cam.cl.dtg.segue.api.managers.UserAccountManager; -import uk.ac.cam.cl.dtg.segue.api.services.ContentService; -import uk.ac.cam.cl.dtg.segue.dao.ILogManager; -import uk.ac.cam.cl.dtg.segue.dao.SegueDatabaseException; -import uk.ac.cam.cl.dtg.segue.dao.content.ContentManagerException; -import uk.ac.cam.cl.dtg.segue.dao.content.GitContentManager; -import uk.ac.cam.cl.dtg.isaac.dos.QuestionValidationResponse; -import uk.ac.cam.cl.dtg.isaac.dos.content.Content; import uk.ac.cam.cl.dtg.isaac.dto.ResultsWrapper; import uk.ac.cam.cl.dtg.isaac.dto.SegueErrorResponse; import uk.ac.cam.cl.dtg.isaac.dto.content.ContentBaseDTO; @@ -50,6 +44,14 @@ import uk.ac.cam.cl.dtg.isaac.dto.users.AbstractSegueUserDTO; import uk.ac.cam.cl.dtg.isaac.dto.users.AnonymousUserDTO; import uk.ac.cam.cl.dtg.isaac.dto.users.RegisteredUserDTO; +import uk.ac.cam.cl.dtg.segue.api.managers.QuestionManager; +import uk.ac.cam.cl.dtg.segue.api.managers.UserAccountManager; +import uk.ac.cam.cl.dtg.segue.api.services.ContentService; +import uk.ac.cam.cl.dtg.segue.dao.ILogManager; +import uk.ac.cam.cl.dtg.segue.dao.SegueDatabaseException; +import uk.ac.cam.cl.dtg.segue.dao.content.ContentManagerException; +import uk.ac.cam.cl.dtg.segue.dao.content.GitContentManager; +import uk.ac.cam.cl.dtg.util.AbstractConfigLoader; import jakarta.annotation.Nullable; import jakarta.servlet.http.HttpServletRequest; @@ -65,14 +67,13 @@ import jakarta.ws.rs.core.Request; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; -import uk.ac.cam.cl.dtg.util.AbstractConfigLoader; - import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import static uk.ac.cam.cl.dtg.isaac.api.Constants.*; import static uk.ac.cam.cl.dtg.segue.api.Constants.*; @@ -419,54 +420,65 @@ public final Response getQuestionList(@Context final Request request, @Produces(MediaType.APPLICATION_JSON) @GZIP @Operation(summary = "Get a question page object by ID.") - public final Response getQuestion(@Context final Request request, - @Context final HttpServletRequest httpServletRequest, + public final Response getQuestion(@Context final Request request, @Context final HttpServletRequest httpServletRequest, @PathParam("question_page_id") final String questionId) { - Map> fieldsToMatch = Maps.newHashMap(); - fieldsToMatch.put("type", Arrays.asList(QUESTION_TYPE, FAST_TRACK_QUESTION_TYPE)); if (null == questionId || questionId.isEmpty()) { return new SegueErrorResponse(Status.BAD_REQUEST, "You must provide a valid question id.").toResponse(); } - // options - fieldsToMatch.put(ID_FIELDNAME + "." + UNPROCESSED_SEARCH_FIELD_SUFFIX, Arrays.asList(questionId)); - try { AbstractSegueUserDTO user = userManager.getCurrentUser(httpServletRequest); - Map>> userQuestionAttempts; - userQuestionAttempts = questionManager.getQuestionAttemptsByUser(user); - - // Calculate the ETag - EntityTag etag = new EntityTag(questionId.hashCode() + userQuestionAttempts.toString().hashCode() + ""); - - Response cachedResponse = generateCachedResponse(request, etag, NEVER_CACHE_WITHOUT_ETAG_CHECK); - if (cachedResponse != null) { - return cachedResponse; - } - Response response = this.findSingleResult(fieldsToMatch, userQuestionAttempts); + ContentDTO contentDTO = contentManager.getContentById(questionId); - if (response.getEntity() != null && response.getEntity() instanceof IsaacQuestionPageDTO) { - SeguePageDTO content = (SeguePageDTO) response.getEntity(); - - Map logEntry = ImmutableMap.of(QUESTION_ID_LOG_FIELDNAME, content.getId(), - CONTENT_VERSION_FIELDNAME, this.contentManager.getCurrentContentSHA()); + if (contentDTO instanceof IsaacQuestionPageDTO) { + SeguePageDTO content = (SeguePageDTO) contentDTO; String userIdForRandomisation; + Map>> questionAttempts; + Map>> relatedQuestionAttempts; + // We have to cope with both anonymous and registered users: if (user instanceof AnonymousUserDTO) { + // For anonymous users, we just load all their question attempts. userIdForRandomisation = ((AnonymousUserDTO) user).getSessionId(); + + Map>> userQuestionAttempts = questionManager.getQuestionAttemptsByUser(user); + relatedQuestionAttempts = userQuestionAttempts; + questionAttempts = userQuestionAttempts; + } else { - userIdForRandomisation = ((RegisteredUserDTO) user).getId().toString(); + // For registered users, we can restrict our search to question attempts at this question (for which we + // need full validation responses), and attempts at related questions (for which we only need lightweight + // validation responses). + RegisteredUserDTO registeredUser = (RegisteredUserDTO) user; + userIdForRandomisation = registeredUser.getId().toString(); + + List relatedQuestionIds = new ArrayList<>(getRelatedContentIds(content)); + relatedQuestionAttempts = questionManager.getMatchingLightweightQuestionAttempts(registeredUser, relatedQuestionIds); + + questionAttempts = questionManager.getQuestionAttemptsByUserForQuestion(registeredUser, questionId); + } + + // Check the cache status: + EntityTag etag = new EntityTag(String.valueOf(this.contentManager.getCurrentContentSHA().hashCode() + + questionId.hashCode() + questionAttempts.toString().hashCode() + relatedQuestionAttempts.toString().hashCode())); + Response cachedResponse = generateCachedResponse(request, etag, NEVER_CACHE_WITHOUT_ETAG_CHECK); + if (cachedResponse != null) { + return cachedResponse; } - content = this.questionManager.augmentQuestionObjects(content, userIdForRandomisation, - userQuestionAttempts); + // Then augment the page with attempt and related content information: + augmentContentWithRelatedContent(content, relatedQuestionAttempts); + questionManager.augmentQuestionObjects(content, userIdForRandomisation, questionAttempts); - // the request log + // Log the request: + Map logEntry = ImmutableMap.of( + QUESTION_ID_LOG_FIELDNAME, content.getId(), + CONTENT_VERSION_FIELDNAME, this.contentManager.getCurrentContentSHA()); getLogManager().logEvent(user, httpServletRequest, IsaacServerLogType.VIEW_QUESTION, logEntry); - // return augmented content. + // Return augmented content: return Response.ok(content) .cacheControl(getCacheControl(NEVER_CACHE_WITHOUT_ETAG_CHECK, false)) .tag(etag) @@ -480,6 +492,10 @@ public final Response getQuestion(@Context final Request request, String message = "SegueDatabaseException whilst trying to retrieve user data"; log.error(message, e); return new SegueErrorResponse(Status.INTERNAL_SERVER_ERROR, message).toResponse(); + } catch (ContentManagerException e) { + String message = "Error whilst trying to load question content!"; + log.error(message, e); + return new SegueErrorResponse(Status.INTERNAL_SERVER_ERROR, message).toResponse(); } } @@ -505,6 +521,7 @@ public final Response getTopicSummaryPage(@Context final Request request, // Calculate the ETag on current live version of the content // NOTE: Assumes that the latest version of the content is being used. + // Should this include the question attempts? EntityTag etag = new EntityTag(this.contentManager.getCurrentContentSHA().hashCode() + topicId.hashCode() + ""); Response cachedResponse = generateCachedResponse(request, etag); if (cachedResponse != null) { @@ -527,11 +544,17 @@ public final Response getTopicSummaryPage(@Context final Request request, IsaacTopicSummaryPageDTO topicSummaryDTO = (IsaacTopicSummaryPageDTO) contentDTOById; AbstractSegueUserDTO user = userManager.getCurrentUser(httpServletRequest); - Map>> userQuestionAttempts; - // Augment related questions with attempt information: - userQuestionAttempts = questionManager.getQuestionAttemptsByUser(user); - this.augmentContentWithRelatedContent(topicSummaryDTO, userQuestionAttempts); + Map>> relatedQuestionAttempts; + // We have to cope with both anonymous and registered users: + if (user instanceof AnonymousUserDTO) { + relatedQuestionAttempts = questionManager.getQuestionAttemptsByUser(user); + } else { + List relatedQuestionIds = getRelatedContentIds(topicSummaryDTO); + relatedQuestionAttempts = questionManager.getMatchingLightweightQuestionAttempts((RegisteredUserDTO) user, relatedQuestionIds); + } + + this.augmentContentWithRelatedContent(topicSummaryDTO, relatedQuestionAttempts); // Augment linked gameboards using the list in the DO: // FIXME: this requires loading both the DO and DTO separately, since augmenting things is hard right now. @@ -560,8 +583,11 @@ public final Response getTopicSummaryPage(@Context final Request request, .put(CONTENT_VERSION_FIELDNAME, this.contentManager.getCurrentContentSHA()).build(); getLogManager().logEvent(user, httpServletRequest, IsaacServerLogType.VIEW_TOPIC_SUMMARY_PAGE, logEntry); + // If there are no question attempts, this is safe to cache. + boolean isPublicData = relatedQuestionAttempts.isEmpty(); + return Response.status(Status.OK).entity(topicSummaryDTO) - .cacheControl(getCacheControl(NUMBER_SECONDS_IN_ONE_HOUR, true)).tag(etag).build(); + .cacheControl(getCacheControl(NUMBER_SECONDS_IN_ONE_HOUR, isPublicData)).tag(etag).build(); } catch (SegueDatabaseException e) { SegueErrorResponse error = new SegueErrorResponse(Status.INTERNAL_SERVER_ERROR, "Database error while looking up user information.", e); @@ -726,9 +752,37 @@ public final Response getPodList(@Context final Request request, } } + /** + * Return the IDs of all related content for a content object, including those of nested children. + * + * Note that some IDs returned may not be question page IDs, but may be concept pages etc. + * + * @param content + * @return + */ + private List getRelatedContentIds(final ContentDTO content) { + List relatedContent = new ArrayList<>(); + + if (null != content.getRelatedContent()) { + // This might include concept page IDs too. + relatedContent.addAll(content.getRelatedContent().stream().map(ContentSummaryDTO::getId).collect(Collectors.toList())); + } + + List children = content.getChildren(); + if (children != null) { + for (ContentBaseDTO child : children) { + if (child instanceof ContentDTO) { + ContentDTO childContent = (ContentDTO) child; + relatedContent.addAll(getRelatedContentIds(childContent)); + } + } + } + return relatedContent; + } + /** * Utility method to allow related content to be populated as summary objects. - * + * * By default content summary objects may just have ids. * * @param contentToAugment @@ -740,7 +794,7 @@ public final Response getPodList(@Context final Request request, * - an exception when the content is not found */ private ContentDTO augmentContentWithRelatedContent(final ContentDTO contentToAugment, - @Nullable final Map>> usersQuestionAttempts) + @Nullable Map>> usersQuestionAttempts) throws ContentManagerException { ContentDTO augmentedDTO = this.contentManager.populateRelatedContent(contentToAugment); @@ -762,21 +816,20 @@ private ContentDTO augmentContentWithRelatedContent(final ContentDTO contentToAu */ private void augmentRelatedQuestionsWithAttemptInformation( final ContentDTO content, - final Map>> usersQuestionAttempts) { + final Map>> usersQuestionAttempts) { // Check if all question parts have been answered List relatedContentSummaries = content.getRelatedContent(); if (relatedContentSummaries != null) { for (ContentSummaryDTO relatedContentSummary : relatedContentSummaries) { String questionId = relatedContentSummary.getId(); - Map> questionAttempts = usersQuestionAttempts.get(questionId); + Map> questionAttempts = usersQuestionAttempts.get(questionId); boolean questionAnsweredCorrectly = false; if (questionAttempts != null) { for (String relatedQuestionPartId : relatedContentSummary.getQuestionPartIds()) { questionAnsweredCorrectly = false; - List questionPartAttempts = - questionAttempts.get(relatedQuestionPartId); + List questionPartAttempts = questionAttempts.get(relatedQuestionPartId); if (questionPartAttempts != null) { - for (QuestionValidationResponse partAttempt : questionPartAttempts) { + for (LightweightQuestionValidationResponse partAttempt : questionPartAttempts) { questionAnsweredCorrectly = partAttempt.isCorrect(); if (questionAnsweredCorrectly) { break; // exit on first correct attempt @@ -846,27 +899,14 @@ private List extractContentSummaryFromList(final List> fieldsToMatch) { - return this.findSingleResult(fieldsToMatch, null); - } - /** * For use when we expect to only find a single result. * - * By default related content ContentSummary objects will be fully augmented. - * * @param fieldsToMatch * - expects a map of the form fieldname -> list of queries to match - * @param usersQuestionAttempts - * - optional question attempt information to support augmentation of content. - * * @return A Response containing a single conceptPage or containing a SegueErrorResponse. */ - private Response findSingleResult(final Map> fieldsToMatch, - @Nullable final Map>> usersQuestionAttempts) { + private Response findSingleResult(final Map> fieldsToMatch) { try { ResultsWrapper resultList = api.findMatchingContent(this.contentIndex, ContentService.generateDefaultFieldToMatch(fieldsToMatch), null, null); // includes @@ -884,7 +924,7 @@ private Response findSingleResult(final Map> fieldsToMatch, c = resultList.getResults().get(0); } - return Response.ok(this.augmentContentWithRelatedContent(c, usersQuestionAttempts)).build(); + return Response.ok(this.augmentContentWithRelatedContent(c, Maps.newHashMap())).build(); } catch (ContentManagerException e1) { SegueErrorResponse error = new SegueErrorResponse(Status.INTERNAL_SERVER_ERROR, "Error locating the content requested", e1); diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/managers/GameManager.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/managers/GameManager.java index 1f350db77f..ef5dd229eb 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/api/managers/GameManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/api/managers/GameManager.java @@ -323,7 +323,7 @@ public final List getGameboardsWithAttempts(final List gam List gameboardsByIds = this.gameboardPersistenceManager.getGameboardsByIds(gameboardIds); List questionPageIds = gameboardsByIds.stream().map(GameboardDTO::getContents).flatMap(Collection::stream).map(GameboardItem::getId).collect(Collectors.toList()); Map>> userQuestionAttempts = - questionManager.getMatchingQuestionAttempts(user, questionPageIds); + questionManager.getMatchingLightweightQuestionAttempts(user, questionPageIds); for (GameboardDTO gb : gameboardsByIds) { augmentGameboardWithQuestionAttemptInformation(gb, userQuestionAttempts); } @@ -374,7 +374,7 @@ public List getLiteGameboards(final Collection gameboardId * - if there is an error retrieving the content requested. */ public final GameboardDTO getGameboard(final String gameboardId, final AbstractSegueUserDTO user, - final Map>> userQuestionAttempts) + final Map>> userQuestionAttempts) throws SegueDatabaseException, ContentManagerException { @@ -415,7 +415,7 @@ public final GameboardListDTO getUsersGameboards(final RegisteredUserDTO user, @ List questionPageIds = usersGameboards.stream().map(GameboardDTO::getContents).flatMap(Collection::stream).map(GameboardItem::getId).collect(Collectors.toList()); Map>> questionAttemptsFromUser = - questionManager.getMatchingQuestionAttempts(user, questionPageIds); + questionManager.getMatchingLightweightQuestionAttempts(user, questionPageIds); List resultToReturn = Lists.newArrayList(); @@ -648,7 +648,7 @@ public List>> gatherGamePro Map>>> questionAttemptsForAllUsersOfInterest = - questionManager.getMatchingQuestionAttempts(users, questionPageIds); + questionManager.getMatchingLightweightQuestionAttempts(users, questionPageIds); for (RegisteredUserDTO user : users) { List userGameItems = Lists.newArrayList(); @@ -750,7 +750,7 @@ public static List getAllMarkableQuestionPartsDFSOrder(ContentDTO c * - if there is an error retrieving the content requested. */ private GameboardDTO augmentGameboardWithQuestionAttemptInformationAndUserInformation(final GameboardDTO gameboardDTO, - final Map>> questionAttemptsFromUser, + final Map>> questionAttemptsFromUser, final AbstractSegueUserDTO user) throws SegueDatabaseException, ContentManagerException { if (user instanceof RegisteredUserDTO) { diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IQuestionAttemptManager.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IQuestionAttemptManager.java index 8a9a73502b..1b6546635a 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IQuestionAttemptManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IQuestionAttemptManager.java @@ -46,6 +46,18 @@ void registerQuestionAttempt(final Long userId, final String questionPageId, fin Map>> getQuestionAttempts(final Long userId) throws SegueDatabaseException; + /** + * Get a users question attempts on a specific question page. + * + * @param userId - the id of the user to search for. + * @param questionPageId - the id of the question. + * @return the questionAttempts map or an empty map if the user has not yet registered any attempts. + * @throws SegueDatabaseException + * - If there is a database error. + */ + Map>> getQuestionAttempts(Long userId, String questionPageId) + throws SegueDatabaseException; + /** * A method that makes a single database request for a group of users and questions to get all of their attempt * information back. @@ -62,7 +74,7 @@ Map>> getQuestionAttempts(f * - if a database error occurrs */ Map>>> - getQuestionAttemptsByUsersAndQuestionPrefix(List userIds, List questionPage) + getMatchingLightweightQuestionAttempts(List userIds, List questionPage) throws SegueDatabaseException; /** diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/PgQuestionAttempts.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/PgQuestionAttempts.java index 9c4adc1f35..461bfbdc21 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/PgQuestionAttempts.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/PgQuestionAttempts.java @@ -198,24 +198,27 @@ public Map>> getQuestionAtt pst.setLong(1, userId); try (ResultSet results = pst.executeQuery()) { - // Since we go to the effort of sorting the attempts in Postgres, use LinkedHashMap which is ordered: - Map>> mapOfQuestionAttemptsByPage = Maps.newLinkedHashMap(); - - while (results.next()) { - QuestionValidationResponse questionAttempt = objectMapper.readValue( - results.getString("question_attempt"), QuestionValidationResponse.class); - String questionPageId = extractPageIdFromQuestionId(questionAttempt.getQuestionId()); - String questionId = questionAttempt.getQuestionId(); - - Map> attemptsForThisQuestionPage - = mapOfQuestionAttemptsByPage.computeIfAbsent(questionPageId, k -> Maps.newLinkedHashMap()); + return resultsToMapOfValidationResponseByPageId(results); + } + } catch (SQLException e) { + throw new SegueDatabaseException("Postgres exception", e); + } catch (IOException e) { + throw new SegueDatabaseException("Exception while parsing json", e); + } + } - List listOfResponses - = attemptsForThisQuestionPage.computeIfAbsent(questionId, k -> Lists.newArrayList()); + @Override + public Map>> getQuestionAttempts(final Long userId, final String questionPageId) + throws SegueDatabaseException { + String query = "SELECT * FROM question_attempts WHERE user_id = ? AND question_id LIKE ? ORDER BY \"timestamp\" ASC"; + try (Connection conn = database.getDatabaseConnection(); + PreparedStatement pst = conn.prepareStatement(query); + ) { + pst.setLong(1, userId); + pst.setString(2, questionPageId.replace("_", "\\_") + "%"); - listOfResponses.add(questionAttempt); - } - return mapOfQuestionAttemptsByPage; + try (ResultSet results = pst.executeQuery()) { + return resultsToMapOfValidationResponseByPageId(results); } } catch (SQLException e) { throw new SegueDatabaseException("Postgres exception", e); @@ -281,7 +284,7 @@ public Map>>> - getQuestionAttemptsByUsersAndQuestionPrefix(final List userIds, final List allQuestionPageIds) + getMatchingLightweightQuestionAttempts(final List userIds, final List allQuestionPageIds) throws SegueDatabaseException { if (allQuestionPageIds.isEmpty()) { log.error("Attempted to fetch group progress for an empty gameboard."); @@ -470,4 +473,25 @@ private LightweightQuestionValidationResponse resultsToLightweightValidationResp return partialQuestionAttempt; } + + private Map>> resultsToMapOfValidationResponseByPageId(final ResultSet results) throws SQLException, JsonProcessingException { + // Since we go to the effort of sorting the attempts in Postgres, use LinkedHashMap which is ordered: + Map>> mapOfQuestionAttemptsByPage = Maps.newLinkedHashMap(); + + while (results.next()) { + QuestionValidationResponse questionAttempt = objectMapper.readValue( + results.getString("question_attempt"), QuestionValidationResponse.class); + String pageId = extractPageIdFromQuestionId(questionAttempt.getQuestionId()); + String questionId = questionAttempt.getQuestionId(); + + Map> attemptsForThisQuestionPage + = mapOfQuestionAttemptsByPage.computeIfAbsent(pageId, k -> Maps.newLinkedHashMap()); + + List listOfResponses + = attemptsForThisQuestionPage.computeIfAbsent(questionId, k -> Lists.newArrayList()); + + listOfResponses.add(questionAttempt); + } + return mapOfQuestionAttemptsByPage; + } } diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/QuestionManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/QuestionManager.java index f797685b2d..8445437ad3 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/QuestionManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/QuestionManager.java @@ -27,24 +27,17 @@ import org.joda.time.LocalDate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import uk.ac.cam.cl.dtg.isaac.configuration.IsaacApplicationRegister; -import uk.ac.cam.cl.dtg.isaac.dos.TestCase; -import uk.ac.cam.cl.dtg.isaac.dos.TestQuestion; -import uk.ac.cam.cl.dtg.isaac.dto.IsaacItemQuestionDTO; -import uk.ac.cam.cl.dtg.segue.api.Constants; -import uk.ac.cam.cl.dtg.segue.api.Constants.TimeInterval; -import uk.ac.cam.cl.dtg.segue.api.ErrorResponseWrapper; -import uk.ac.cam.cl.dtg.segue.configuration.SegueGuiceConfigurationModule; -import uk.ac.cam.cl.dtg.segue.dao.SegueDatabaseException; -import uk.ac.cam.cl.dtg.segue.dao.content.ContentMapper; import uk.ac.cam.cl.dtg.isaac.dos.LightweightQuestionValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.QuestionValidationResponse; +import uk.ac.cam.cl.dtg.isaac.dos.TestCase; +import uk.ac.cam.cl.dtg.isaac.dos.TestQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; import uk.ac.cam.cl.dtg.isaac.dos.content.ChoiceQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; import uk.ac.cam.cl.dtg.isaac.dos.content.DTOMapping; import uk.ac.cam.cl.dtg.isaac.dos.content.Question; import uk.ac.cam.cl.dtg.isaac.dos.users.Role; +import uk.ac.cam.cl.dtg.isaac.dto.IsaacItemQuestionDTO; import uk.ac.cam.cl.dtg.isaac.dto.QuestionValidationResponseDTO; import uk.ac.cam.cl.dtg.isaac.dto.ResultsWrapper; import uk.ac.cam.cl.dtg.isaac.dto.SegueErrorResponse; @@ -63,6 +56,12 @@ import uk.ac.cam.cl.dtg.isaac.quiz.SpecifiesWith; import uk.ac.cam.cl.dtg.isaac.quiz.ValidatesWith; import uk.ac.cam.cl.dtg.isaac.quiz.ValidatorUnavailableException; +import uk.ac.cam.cl.dtg.segue.api.Constants; +import uk.ac.cam.cl.dtg.segue.api.Constants.*; +import uk.ac.cam.cl.dtg.segue.api.ErrorResponseWrapper; +import uk.ac.cam.cl.dtg.segue.configuration.SegueGuiceConfigurationModule; +import uk.ac.cam.cl.dtg.segue.dao.SegueDatabaseException; +import uk.ac.cam.cl.dtg.segue.dao.content.ContentMapper; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.core.Response; @@ -219,16 +218,14 @@ private ISpecifier locateSpecifier(Class choiceClass) { * - as a map of QuestionPageId to Map of QuestionId to QuestionValidationResponseDO * @return augmented page - the return result is by convenience as the page provided as a parameter will be mutated. */ - public SeguePageDTO augmentQuestionObjects(final SeguePageDTO page, final String userId, + public void augmentQuestionObjects(final SeguePageDTO page, final String userId, final Map>> usersQuestionAttempts) { List questionsToAugment = extractQuestionObjects(page); - this.augmentQuestionObjectWithAttemptInformation(page, questionsToAugment, usersQuestionAttempts); + augmentQuestionObjectWithAttemptInformation(page, questionsToAugment, usersQuestionAttempts); shuffleChoiceQuestionsChoices(userId, questionsToAugment); - - return page; } /** @@ -241,14 +238,13 @@ public SeguePageDTO augmentQuestionObjects(final SeguePageDTO page, final String * - The flattened list of questions which should be augmented. * @param usersQuestionAttempts * - as a map of QuestionPageId to Map of QuestionId to QuestionValidationResponseDO - * @return augmented page - the return result is by convenience as the page provided as a parameter will be mutated. */ - private SeguePageDTO augmentQuestionObjectWithAttemptInformation(final SeguePageDTO page, + private void augmentQuestionObjectWithAttemptInformation(final SeguePageDTO page, final List questionsToAugment, final Map>> usersQuestionAttempts) { if (null == usersQuestionAttempts) { - return page; + return; } for (QuestionDTO question : questionsToAugment) { @@ -280,7 +276,6 @@ private SeguePageDTO augmentQuestionObjectWithAttemptInformation(final SeguePage question.setBestAttempt(this.convertQuestionValidationResponseToDTO(bestAnswer)); } - return page; } @@ -334,7 +329,7 @@ public void recordQuestionAttempt(final AbstractSegueUserDTO user, log.error("Unexpected user type. Unable to record question response"); } } - + /** Test a question of a particular type against a series of test cases **/ public List testQuestion(final String questionType, final TestQuestion testDefinition) throws BadRequestException, ValidatorUnavailableException { @@ -394,6 +389,22 @@ public Map>> getQuestionAtt return this.questionAttemptPersistenceManager.getAnonymousQuestionAttempts(anonymousUser.getSessionId()); } } + + /** + * Return all the attempts of a user at a specified page ID prefix. + * + * The map returned by this method is in the same format as {@link #getQuestionAttemptsByUser(AbstractSegueUserDTO)} + * for compatibility. + * + * @param user the user of interest + * @param questionPageId the page ID prefix of interest + * @return a map QuestionPageID -> (QuestionID -> List[QuestionValidationResponse]). + * @throws SegueDatabaseException on database error + */ + public Map>> getQuestionAttemptsByUserForQuestion( + final RegisteredUserDTO user, final String questionPageId) throws SegueDatabaseException { + return questionAttemptPersistenceManager.getQuestionAttempts(user.getId(), questionPageId); + } /** * @param users who we are interested in. @@ -401,31 +412,30 @@ public Map>> getQuestionAtt * @return a map of user id to question page id to question_id to list of attempts. * @throws SegueDatabaseException if there is a database error. */ - public Map>>> getMatchingQuestionAttempts( + public Map>>> getMatchingLightweightQuestionAttempts( final List users, final List questionPageIds) throws SegueDatabaseException { List userIds = Lists.newArrayList(); for (RegisteredUserDTO user : users) { userIds.add(user.getId()); } - return this.questionAttemptPersistenceManager.getQuestionAttemptsByUsersAndQuestionPrefix(userIds, - questionPageIds); + return this.questionAttemptPersistenceManager.getMatchingLightweightQuestionAttempts(userIds, questionPageIds); } /** * Helper method for attempts from a single user. * - * @see #getMatchingQuestionAttempts(List, List) + * @see #getMatchingLightweightQuestionAttempts(List, List) * * @param user who we are interested in. * @param questionPageIds we want to look up. * @return a map of user id to question page id to question_id to list of attempts. * @throws SegueDatabaseException if there is a database error. */ - public Map>> getMatchingQuestionAttempts( + public Map>> getMatchingLightweightQuestionAttempts( final RegisteredUserDTO user, final List questionPageIds) throws SegueDatabaseException { - return this.getMatchingQuestionAttempts(Collections.singletonList(user), questionPageIds).get(user.getId()); + return this.getMatchingLightweightQuestionAttempts(Collections.singletonList(user), questionPageIds).get(user.getId()); } /**