Merge branch feature/f3-lie-submit-x-sek

This commit is contained in:
2026-02-27 14:38:00 +01:00
6 changed files with 273 additions and 4 deletions

View File

@@ -55,7 +55,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
### Fase 3 — Spilflow `Fup og Fakta` ### Fase 3 — Spilflow `Fup og Fakta`
- [x] Lobby: host opretter session, spillere joiner via kode - [x] Lobby: host opretter session, spillere joiner via kode
- [x] Runde starter med kategori - [x] Runde starter med kategori
- [ ] Spørgsmål vises -> alle skriver løgn inden X sek - [x] Spørgsmål vises -> alle skriver løgn inden X sek
- [ ] System blander korrekt svar + løgne - [ ] System blander korrekt svar + løgne
- [ ] Guessfase: alle gætter inden Z sek - [ ] Guessfase: alle gætter inden Z sek
- [ ] Pointudregning (konfigurerbar pr. runde) - [ ] Pointudregning (konfigurerbar pr. runde)

View File

@@ -0,0 +1,19 @@
# Generated by Django 6.0.2 on 2026-02-27 13:32
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("fupogfakta", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="roundquestion",
name="shown_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View File

@@ -1,5 +1,6 @@
from django.db import models from django.db import models
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.utils import timezone
User = get_user_model() User = get_user_model()
@@ -85,6 +86,7 @@ class RoundQuestion(models.Model):
round_number = models.PositiveIntegerField() round_number = models.PositiveIntegerField()
question = models.ForeignKey(Question, on_delete=models.PROTECT) question = models.ForeignKey(Question, on_delete=models.PROTECT)
correct_answer = models.CharField(max_length=255) correct_answer = models.CharField(max_length=255)
shown_at = models.DateTimeField(default=timezone.now)
class LieAnswer(models.Model): class LieAnswer(models.Model):

View File

@@ -1,8 +1,19 @@
from datetime import timedelta
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from fupogfakta.models import Category, GameSession, Player, Question, RoundConfig from fupogfakta.models import (
Category,
GameSession,
LieAnswer,
Player,
Question,
RoundConfig,
RoundQuestion,
)
User = get_user_model() User = get_user_model()
@@ -160,3 +171,97 @@ class StartRoundTests(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Round can only be started from lobby") self.assertEqual(response.json()["error"], "Round can only be started from lobby")
class LieSubmissionTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE)
self.category = Category.objects.create(name="Geografi", slug="geografi", is_active=True)
self.question = Question.objects.create(
category=self.category,
prompt="Hvad er hovedstaden i Australien?",
correct_answer="Canberra",
is_active=True,
)
RoundConfig.objects.create(
session=self.session,
number=1,
category=self.category,
lie_seconds=45,
)
self.player = Player.objects.create(session=self.session, nickname="Luna")
def test_host_can_show_question_and_get_lie_deadline(self):
self.client.login(username="host", password="secret123")
response = self.client.post(reverse("lobby:show_question", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 201)
payload = response.json()
self.assertEqual(payload["config"]["lie_seconds"], 45)
self.assertIn("lie_deadline_at", payload["round_question"])
self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists())
def test_player_can_submit_lie_before_deadline(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
response = self.client.post(
reverse(
"lobby:submit_lie",
kwargs={"code": self.session.code, "round_question_id": round_question.id},
),
data={"player_id": self.player.id, "text": "Sydney"},
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(LieAnswer.objects.filter(round_question=round_question, player=self.player).exists())
def test_submit_lie_rejects_after_time_window(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
round_question.shown_at = timezone.now() - timedelta(seconds=46)
round_question.save(update_fields=["shown_at"])
response = self.client.post(
reverse(
"lobby:submit_lie",
kwargs={"code": self.session.code, "round_question_id": round_question.id},
),
data={"player_id": self.player.id, "text": "Melbourne"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Lie submission window has closed")
def test_submit_lie_rejects_duplicate_submission(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
LieAnswer.objects.create(round_question=round_question, player=self.player, text="Perth")
response = self.client.post(
reverse(
"lobby:submit_lie",
kwargs={"code": self.session.code, "round_question_id": round_question.id},
),
data={"player_id": self.player.id, "text": "Brisbane"},
content_type="application/json",
)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"], "Lie already submitted for this player")

View File

@@ -9,4 +9,10 @@ urlpatterns = [
path("sessions/join", views.join_session, name="join_session"), path("sessions/join", views.join_session, name="join_session"),
path("sessions/<str:code>", views.session_detail, name="session_detail"), path("sessions/<str:code>", views.session_detail, name="session_detail"),
path("sessions/<str:code>/rounds/start", views.start_round, name="start_round"), path("sessions/<str:code>/rounds/start", views.start_round, name="start_round"),
path("sessions/<str:code>/questions/show", views.show_question, name="show_question"),
path(
"sessions/<str:code>/questions/<int:round_question_id>/lies/submit",
views.submit_lie,
name="submit_lie",
),
] ]

View File

@@ -1,12 +1,22 @@
import json import json
import random import random
from datetime import timedelta
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db import transaction from django.db import IntegrityError, transaction
from django.http import HttpRequest, JsonResponse from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
from fupogfakta.models import Category, GameSession, Player, Question, RoundConfig from fupogfakta.models import (
Category,
GameSession,
LieAnswer,
Player,
Question,
RoundConfig,
RoundQuestion,
)
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
SESSION_CODE_LENGTH = 6 SESSION_CODE_LENGTH = 6
@@ -198,3 +208,130 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
}, },
status=201, status=201,
) )
@require_POST
@login_required
def show_question(request: HttpRequest, code: str) -> JsonResponse:
session_code = code.strip().upper()
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can show question"}, status=403)
if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Question can only be shown in lie phase"}, status=400)
try:
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400)
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
return JsonResponse({"error": "Question already shown for this round"}, status=409)
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
available_questions = Question.objects.filter(
category=round_config.category,
is_active=True,
).exclude(pk__in=used_question_ids)
if not available_questions.exists():
return JsonResponse({"error": "No available questions in category"}, status=400)
question = random.choice(list(available_questions))
round_question = RoundQuestion.objects.create(
session=session,
round_number=session.current_round,
question=question,
correct_answer=question.correct_answer,
)
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
return JsonResponse(
{
"round_question": {
"id": round_question.id,
"prompt": question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
},
"config": {
"lie_seconds": round_config.lie_seconds,
},
},
status=201,
)
@require_POST
def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
payload = _json_body(request)
session_code = code.strip().upper()
player_id = payload.get("player_id")
lie_text = str(payload.get("text", "")).strip()
if not player_id:
return JsonResponse({"error": "player_id is required"}, status=400)
if not lie_text or len(lie_text) > 255:
return JsonResponse({"error": "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.LIE:
return JsonResponse({"error": "Lie submission is only allowed in lie 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)
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
if timezone.now() > lie_deadline_at:
return JsonResponse({"error": "Lie submission window has closed"}, status=400)
try:
lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text)
except IntegrityError:
return JsonResponse({"error": "Lie already submitted for this player"}, status=409)
return JsonResponse(
{
"lie": {
"id": lie.id,
"player_id": player.id,
"round_question_id": round_question.id,
"text": lie.text,
"created_at": lie.created_at.isoformat(),
},
"window": {
"lie_deadline_at": lie_deadline_at.isoformat(),
},
},
status=201,
)