diff --git a/fupogfakta/migrations/0003_roundquestion_mixed_answers.py b/fupogfakta/migrations/0003_roundquestion_mixed_answers.py new file mode 100644 index 0000000..8472c0a --- /dev/null +++ b/fupogfakta/migrations/0003_roundquestion_mixed_answers.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-02-27 21:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fupogfakta', '0002_roundquestion_shown_at'), + ] + + operations = [ + migrations.AddField( + model_name='roundquestion', + name='mixed_answers', + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/fupogfakta/models.py b/fupogfakta/models.py index 1e69e7e..55e98dd 100644 --- a/fupogfakta/models.py +++ b/fupogfakta/models.py @@ -87,6 +87,7 @@ class RoundQuestion(models.Model): question = models.ForeignKey(Question, on_delete=models.PROTECT) correct_answer = models.CharField(max_length=255) shown_at = models.DateTimeField(default=timezone.now) + mixed_answers = models.JSONField(default=list, blank=True) class LieAnswer(models.Model): diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index 7ce503c..4929020 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase # Create your tests here. diff --git a/fupogfakta/views.py b/fupogfakta/views.py index 91ea44a..b8e4ee0 100644 --- a/fupogfakta/views.py +++ b/fupogfakta/views.py @@ -1,3 +1,2 @@ -from django.shortcuts import render # Create your views here. diff --git a/lobby/tests.py b/lobby/tests.py index 68338d1..905d9a0 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -308,7 +308,9 @@ class MixAnswersTests(TestCase): self.assertEqual(payload["session"]["status"], GameSession.Status.GUESS) self.session.refresh_from_db() + self.round_question.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.GUESS) + self.assertEqual(self.round_question.mixed_answers, answer_texts) def test_mix_answers_requires_host(self): self.client.login(username="other", password="secret123") @@ -339,6 +341,30 @@ class MixAnswersTests(TestCase): answer_texts = [entry["text"] for entry in response.json()["answers"]] self.assertEqual(set(answer_texts), {"København", "Aarhus"}) + def test_mix_answers_is_idempotent_after_transition_to_guess(self): + LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus") + LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense") + + self.client.login(username="host", password="secret123") + first = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id})) + second = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id})) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual([entry["text"] for entry in first.json()["answers"]], [entry["text"] for entry in second.json()["answers"]]) + + def test_session_detail_returns_persisted_mixed_answers_for_reconnect(self): + LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus") + LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense") + + self.client.login(username="host", password="secret123") + mix_response = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id})) + detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})) + + self.assertEqual(mix_response.status_code, 200) + self.assertEqual(detail_response.status_code, 200) + self.assertEqual([entry["text"] for entry in mix_response.json()["answers"]], [entry["text"] for entry in detail_response.json()["round_question"]["answers"]]) + class GuessSubmissionTests(TestCase): def setUp(self): diff --git a/lobby/views.py b/lobby/views.py index da3cc25..b232015 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -147,6 +147,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse: "round_number": current_round_question.round_number, "prompt": current_round_question.question.prompt, "shown_at": current_round_question.shown_at.isoformat(), + "answers": [{"text": text} for text in (current_round_question.mixed_answers or [])], } return JsonResponse( @@ -368,8 +369,8 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json if session.host_id != request.user.id: return JsonResponse({"error": "Only host can mix answers"}, status=403) - if session.status != GameSession.Status.LIE: - return JsonResponse({"error": "Answers can only be mixed in lie phase"}, status=400) + if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: + return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400) try: round_question = RoundQuestion.objects.get( @@ -380,28 +381,34 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json except RoundQuestion.DoesNotExist: return JsonResponse({"error": "Round question not found"}, status=404) - lie_texts = list(round_question.lies.values_list("text", flat=True)) - - deduped_answers = [] - seen = set() - for text in [round_question.correct_answer, *lie_texts]: - normalized = text.strip().casefold() - if not normalized or normalized in seen: - continue - seen.add(normalized) - deduped_answers.append(text.strip()) - - if len(deduped_answers) < 2: - return JsonResponse({"error": "Not enough answers to mix"}, status=400) - - random.shuffle(deduped_answers) - with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status != GameSession.Status.LIE: - return JsonResponse({"error": "Answers can only be mixed in lie phase"}, status=400) - locked_session.status = GameSession.Status.GUESS - locked_session.save(update_fields=["status"]) + if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: + return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400) + + locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk) + + deduped_answers = list(locked_round_question.mixed_answers or []) + if not deduped_answers: + lie_texts = list(locked_round_question.lies.values_list("text", flat=True)) + seen = set() + for text in [locked_round_question.correct_answer, *lie_texts]: + normalized = text.strip().casefold() + if not normalized or normalized in seen: + continue + seen.add(normalized) + deduped_answers.append(text.strip()) + + if len(deduped_answers) < 2: + return JsonResponse({"error": "Not enough answers to mix"}, status=400) + + random.shuffle(deduped_answers) + locked_round_question.mixed_answers = deduped_answers + locked_round_question.save(update_fields=["mixed_answers"]) + + if locked_session.status == GameSession.Status.LIE: + locked_session.status = GameSession.Status.GUESS + locked_session.save(update_fields=["status"]) return JsonResponse( {