Skip to content

Conversation

@barna-isaac
Copy link
Contributor

@barna-isaac barna-isaac commented Nov 28, 2025

This adds a validator for the DnD question type. You can test that this works using the following question: http://localhost:8004/questions/c4520e54-4777-41b9-9967-2893cea96a62. Although the front-end does not yet
implement showing item-level feedback, you can see that the response also contains the necessary information for this.

The feature

Here's how it works. To create a drag-and-drop question, the content developer must specify the type as drag and drop, declare drop zones in either text or figure content, and define the drag-and-drop items. Then, they need to specify at least one correct answer, which is a pairing of drop zones and items. They may also define incorrect answers. This is only necessary if they'd like to show specific feedback to the student about why a submission is wrong.

Requirements for answers:

  • a correct answer must contain items for all drop zones. The answer is only recognised as correct when all drop zones contain the correct item. If any correct answer only specifies a subset of drop zones, the question is invalid, and all answers will mark as incorrect (along with a message being shown about the question being invalid).
  • An incorrect answer must contain more than 0 items, and may contain at most as many items as there are drop zones. There's no requirement to use all drop zones for invalid answers.
  • No answer may contain the same drop zone more than once, and all drop zones in an answer must correspond to a drop zone that's declared in the content.
  • Items may be used more than once, but the UI will only allow re-using them if the "Allow items to be used more than once" flag is also checked. All items in an answer must correspond to an item declared on the question.

Differences between DnD and Cloze questions.

  • DnD questions can have drop zones on figures, whereas cloze questions cannot.
  • Cloze questions allow subset matching for both correct and incorrect answers, and need explicit placeholders. DnD questions don't have a setting to allow subset matching, and must always use all drop zones for correct answers. For incorrect answers, they always use subset matching, and don't need any placeholders. When all items are set, the subset matching behaviour is identical to the full match behaviour that's used on correct answers.

The implementation

There are changes to 13 non-test and 7 test files.

Code changes (15 files)

  • new and modified DO's and DTO's and mappers (8 files): DndChoice, DndChoiceDTO, DndValidationResponse, DndValidationResponseDTO. The validator for IsaacDndQuestion needed a minor adjustment, and the change to ItemChoice is stylistic. QuestionValidationResponseDeserializer needed changing so we correctly preload DnD questions with the last attempt when one is available. QuestionValidationMapper supports the new DndValidationResponse type.
  • IsaacDndValidator and ValidationUtils (2 files). This is where validation is mainly implemented. I've modelled the behavior on IsaacClozeValidator. Compared to it, the most important changes are:
    • stricter validation rules. Where IsaacClozeValidator tries to fix invalid questions by, for example, ignoring any answers that are invalid (eg: the item id is missing), IsaacDndValidator refuses to validate invalid questions (it says the answer was incorrect, and explains that the question is invalid). I'm hoping that by being stricter with validation, we'll save ourselves some trouble later, by only needing to support a more limited number of question states for this type. Invalid DnD questions show up as non-critical content errors. Even when invalid, they're ETL'ed.
    • re-usable validation logic. The validator's primary functionality is to validate and mark a student answer, but before it does this, it also decides whether the question is valid in the first place. I've separated the question validation logic from the marking, so the same question validation rules can be re-used during ETL, for producing content errors.
  • SegueGuiceConfigurationModule: a new setInjector method, used for mocking the injector from integration tests. It would be better if we didn't have to mock at all from integration tests, but I didn't have the strength to make this change.
  • ElasticSearchIndexer: changed a few method visisbilites to public so we can load content objects during integration tests
  • ContentIndexer implements the ETL process. This has been updated so invalid questions show up as content errors. This performs the same question validation as the marking endpoint.

Test changes

  • a new integration test for QuestionsFacade. I made this because I wanted to explore how Jackson reacts to requests with invalid JSON, to get a better idea of what still needed to be checked manually in the validator. I needed to extend AbstractIntegrationTest so it can provide the new dependencies this required.
  • some new asserts in IsaacIntegrationTestWithRest
  • a new TestAppender helper, which I used when testing logging
  • new tests for IsaacDndValidator, and extended tests for ContentIndexer. IsaacClozeValidator also got a new test, despite the functionality in the class not getting modified (I wanted to be sure it worked how I expected it to).

Logging

When the /answer endpoint encounters an invalid question, it logs this so we are aware there is an invalid question on the platform. However, it doesn't create logs for attempts that are generated by users. The ETL process also doesn't log invalid questions, as these are just recorded as content errors.

Security

After reading through the code of several validators, I concluded that protecting against SQL injuctions doesn't happen here. I did add stricter validation, which should also make the endpoint more secure, but didn't add any additional protections against SQL injection to the DnD validator (eg: by sanitising the submitted answer). Although the validator itself never touches the database, the validation response it produces does get recorded, and the response includes the answer, which is user-provided. If the reviewer feels I should have done this as part of this PR, I'm happy to do it.

Performance

Possible improvements

  • ETL only records the first encountered error. In many cases, this makes perfect sense (eg: if the question has no answers at all, we don't show extra errors about it also not having any correct answers), but there are some cases where it'd be helpful to show more than one problem at once on the Content Errors page. For example, if an answer had a problem with both a drop zone ref and an item ref, it'd be useful to show both. As things are, this only shows the first problem, and the second problem is shown only after the first one's been resolved.

Please don't be afraid to request changes, I'm sure we can find a way to get them done quickly^^.


Pull Request Check List

  • Unit Tests & Regression Tests Added (Optional)
  • Removed Unnecessary Logs/System.Outs/Comments/TODOs
  • Added enough Logging to monitor expected behaviour change
  • Security - Injection - everything run by an interpreter (SQL, OS...) is either validated or escaped
  • Security - Data Exposure - PII is not stored or sent unencrypted
  • Security - Data Exposure - Test any altered or created endpoints using swagger
  • Security - Access Control - Check authorisation on every new endpoint
  • Security - New dependency - configured sensibly not relying on defaults
  • Security - New dependency - Searched for any know vulnerabilities
  • Security - New dependency - Signed up team to mailing list
  • Security - New dependency - Added to dependency list
  • DB schema changes - postgres-rutherford-create-script updated
  • DB schema changes - upgrade script created matching create script
  • Updated Release Procedure & Documentation (& Considered Implications to Previous Versions)
  • Peer-Reviewed

@barna-isaac barna-isaac changed the base branch from main to hackathon/dnd-images November 28, 2025 17:30
@barna-isaac barna-isaac changed the base branch from hackathon/dnd-images to main November 28, 2025 17:36
@barna-isaac barna-isaac marked this pull request as draft November 28, 2025 17:37
@codecov
Copy link

codecov bot commented Dec 1, 2025

Codecov Report

❌ Patch coverage is 93.37017% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.10%. Comparing base (e375ca8) to head (c6b7d7d).
⚠️ Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
...uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java 94.16% 4 Missing and 3 partials ⚠️
...cam/cl/dtg/isaac/dto/DndValidationResponseDTO.java 62.50% 3 Missing ⚠️
.../users/QuestionValidationResponseDeserializer.java 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #738      +/-   ##
==========================================
+ Coverage   36.84%   38.10%   +1.25%     
==========================================
  Files         540      543       +3     
  Lines       23642    23821     +179     
  Branches     2825     2848      +23     
==========================================
+ Hits         8711     9077     +366     
+ Misses      14056    13853     -203     
- Partials      875      891      +16     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

this is needed when a previous question attempt is available for a
question
@barna-isaac barna-isaac marked this pull request as ready for review December 18, 2025 17:32
@jsharkey13 jsharkey13 self-requested a review January 6, 2026 11:21
Copy link
Member

@jsharkey13 jsharkey13 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some initial thoughts.

One that is not addressed everywhere inline is the use of var. We have never used var before (because the codebase started in Java 6, and was Java 8 for most of its life). I would much prefer explicit types everywhere, since they are almost always easier to read (var can't even help us with the awful Map<String, ? extends Map<String, ? extends List<? extends LightweightQuestionValidationResponse>>> type we use a lot, since you obviously cannot use it in method signatures; that's one case where hiding this mess of a type would be useful and it's no help!). There's at least one comment on var with a suggestion from a Java style guide I found useful - since I hadn't met var before this PR.


/**
* Choice for Dnd Questions, containing a list of DndItems.
* Choice for Dnd Questions, containing a list of DnDItems.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this intentional?

*/
@DTOMapping(DndChoiceDTO.class)
@JsonContentType("dndChoice")
public class DndChoice extends ItemChoice {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason for switching this hierarchy, given that DndItem still extends Item and we did not end up switching to a Map here as I had hoped?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(The DTO should have a parallel hierarchy, and matches this right now, but needs to if anything changes, too).

return new DndValidationResponse(question.getId(), answer, isCorrect, dropZonesCorrect, feedback, new Date());
}

private Optional<String> validate(final Question question, final Choice answer) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is checking whether the question is valid, but I think since we use "validator" to mean "marker" exclusively across the codebase, we need another name here. Even something like getProblemsWithQuestionOrSubmission(...) is way clearer about what the purpose of this method is.

}

/** Validate a set of rules. Each rule takes two arguments. `.add` some rules, then `.check` whether they held.*/
public static class BiRuleValidator<T, U> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These methods are again about checking whether content is valid, which is not what we use "validator" to mean across the codebase. They do not belong in isaac.quiz, certainly.

For now, maybe making them live in a new package called isaac.util, in ContentValidatorUtils to suchlike? I think with extra context, we can keep "validator" in the name and maybe we'll refactor the validators to be called markers at some point?

return injector;
}

public static void setInjector(final Injector newInjector) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is dangerous, and I don't like it. If we add it, we should add it @Deprecated immediately, so that nobody thinks they can use it. It also needs either a name or clear JavaDoc indicating the dangers of using it, i.e. that it will pollute the global creation of new classes going forwards.
If this is used in a test, all subsequent tests may be affected.

Comment on lines +79 to +83
var feedback = (Content) matchedAnswer.map(Choice::getExplanation)
.or(() -> !isCorrect && answer.getItems().size() < closestCorrect.map(c -> c.getItems().size()).orElse(0)
? Optional.of(new Content(FEEDBACK_ANSWER_NOT_ENOUGH)) : Optional.empty())
.or(() -> !isCorrect ? Optional.ofNullable(question.getDefaultFeedback()) : Optional.empty())
.orElse(null);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really hard to read. It is a complete mess of nested streams and Optionals, I think, but I can't actually tell. Maybe it's actually all Optionals?
This would be so much easier to read with some intermediate variables, and maybe is a use-case for var (though I don't think var vs boolean is much of a saving!).

@SuppressWarnings("checkstyle:MissingJavadocType")
public static class QuestionHelpers {
public static Stream<DndChoice> getChoices(final IsaacDndQuestion question) {
return question.getChoices().stream().map(c -> (DndChoice) c);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this not an unchecked cast? I know that as written, your code does some checking of the types before this is ever called, but that's not enforced by anything and it's not at all obvious it is happening.

var feedback = (Content) matchedAnswer.map(Choice::getExplanation)
.or(() -> !isCorrect && answer.getItems().size() < closestCorrect.map(c -> c.getItems().size()).orElse(0)
? Optional.of(new Content(FEEDBACK_ANSWER_NOT_ENOUGH)) : Optional.empty())
.or(() -> !isCorrect ? Optional.ofNullable(question.getDefaultFeedback()) : Optional.empty())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we use default feedback even if the answer is correct, provided there is no other feedback already being given to the user.
I.e. if the matched correct choice has feedback, we use that (check with feedbackIsNullOrEmpty because every choice the editor creates has an empty explanation but that's not the same as having feedback), otherwise we still use default feedback.


private static class ChoiceHelpers {
public static boolean matches(final DndChoice lhs, final DndChoice rhs) {
return lhs.getItems().stream().allMatch(lhsItem -> dropZoneEql(rhs, lhsItem));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this NullPointerException if getItems() is null? All of these helpers need null guards, no? Whereas if they were inlined we could be sure we'd already done the null check?

.collect(Collectors.toList());
}

private static Stream<String> getDropZonesFromContent(final ContentBase content) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like the fact that we use this method at runtime on every single question attempt. I can see why it may be worth having for the content errors page, and that you want to make the feedback given to the user be consistent with this - but it should be unnecessary almost all of the time.
I wouldn't object to doing a runtime check for DEV mode and only validating it then (much like we do elsewhere already- though this uses a lookup from properties, we sometimes cache it for faster lookup), but ETL runs in PROD so using the same method for ETL and runtime checking will be a pain.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one of my biggest complaints about the "small functions abound" style; it's really hard to reason about what we're doing when, because this method is so far down a convoluted call stack from the main validateQuestionResponse it is hard to tell as a human reading the code. IntelliJ is clever enough to detect it at least.

Copy link
Member

@jsharkey13 jsharkey13 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some initial thoughts.

One that is not addressed everywhere inline is the use of var. We have never used var before (because the codebase started in Java 6, and was Java 8 for most of its life). I would much prefer explicit types everywhere, since they are almost always easier to read (var can't even help us with the awful Map<String, ? extends Map<String, ? extends List<? extends LightweightQuestionValidationResponse>>> type we use a lot, since you obviously cannot use it in method signatures; that's one case where hiding this mess of a type would be useful and it's no help!). There's at least one comment on var with a suggestion from a Java style guide I found useful - since I hadn't met var before this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants