From d66c21ecb305cb930ec0f4b634047314dcbd8e24 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Fri, 27 Feb 2026 16:31:31 +0100 Subject: [PATCH] feat(f3): add guess submission endpoint with deadline checks --- TODO.md | 2 +- lobby/tests.py | 106 +++++++++++++++++++++++++++++++++++++++++++++++++ lobby/urls.py | 6 +++ lobby/views.py | 98 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 10fb2fe..b643fdd 100644 --- a/TODO.md +++ b/TODO.md @@ -57,7 +57,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo - [x] Runde starter med kategori - [x] Spørgsmål vises -> alle skriver løgn inden X sek - [x] System blander korrekt svar + løgne -- [ ] Guessfase: alle gætter inden Z sek +- [x] Guessfase: alle gætter inden Z sek - [ ] Pointudregning (konfigurerbar pr. runde) - [ ] Scoreboard + næste spørgsmål/runde - [ ] Slutresultat diff --git a/lobby/tests.py b/lobby/tests.py index 2a0baeb..e8aaa05 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -8,6 +8,7 @@ from django.utils import timezone from fupogfakta.models import ( Category, GameSession, + Guess, LieAnswer, Player, Question, @@ -337,3 +338,108 @@ class MixAnswersTests(TestCase): self.assertEqual(response.status_code, 200) answer_texts = [entry["text"] for entry in response.json()["answers"]] self.assertEqual(set(answer_texts), {"København", "Aarhus"}) + + +class GuessSubmissionTests(TestCase): + def setUp(self): + self.host = User.objects.create_user(username="host_guess", password="secret123") + self.session = GameSession.objects.create(host=self.host, code="GU3551", status=GameSession.Status.GUESS) + self.category = Category.objects.create(name="Videnskab", slug="videnskab", is_active=True) + self.question = Question.objects.create( + category=self.category, + prompt="Hvilken planet kaldes den røde planet?", + correct_answer="Mars", + is_active=True, + ) + self.round_config = RoundConfig.objects.create( + session=self.session, + number=1, + category=self.category, + lie_seconds=45, + guess_seconds=30, + ) + self.round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer="Mars", + ) + self.player = Player.objects.create(session=self.session, nickname="Luna") + self.liar = Player.objects.create(session=self.session, nickname="Mads") + LieAnswer.objects.create(round_question=self.round_question, player=self.liar, text="Jupiter") + + def test_player_can_submit_guess_in_guess_phase(self): + response = self.client.post( + reverse( + "lobby:submit_guess", + kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, + ), + data={"player_id": self.player.id, "selected_text": "Mars"}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 201) + payload = response.json() + self.assertTrue(payload["guess"]["is_correct"]) + self.assertIsNone(payload["guess"]["fooled_player_id"]) + self.assertIn("guess_deadline_at", payload["window"]) + + def test_submit_guess_rejects_when_not_in_guess_phase(self): + self.session.status = GameSession.Status.LIE + self.session.save(update_fields=["status"]) + + response = self.client.post( + reverse( + "lobby:submit_guess", + kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, + ), + data={"player_id": self.player.id, "selected_text": "Mars"}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"], "Guess submission is only allowed in guess phase") + + def test_submit_guess_rejects_unknown_answer(self): + response = self.client.post( + reverse( + "lobby:submit_guess", + kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, + ), + data={"player_id": self.player.id, "selected_text": "Venus"}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"], "Selected answer is not part of this round") + + def test_submit_guess_rejects_duplicate_submission(self): + Guess.objects.create(round_question=self.round_question, player=self.player, selected_text="Mars", is_correct=True) + + response = self.client.post( + reverse( + "lobby:submit_guess", + kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, + ), + data={"player_id": self.player.id, "selected_text": "Jupiter"}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"], "Guess already submitted for this player") + + def test_submit_guess_rejects_after_deadline(self): + self.round_question.shown_at = timezone.now() - timedelta(seconds=76) + self.round_question.save(update_fields=["shown_at"]) + + response = self.client.post( + reverse( + "lobby:submit_guess", + kwargs={"code": self.session.code, "round_question_id": self.round_question.id}, + ), + data={"player_id": self.player.id, "selected_text": "Mars"}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"], "Guess submission window has closed") diff --git a/lobby/urls.py b/lobby/urls.py index 19ff715..9f5484c 100644 --- a/lobby/urls.py +++ b/lobby/urls.py @@ -20,4 +20,10 @@ urlpatterns = [ views.mix_answers, name="mix_answers", ), + path( + "sessions//questions//guesses/submit", + views.submit_guess, + name="submit_guess", + ), ] + diff --git a/lobby/views.py b/lobby/views.py index a32d6b8..1746211 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -11,6 +11,7 @@ from django.views.decorators.http import require_GET, require_POST from fupogfakta.models import ( Category, GameSession, + Guess, LieAnswer, Player, Question, @@ -398,3 +399,100 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json "answers": [{"text": text} for text in deduped_answers], } ) + + +@require_POST +def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse: + payload = _json_body(request) + session_code = code.strip().upper() + + player_id = payload.get("player_id") + selected_text = str(payload.get("selected_text", "")).strip() + + if not player_id: + return JsonResponse({"error": "player_id is required"}, status=400) + + if not selected_text or len(selected_text) > 255: + return JsonResponse({"error": "selected_text must be between 1 and 255 characters"}, status=400) + + try: + session = GameSession.objects.get(code=session_code) + except GameSession.DoesNotExist: + return JsonResponse({"error": "Session not found"}, status=404) + + if session.status != GameSession.Status.GUESS: + return JsonResponse({"error": "Guess submission is only allowed in guess phase"}, status=400) + + try: + player = Player.objects.get(pk=player_id, session=session) + except Player.DoesNotExist: + return JsonResponse({"error": "Player not found in session"}, status=404) + + try: + round_question = RoundQuestion.objects.get( + pk=round_question_id, + session=session, + round_number=session.current_round, + ) + except RoundQuestion.DoesNotExist: + return JsonResponse({"error": "Round question not found"}, status=404) + + try: + round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) + except RoundConfig.DoesNotExist: + return JsonResponse({"error": "Round config missing"}, status=400) + + guess_deadline_at = round_question.shown_at + timedelta( + seconds=round_config.lie_seconds + round_config.guess_seconds + ) + if timezone.now() > guess_deadline_at: + return JsonResponse({"error": "Guess submission window has closed"}, status=400) + + allowed_answers = { + round_question.correct_answer.strip().casefold(), + *( + text.strip().casefold() + for text in round_question.lies.values_list("text", flat=True) + if text.strip() + ), + } + + selected_normalized = selected_text.casefold() + if selected_normalized not in allowed_answers: + return JsonResponse({"error": "Selected answer is not part of this round"}, status=400) + + correct_normalized = round_question.correct_answer.strip().casefold() + fooled_player_id = None + if selected_normalized != correct_normalized: + fooled_player_id = ( + round_question.lies.filter(text__iexact=selected_text).values_list("player_id", flat=True).first() + ) + + try: + guess = Guess.objects.create( + round_question=round_question, + player=player, + selected_text=selected_text, + is_correct=selected_normalized == correct_normalized, + fooled_player_id=fooled_player_id, + ) + except IntegrityError: + return JsonResponse({"error": "Guess already submitted for this player"}, status=409) + + return JsonResponse( + { + "guess": { + "id": guess.id, + "player_id": player.id, + "round_question_id": round_question.id, + "selected_text": guess.selected_text, + "is_correct": guess.is_correct, + "fooled_player_id": guess.fooled_player_id, + "created_at": guess.created_at.isoformat(), + }, + "window": { + "guess_deadline_at": guess_deadline_at.isoformat(), + }, + }, + status=201, + )