uk.ac.cam.cl.dtg.segue.api.managers.QuestionManager.java Source code

Java tutorial

Introduction

Here is the source code for uk.ac.cam.cl.dtg.segue.api.managers.QuestionManager.java

Source

/**
 * Copyright 2014 Stephen Cummins
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 *
 * You may obtain a copy of the License at
 *       http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package uk.ac.cam.cl.dtg.segue.api.managers;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Random;

import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;

import com.google.inject.Guice;
import com.google.inject.Injector;
import org.apache.commons.lang3.Validate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.api.client.util.Lists;
import com.google.api.client.util.Maps;
import com.google.inject.Inject;

import uk.ac.cam.cl.dtg.isaac.configuration.IsaacApplicationRegister;
import uk.ac.cam.cl.dtg.isaac.configuration.IsaacGuiceConfigurationModule;
import uk.ac.cam.cl.dtg.segue.api.Constants;
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.segue.dos.QuestionValidationResponse;
import uk.ac.cam.cl.dtg.segue.dos.content.Choice;
import uk.ac.cam.cl.dtg.segue.dos.content.DTOMapping;
import uk.ac.cam.cl.dtg.segue.dos.content.Question;
import uk.ac.cam.cl.dtg.segue.dto.QuestionValidationResponseDTO;
import uk.ac.cam.cl.dtg.segue.dto.SegueErrorResponse;
import uk.ac.cam.cl.dtg.segue.dto.content.ChoiceDTO;
import uk.ac.cam.cl.dtg.segue.dto.content.ChoiceQuestionDTO;
import uk.ac.cam.cl.dtg.segue.dto.content.ContentBaseDTO;
import uk.ac.cam.cl.dtg.segue.dto.content.ContentDTO;
import uk.ac.cam.cl.dtg.segue.dto.content.QuestionDTO;
import uk.ac.cam.cl.dtg.segue.dto.content.SeguePageDTO;
import uk.ac.cam.cl.dtg.segue.dto.users.AbstractSegueUserDTO;
import uk.ac.cam.cl.dtg.segue.dto.users.AnonymousUserDTO;
import uk.ac.cam.cl.dtg.segue.dto.users.RegisteredUserDTO;
import uk.ac.cam.cl.dtg.segue.quiz.*;

/**
 * This class is responsible for validating correct answers using the ValidatesWith annotation when it is applied on to
 * Questions.
 * 
 * It is also responsible for orchestrating question attempt persistence.
 * 
 */
public class QuestionManager {
    private static final Logger log = LoggerFactory.getLogger(QuestionManager.class);

    private final ContentMapper mapper;
    private final IQuestionAttemptManager questionAttemptPersistenceManager;

    /**
     * Create a default Question manager object.
     * 
     * @param mapper
     *            - an auto mapper to allow us to convert to and from QuestionValidationResponseDOs and DTOs.
     * @param questionPersistenceManager - for question attempt persistence.
     */
    @Inject
    public QuestionManager(final ContentMapper mapper, final IQuestionAttemptManager questionPersistenceManager) {
        this.mapper = mapper;
        this.questionAttemptPersistenceManager = questionPersistenceManager;
    }

    /**
     * Validate client answer to recorded answer.
     * 
     * @param question
     *            The question to which the answer must be validated against.
     * @param answers
     *            from the client as a list used for comparison purposes.
     * @return A response containing a QuestionValidationResponse object.
     */
    public final Response validateAnswer(final Question question, final List<ChoiceDTO> answers) {
        IValidator validator = locateValidator(question.getClass());

        if (null == validator) {
            log.error("Unable to locate a valid validator for this question " + question.getId());
            return Response.serverError()
                    .entity("Unable to detect question validator for " + "this object. Unable to verify answer")
                    .build();
        }

        if (validator instanceof IMultiFieldValidator) {
            IMultiFieldValidator multiFieldValidator = (IMultiFieldValidator) validator;
            // we need to call the multifield validator instead.
            return Response.ok(multiFieldValidator.validateMultiFieldQuestionResponses(question, answers)).build();
        } else { // use the standard IValidator

            // ok so we are expecting there just to be one choice?
            if (answers.isEmpty() || answers.size() > 1) {
                log.debug("We only expected one answer for this question...");
                SegueErrorResponse error = new SegueErrorResponse(Status.BAD_REQUEST,
                        "We only expected one answer for this question (id " + question.getId()
                                + ") and we were given a list.");
                return error.toResponse();
            }

            Choice answerFromUser = mapper.getAutoMapper().map(answers.get(0), Choice.class);
            QuestionValidationResponse validateQuestionResponse = null;
            try {
                validateQuestionResponse = validator.validateQuestionResponse(question, answerFromUser);
            } catch (ValidatorUnavailableException e) {
                return SegueErrorResponse
                        .getServiceUnavailableResponse(e.getClass().getSimpleName() + ":" + e.getMessage());
            }

            return Response
                    .ok(mapper.getAutoMapper().map(validateQuestionResponse, QuestionValidationResponseDTO.class))
                    .build();
        }
    }

    /**
     * Reflection to try and determine the associated validator for the question being answered.
     * 
     * @param questionType
     *            - the type of question being answered.
     * @return a Validator
     */
    @SuppressWarnings("unchecked")
    private static IValidator locateValidator(final Class<? extends Question> questionType) {
        // check we haven't gone too high up the superclass tree
        if (!Question.class.isAssignableFrom(questionType)) {
            return null;
        }

        // Does this class have the correct annotation?
        if (questionType.isAnnotationPresent(ValidatesWith.class)) {

            log.debug("Validator for question validation found. Using : "
                    + questionType.getAnnotation(ValidatesWith.class).value());
            Injector injector = IsaacApplicationRegister.injector;
            return injector.getInstance(questionType.getAnnotation(ValidatesWith.class).value());

        } else if (questionType.equals(Question.class)) {
            // so if we get here then we haven't found a ValidatesWith class, so
            // we should just give up and return null.
            return null;
        }

        // we will continue our search of the superclasses for the annotation
        return locateValidator((Class<? extends Question>) questionType.getSuperclass());
    }

    /**
     * This method will ensure any user question attempt information available is used to augment this question object.
     * 
     * It will also ensure that any personalisation of questions is affected (e.g. randomised multichoice elements).
     * 
     * @param page
     *            - to augment - this object may be mutated as a result of this method. i.e BestAttempt field set on
     *            question DTOs.
     * @param userId
     *            - to allow us to provide a per user experience of question configuration (random seed).
     * @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.
     */
    public SeguePageDTO augmentQuestionObjects(final SeguePageDTO page, final String userId,
            final Map<String, Map<String, List<QuestionValidationResponse>>> usersQuestionAttempts) {

        List<QuestionDTO> questionsToAugment = QuestionManager.extractQuestionObjectsRecursively(page,
                new ArrayList<QuestionDTO>());

        this.augmentQuestionObjectWithAttemptInformation(page, questionsToAugment, usersQuestionAttempts);

        shuffleChoiceQuestionsChoices(userId, questionsToAugment);

        return page;
    }

    /**
     * Modify a question objects in a page such that it contains bestAttempt information if we can provide it.
     * 
     * @param page
     *            - the page this object may be mutated as a result of this method. i.e BestAttempt field set on
     *            question DTOs.
     * @param questionsToAugment
     *            - 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.
     */
    public SeguePageDTO augmentQuestionObjectWithAttemptInformation(final SeguePageDTO page,
            final List<QuestionDTO> questionsToAugment,
            final Map<String, Map<String, List<QuestionValidationResponse>>> usersQuestionAttempts) {

        if (null == usersQuestionAttempts) {
            return page;
        }

        for (QuestionDTO question : questionsToAugment) {
            if (!usersQuestionAttempts.containsKey(page.getId())) {
                continue;
            }
            if (usersQuestionAttempts.get(page.getId()).get(question.getId()) == null) {
                continue;
            }

            QuestionValidationResponse bestAnswer = null;
            List<QuestionValidationResponse> questionAttempts = usersQuestionAttempts.get(page.getId())
                    .get(question.getId());

            // iterate in reverse order to try and find the correct answer.
            for (int i = questionAttempts.size() - 1; i >= 0; i--) {
                QuestionValidationResponse currentResponse = questionAttempts.get(i);

                if (bestAnswer == null) {
                    bestAnswer = currentResponse;
                }

                if (questionAttempts.get(i).isCorrect() != null && questionAttempts.get(i).isCorrect()) {
                    bestAnswer = currentResponse;
                    break;
                }
            }

            question.setBestAttempt(this.convertQuestionValidationResponseToDTO(bestAnswer));

        }
        return page;
    }

    /**
     * Converts a QuestionValidationResponse into a QuestionValidationResponseDTO.
     * 
     * @param questionValidationResponse
     *            - the thing to convert.
     * @return QuestionValidationResponseDTO
     */
    @SuppressWarnings("unchecked")
    public QuestionValidationResponseDTO convertQuestionValidationResponseToDTO(
            final QuestionValidationResponse questionValidationResponse) {
        // Determine what kind of ValidationResponse to turn it in to.
        DTOMapping dtoMapping = questionValidationResponse.getClass().getAnnotation(DTOMapping.class);
        if (QuestionValidationResponseDTO.class.isAssignableFrom(dtoMapping.value())) {
            return mapper.getAutoMapper().map(questionValidationResponse,
                    (Class<? extends QuestionValidationResponseDTO>) dtoMapping.value());
        } else {
            log.error("Unable to set best attempt as we cannot match the answer to a DTO type.");
            throw new ClassCastException("Unable to cast " + questionValidationResponse.getClass()
                    + " to a QuestionValidationResponse.");
        }
    }

    /**
     * Record a question attempt for a given user.
     * @param user - user that made the attempt.
     * @param questionResponse - the outcome of the attempt to be persisted.
     */
    public void recordQuestionAttempt(final AbstractSegueUserDTO user,
            final QuestionValidationResponseDTO questionResponse) {
        QuestionValidationResponse questionResponseDO = this.mapper.getAutoMapper().map(questionResponse,
                QuestionValidationResponse.class);

        // We are operating with the convention that the first component of
        // an id is the question page
        // and that the id separator is |
        String[] questionPageId = questionResponse.getQuestionId().split(Constants.ESCAPED_ID_SEPARATOR);
        try {
            if (user instanceof RegisteredUserDTO) {
                RegisteredUserDTO registeredUser = (RegisteredUserDTO) user;

                this.questionAttemptPersistenceManager.registerQuestionAttempt(registeredUser.getId(),
                        questionPageId[0], questionResponse.getQuestionId(), questionResponseDO);
                log.debug("Question information recorded for user: " + registeredUser.getId());

            } else if (user instanceof AnonymousUserDTO) {
                AnonymousUserDTO anonymousUserDTO = (AnonymousUserDTO) user;

                this.questionAttemptPersistenceManager.registerAnonymousQuestionAttempt(
                        anonymousUserDTO.getSessionId(), questionPageId[0], questionResponse.getQuestionId(),
                        questionResponseDO);
            } else {
                log.error("Unexpected user type. Unable to record question response");
            }
        } catch (SegueDatabaseException e) {
            log.error("Unable to to record question attempt.", e);
        }
    }

    /**
     * getQuestionAttemptsByUser. This method will return all of the question attempts for a given user as a map.
     * 
     * @param user
     *            - with the session information included.
     * @return map of question attempts (QuestionPageId -> QuestionID -> [QuestionValidationResponse] or an empty map.
     * @throws SegueDatabaseException
     *             - if there is a database error.
     */
    public Map<String, Map<String, List<QuestionValidationResponse>>> getQuestionAttemptsByUser(
            final AbstractSegueUserDTO user) throws SegueDatabaseException {
        Validate.notNull(user);

        if (user instanceof RegisteredUserDTO) {
            RegisteredUserDTO registeredUser = (RegisteredUserDTO) user;

            return this.questionAttemptPersistenceManager.getQuestionAttempts(registeredUser.getId());
        } else {
            AnonymousUserDTO anonymousUser = (AnonymousUserDTO) user;
            // since no user is logged in assume that we want to use any anonymous attempts
            return this.questionAttemptPersistenceManager
                    .getAnonymousQuestionAttempts(anonymousUser.getSessionId());
        }
    }

    /**
     * @param users 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<Long, Map<String, Map<String, List<QuestionValidationResponse>>>> getMatchingQuestionAttempts(
            final List<RegisteredUserDTO> users, final List<String> questionPageIds) throws SegueDatabaseException {
        List<Long> userIds = Lists.newArrayList();
        for (RegisteredUserDTO user : users) {
            userIds.add(user.getId());
        }

        return this.questionAttemptPersistenceManager.getQuestionAttemptsByUsersAndQuestionPrefix(userIds,
                questionPageIds);
    }

    /**
     * Convenient method for requesting only question attempts we are interested in.
     * @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<String, Map<String, List<QuestionValidationResponse>>> getMatchingQuestionAttempts(
            final AbstractSegueUserDTO user, final List<String> questionPageIds) throws SegueDatabaseException {
        Validate.notNull(user);

        if (user instanceof RegisteredUserDTO) {
            RegisteredUserDTO ru = (RegisteredUserDTO) user;
            List<Long> userIds = Arrays.asList(ru.getId());
            Map<String, Map<String, List<QuestionValidationResponse>>> mapToReturn = this.questionAttemptPersistenceManager
                    .getQuestionAttemptsByUsersAndQuestionPrefix(userIds, questionPageIds).get(user);

            if (mapToReturn == null) {
                return Maps.newHashMap();
            }

            return mapToReturn;
        } else {
            AnonymousUserDTO anonymousUser = (AnonymousUserDTO) user;
            // since no user is logged in assume that we want to use any anonymous attempts
            return this.questionAttemptPersistenceManager
                    .getAnonymousQuestionAttempts(anonymousUser.getSessionId());
        }
    }

    /**
     * mergeAnonymousQuestionAttemptsIntoRegisteredUser.
     * 
     * @param anonymousUser
     *            to look up question attempts
     * @param registeredUser
     *            to merge into.
     * @throws SegueDatabaseException
     *             - if something goes wrong.
     */
    public void mergeAnonymousQuestionAttemptsIntoRegisteredUser(final AnonymousUserDTO anonymousUser,
            final RegisteredUserDTO registeredUser) throws SegueDatabaseException {
        this.questionAttemptPersistenceManager.mergeAnonymousQuestionInformationWithRegisteredUserRecord(
                anonymousUser.getSessionId(), registeredUser.getId());
    }

    /**
     * Extract all of the questionObjectsRecursively.
     * 
     * @param toExtract
     *            - The contentDTO which may have question objects as children.
     * @param result
     *            - The initially empty List which will be mutated to contain references to all of the question objects.
     * @return The modified result array.
     */
    private static List<QuestionDTO> extractQuestionObjectsRecursively(final ContentDTO toExtract,
            final List<QuestionDTO> result) {
        if (toExtract instanceof QuestionDTO) {
            // we found a question so add it to the list.
            result.add((QuestionDTO) toExtract);
        }

        if (toExtract.getChildren() != null) {
            // Go through each child in the content object.
            for (ContentBaseDTO child : toExtract.getChildren()) {
                if (child instanceof ContentDTO) {
                    // if it is not a question but it can have children then
                    // continue recursing.
                    ContentDTO childContent = (ContentDTO) child;
                    if (childContent.getChildren() != null) {
                        QuestionManager.extractQuestionObjectsRecursively(childContent, result);
                    }
                }
            }
        }

        return result;
    }

    /**
     * This is a helper method that will shuffle multiple choice questions based on a user specified seed.
     * 
     * @param seed
     *            - Randomness
     * @param questions
     *            - questions which may have choices to shuffle.
     */
    private void shuffleChoiceQuestionsChoices(final String seed, final List<QuestionDTO> questions) {
        if (null == questions) {
            return;
        }

        // shuffle all choices based on the seed provided, augmented by individual question ID.
        for (QuestionDTO question : questions) {
            if (question instanceof ChoiceQuestionDTO) {
                Boolean randomiseChoices = ((ChoiceQuestionDTO) question).getRandomiseChoices();
                if (randomiseChoices == null || randomiseChoices) { // Default to randomised if not set.
                    ChoiceQuestionDTO choiceQuestion = (ChoiceQuestionDTO) question;
                    String qSeed = seed + choiceQuestion.getId();
                    if (choiceQuestion.getChoices() != null) {
                        Collections.shuffle(choiceQuestion.getChoices(), new Random(qSeed.hashCode()));
                    }
                }
            }
        }
    }
}