Skip to content

Separate questions and questionnaires#2513

Merged
niklasmohrin merged 7 commits intoe-valuation:mainfrom
Kakadus:combined-questions
Mar 2, 2026
Merged

Separate questions and questionnaires#2513
niklasmohrin merged 7 commits intoe-valuation:mainfrom
Kakadus:combined-questions

Conversation

@Kakadus
Copy link
Copy Markdown
Collaborator

@Kakadus Kakadus commented Sep 24, 2025

close #457

featuring...
☑️ ~11 year old issue
☑️ >24k changed lines (nearly all in test data / tests though)
☑️ touch every file in the git tree
☑️ some non-trivial migrations

I await your comments or questions of questions with questions :)

@Kakadus Kakadus force-pushed the combined-questions branch 2 times, most recently from f5218c6 to 7b87d12 Compare September 25, 2025 06:45
@Kakadus

This comment was marked as outdated.

@Kakadus Kakadus force-pushed the combined-questions branch 2 times, most recently from 91442b6 to 6602a7a Compare September 25, 2025 10:00
@niklasmohrin niklasmohrin self-requested a review October 14, 2025 20:59
Copy link
Copy Markdown
Member

@niklasmohrin niklasmohrin left a comment

Choose a reason for hiding this comment

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

Oh wow, this is quite the undertaking. I did not look too close at stuff yet, but here are some initial thoughts that I want to clear out first.

There are a number of changes in this PR that we could make independently (typing, refactors, added assertions), can you PR them in separately so that we clear some of the diff here? :D

Comment thread evap/evaluation/models.py Outdated
Comment thread evap/staff/tests/test_forms.py
Comment thread evap/staff/tests/test_live.py Outdated
Comment thread tools/check_consistent_question_texts.py Outdated
Comment thread evap/student/views.py Outdated
@Kakadus Kakadus force-pushed the combined-questions branch 3 times, most recently from 6f80f10 to 61bec79 Compare October 20, 2025 19:03
janno42 pushed a commit that referenced this pull request Oct 25, 2025
extracted some independent changes from #2513 for easier reviewability

1. pylint rule is covered in ruff and needed for out-of-line imports in
temporary tool
1. question types helped me a lot to avoid question <->
questionassignment confusions
1. unrelated typo fix
1. fixed odd questionnaire type handling in text answer exporter (+
added type assertion) found while tracing question usage
@Kakadus Kakadus force-pushed the combined-questions branch 3 times, most recently from 0c0bfd3 to 9356904 Compare October 27, 2025 15:40
@Kakadus Kakadus force-pushed the combined-questions branch from 9356904 to 94ca1d3 Compare October 27, 2025 16:55
@Kakadus

This comment was marked as outdated.

@Kakadus

This comment was marked as outdated.

Comment thread evap/staff/forms.py Outdated
@Kakadus Kakadus requested review from niklasmohrin and removed request for niklasmohrin October 27, 2025 17:10
@Kakadus Kakadus force-pushed the combined-questions branch from 5f4e50d to 258bc8f Compare October 27, 2025 18:24
Copy link
Copy Markdown
Member

@niklasmohrin niklasmohrin left a comment

Choose a reason for hiding this comment

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

didn't get far again, but here are some more thoughts :D

Comment thread evap/evaluation/models.py
Comment thread evap/results/tools.py
Comment thread evap/staff/templates/staff_questionnaire_form.html Outdated
@Kakadus Kakadus force-pushed the combined-questions branch 2 times, most recently from b324a0a to fbb8163 Compare November 4, 2025 16:35
Copy link
Copy Markdown
Member

@richardebeling richardebeling left a comment

Choose a reason for hiding this comment

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

surprisingly little changes to production logic. Nice! I will have to look at migrations, tests, and test_data separately, but let's first agree on the actual implementation

Comment thread evap/results/tools.py Outdated
Copy link
Copy Markdown
Member

@niklasmohrin niklasmohrin left a comment

Choose a reason for hiding this comment

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

crossed off some more files :) I agree with Richard, maybe we can actually get this PR through quicker than I thought and before #2450. Let's see how we progress on the frontend, I think the backend is pretty solid 👍

Comment thread evap/evaluation/tests/test_commands.py Outdated
Comment thread evap/evaluation/tests/test_views.py Outdated
@Kakadus Kakadus force-pushed the combined-questions branch from 4c26801 to 9e45a67 Compare January 23, 2026 16:26
Copy link
Copy Markdown
Member

@richardebeling richardebeling left a comment

Choose a reason for hiding this comment

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

Almost time to break production! 😅

Comment thread evap/evaluation/models.py Outdated
Comment thread evap/staff/tests/test_views.py Outdated
Comment thread evap/staff/tests/test_views.py Outdated
Comment thread evap/staff/tests/test_views.py
Comment thread evap/evaluation/migrations/0161_temporary_newquestion_relation.py
@Kakadus Kakadus force-pushed the combined-questions branch from cba9062 to f4016c3 Compare January 26, 2026 22:05
Copy link
Copy Markdown
Member

@richardebeling richardebeling left a comment

Choose a reason for hiding this comment

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

Thank you so much :)

Comment thread evap/staff/tests/test_views.py Outdated
@richardebeling
Copy link
Copy Markdown
Member

Oh, by the way, since this is large scale with high probability of fatigue leading to us missing subtle errors, I asked Copilot for a review on richardebeling#1 (review). I closed a few bogus review points, I'll have to go through the remaining ones, I'll try to get that done tomorrow

Comment on lines +16 to +28
new_question = new_questions.setdefault(
(question.text_de, question.text_en, question.allows_additional_textanswers, question.type),
NewQuestion(
# pk=question.pk, # was only active when the test data jsons were regenerated to minimize the diff
text_de=question.text_de,
text_en=question.text_en,
allows_additional_textanswers=question.allows_additional_textanswers,
type=question.type,
),
)
assignments[question.pk] = QuestionAssignment(
question=new_question, questionnaire_id=question.questionnaire_id, order=question.order
)
Copy link
Copy Markdown
Collaborator Author

@Kakadus Kakadus Feb 9, 2026

Choose a reason for hiding this comment

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

from ai review:

The migration has a potential issue with question deduplication. It uses a tuple of (text_de, text_en, allows_additional_textanswers, type) as a key to deduplicate questions. However, this approach may incorrectly merge questions that happen to have the same text but are actually different questions in different contexts. For instance, two different questionnaires might have a question "How satisfied are you?" that should remain separate. This could lead to unintended data merging during migration. Consider whether this deduplication is intended or if each question should be preserved separately.

Question deduplication is not bad per se, and the "copy on write" logic mimics current behavior.
What I haven't thought of specifically is questionnaires that contain the same question multiple times. We prohibited this specifically with unique_together = [("question", "questionnaire")], so the check fails then. This may happen when migrating production.
@janno42, can you check this by running

from django.db.models import CharField, Count, F, Value
from django.db.models.functions import Concat

questionnaires_with_duplicates = Questionnaire.objects.annotate(
    total_questions=Count('questions'),
    unique_combinations=Count(
        Concat(
            'questions__text_de',
            Value('|==||==|'),
            'questions__text_en',
            Value('|==||==|'),
            'questions__allows_additional_textanswers',
            Value('|==||==|'),
            'questions__type',
            output_field=CharField()
        ),
        distinct=True
    )
).filter(total_questions__gt=F('unique_combinations'))
print(len(questionnaires_with_duplicates), "questionnaires ask the same question multiple times.")

in production?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I cannot imagine a situation where we intentionally included the same question twice in a questionnaire, but it probably doesn't hurt to double check

@Kakadus Kakadus force-pushed the combined-questions branch from f4016c3 to 41001eb Compare February 9, 2026 18:08
Copy link
Copy Markdown
Member

@richardebeling richardebeling left a comment

Choose a reason for hiding this comment

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

These were the things Copilot review found

Comment thread evap/student/tests/test_views.py Outdated
Comment thread evap/staff/forms.py Outdated
Comment thread evap/evaluation/tests/tools.py Outdated
@niklasmohrin
Copy link
Copy Markdown
Member

@Kakadus The current state is compatible with main and there are no user facing changes right? We are considering to use this chance right now and merge, is there any reason against that in your view?

@niklasmohrin niklasmohrin merged commit b67fb4e into e-valuation:main Mar 2, 2026
16 checks passed
@niklasmohrin
Copy link
Copy Markdown
Member

Thanks, great changes ahead!

@richardebeling
Copy link
Copy Markdown
Member

Thanks again @Kakadus!

@niklasmohrin niklasmohrin mentioned this pull request Mar 2, 2026
1 task
@niklasmohrin
Copy link
Copy Markdown
Member

karyon added a commit to karyon/EvaP that referenced this pull request Mar 8, 2026
This got introduced in e-valuation#2513. The tests didn't explicitly cover deleting a heading question, but test_delete_question would still occasionally do it depending on the status of the RNG when running with --shuffle, because it didn't specify the question type so model baker would choose one at random.
richardebeling pushed a commit that referenced this pull request Mar 10, 2026
This got introduced in #2513.
`answer_class` throws on heading questions.

The tests didn't explicitly cover deleting a heading question, but
test_delete_question would still occasionally do it depending on the
status of the RNG when running with --shuffle, because it didn't specify
the question type so model baker would choose one at random. I saw this
failing in a PR check for #2643.
page = form.submit()
self.assertIn("Select a valid choice.", page)

def test_delete_question(self) -> None:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We have seen one flaky failure if a question was of type HeadingQuestion, this was fixed in #2665.

There was another flaky failure in https://github.com/e-valuation/EvaP/actions/runs/22596857685/job/65468976117?pr=2587, the test failed at

self.assertQuerySetEqual(self.question.questionnaires.all(), [])

with

======================================================================
FAIL: test_delete_question (evap.staff.tests.test_views.TestQuestionnaireEditView.test_delete_question)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/runner/work/EvaP/EvaP/evap/staff/tests/test_views.py", line 3405, in test_delete_question
    self.assertQuerySetEqual(self.question.questionnaires.all(), [])
    ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: Lists differ: [<Questionnaire: nspLpceuXDUWCqsJcEsx>] != []

I cannot reproduce this at all, and I don't have a convincing explanation on what happened. Based on the error message, I'd say this must be a different problem. A heading question should have ran into the assertion consistently. I could see database transaction ordering or django related-model caching to be a problem here, but:

  • If django related-model caching was a problem, we should be able to reproduce this somewhat consistently
  • Database transaction ordering should be nice enough to us. Iirc we have a guarantee that effects of previously completed transactions will be visible when we look at the database again. I could see that QuestionAssignment::delete not being wrapped in a transaction could cause trouble, but only if question deletion fails. If both queries succeed, I would expect that our code here is sequenced strictly after both delete queries (with automatic single-query transactions) have succeeded, so we should never observe the assignment existing here.

We have changed some stuff around this test and the delete logic now. We may want to put delete in @transaction.atomic, but I don't see how it would have fixed this test failure.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe it is related to this?

Overridden model methods are not called on bulk operations

Note that the delete() method for an object is not necessarily called when deleting objects in bulk using a QuerySet or as a result of a cascading delete. To ensure customized delete logic gets executed, you can use pre_delete and/or post_delete signals.

From https://docs.djangoproject.com/en/6.0/topics/db/models/#overriding-model-methods

Could it be that we somehow run into this being a cascading delete in some case? I don't see Django using a queryset delete in one case and a normal one in the other, but calling .delete on models in a non-deterministic order I could see

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@karyon has pointed out that our QuestionAssignment::delete method will first delete related objects via self.question.delete, which will in turn trigger a cascaded delete on the instance that delete was just called on -- but I would still (naively) expect that this behaves somewhat consistently if this was an issue

niklasmohrin added a commit that referenced this pull request Mar 16, 2026
The model's `.delete` method is not called when an object is deleted
from a queryset or in a cascading delete. Instead, the `post_delete`
signal can be used. I also made sure to wrap the handler in a
transaction to rule out this kind of error.

See
https://docs.djangoproject.com/en/6.0/topics/db/models/#overriding-model-methods
and #2513 (comment)

- Run QuestionAssignment's delete garbage collection in signal
- Merge `TestQuestionnaire` and `QuestionnaireTests`, and downgrade to
TestCase
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants