diff --git a/fupogfakta/migrations/0002_roundquestion_lie_deadline_at_and_more.py b/fupogfakta/migrations/0002_roundquestion_lie_deadline_at_and_more.py new file mode 100644 index 0000000..84bf7e3 --- /dev/null +++ b/fupogfakta/migrations/0002_roundquestion_lie_deadline_at_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.2 on 2026-02-27 13:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fupogfakta', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='roundquestion', + name='lie_deadline_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterUniqueTogether( + name='roundquestion', + unique_together={('session', 'round_number')}, + ), + ] diff --git a/fupogfakta/models.py b/fupogfakta/models.py index da9f309..1d2ade0 100644 --- a/fupogfakta/models.py +++ b/fupogfakta/models.py @@ -1,5 +1,5 @@ -from django.db import models from django.contrib.auth import get_user_model +from django.db import models User = get_user_model() @@ -85,6 +85,10 @@ class RoundQuestion(models.Model): round_number = models.PositiveIntegerField() question = models.ForeignKey(Question, on_delete=models.PROTECT) correct_answer = models.CharField(max_length=255) + lie_deadline_at = models.DateTimeField(null=True, blank=True) + + class Meta: + unique_together = (("session", "round_number"),) class LieAnswer(models.Model): diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index 7ce503c..7c93609 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -1,3 +1,93 @@ -from django.test import TestCase +from datetime import timedelta -# Create your tests here. +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from .models import Category, GameSession, LieAnswer, Player, Question, RoundConfig, RoundQuestion + +User = get_user_model() + + +class LiePhaseFlowTests(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="Historie", slug="historie", is_active=True) + self.question = Question.objects.create( + category=self.category, + prompt="Hvilket år faldt muren?", + correct_answer="1989", + is_active=True, + ) + RoundConfig.objects.create( + session=self.session, + number=self.session.current_round, + category=self.category, + lie_seconds=20, + ) + self.player = Player.objects.create(session=self.session, nickname="Luna") + + def test_host_can_start_lie_question_with_deadline(self): + self.client.login(username="host", password="secret123") + + response = self.client.post(reverse("fupogfakta:start_lie_question", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 201) + payload = response.json()["question"] + self.assertEqual(payload["round"], 1) + self.assertEqual(payload["prompt"], self.question.prompt) + self.assertEqual(payload["lie_seconds"], 20) + + round_question = RoundQuestion.objects.get(session=self.session, round_number=1) + self.assertIsNotNone(round_question.lie_deadline_at) + + def test_submit_lie_before_deadline_is_accepted(self): + round_question = RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + lie_deadline_at=timezone.now() + timedelta(seconds=10), + ) + + response = self.client.post( + reverse("fupogfakta:submit_lie", kwargs={"code": self.session.code}), + data={"player_id": self.player.id, "text": "1991"}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 201) + self.assertTrue( + LieAnswer.objects.filter(round_question=round_question, player=self.player, text="1991").exists() + ) + self.assertEqual(response.json()["progress"]["submitted_count"], 1) + + def test_submit_lie_after_deadline_is_rejected(self): + RoundQuestion.objects.create( + session=self.session, + round_number=1, + question=self.question, + correct_answer=self.question.correct_answer, + lie_deadline_at=timezone.now() - timedelta(seconds=1), + ) + + response = self.client.post( + reverse("fupogfakta:submit_lie", kwargs={"code": self.session.code}), + data={"player_id": self.player.id, "text": "1991"}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"], "Lie timer expired") + self.assertEqual(LieAnswer.objects.count(), 0) + + def test_non_host_cannot_start_lie_question(self): + User.objects.create_user(username="other", password="secret123") + self.client.login(username="other", password="secret123") + + response = self.client.post(reverse("fupogfakta:start_lie_question", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"], "Only host can start lie question") diff --git a/fupogfakta/urls.py b/fupogfakta/urls.py new file mode 100644 index 0000000..52dce7e --- /dev/null +++ b/fupogfakta/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from . import views + +app_name = "fupogfakta" + +urlpatterns = [ + path("sessions//lie-question/start", views.start_lie_question, name="start_lie_question"), + path("sessions//lies", views.submit_lie, name="submit_lie"), +] diff --git a/fupogfakta/views.py b/fupogfakta/views.py index 91ea44a..1b9fa57 100644 --- a/fupogfakta/views.py +++ b/fupogfakta/views.py @@ -1,3 +1,176 @@ -from django.shortcuts import render +import json +import random +from datetime import timedelta -# Create your views here. +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.http import HttpRequest, JsonResponse +from django.utils import timezone +from django.views.decorators.http import require_POST + +from .models import GameSession, LieAnswer, Player, Question, RoundConfig, RoundQuestion + + +MAX_LIE_LENGTH = 255 + + +def _json_body(request: HttpRequest) -> dict: + if not request.body: + return {} + + try: + return json.loads(request.body) + except json.JSONDecodeError: + return {} + + +def _emit_realtime_session_event(session_code: str, event: str, payload: dict) -> None: + channel_layer = get_channel_layer() + if not channel_layer: + return + + try: + async_to_sync(channel_layer.group_send)( + f"session_{session_code}", + { + "type": "session.event", + "event": event, + "payload": payload, + }, + ) + except Exception: + # Realtime broadcasting must not fail game flow. + return + + +@require_POST +@login_required +def start_lie_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 start lie question"}, status=403) + + if session.status != GameSession.Status.LIE: + return JsonResponse({"error": "Session is not in lie phase"}, status=400) + + with transaction.atomic(): + session = GameSession.objects.select_for_update().get(pk=session.pk) + if session.status != GameSession.Status.LIE: + return JsonResponse({"error": "Session is not in lie phase"}, status=400) + + if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists(): + return JsonResponse({"error": "Lie question already started for round"}, status=409) + + try: + round_config = RoundConfig.objects.select_related("category").get( + session=session, + number=session.current_round, + ) + except RoundConfig.DoesNotExist: + return JsonResponse({"error": "Round config not found"}, status=404) + + question_qs = Question.objects.filter(category=round_config.category, is_active=True) + question_ids = list(question_qs.values_list("id", flat=True)) + if not question_ids: + return JsonResponse({"error": "No active question for category"}, status=400) + + question = question_qs.get(pk=random.choice(question_ids)) + + lie_deadline_at = timezone.now() + timedelta(seconds=round_config.lie_seconds) + round_question = RoundQuestion.objects.create( + session=session, + round_number=session.current_round, + question=question, + correct_answer=question.correct_answer, + lie_deadline_at=lie_deadline_at, + ) + + payload = { + "round": round_question.round_number, + "question_id": round_question.id, + "prompt": round_question.question.prompt, + "lie_deadline_at": round_question.lie_deadline_at.isoformat(), + "lie_seconds": round_config.lie_seconds, + } + _emit_realtime_session_event(session.code, "lie_question_started", payload) + + return JsonResponse({"question": payload}, status=201) + + +@require_POST +def submit_lie(request: HttpRequest, code: str) -> JsonResponse: + session_code = code.strip().upper() + payload = _json_body(request) + + 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 len(lie_text) < 2 or len(lie_text) > MAX_LIE_LENGTH: + return JsonResponse({"error": "text must be between 2 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": "Session is not in lie phase"}, status=400) + + try: + player = Player.objects.get(id=player_id, session=session) + except Player.DoesNotExist: + return JsonResponse({"error": "Player not found in session"}, status=404) + + try: + round_question = RoundQuestion.objects.get(session=session, round_number=session.current_round) + except RoundQuestion.DoesNotExist: + return JsonResponse({"error": "No active lie question"}, status=404) + + now = timezone.now() + if round_question.lie_deadline_at and now > round_question.lie_deadline_at: + return JsonResponse({"error": "Lie timer expired"}, status=400) + + lie_answer, _ = LieAnswer.objects.update_or_create( + round_question=round_question, + player=player, + defaults={"text": lie_text}, + ) + + submitted_count = LieAnswer.objects.filter(round_question=round_question).count() + players_count = session.players.count() + + event_payload = { + "round": session.current_round, + "question_id": round_question.id, + "player_id": player.id, + "submitted_count": submitted_count, + "players_count": players_count, + "lie_deadline_at": round_question.lie_deadline_at.isoformat() if round_question.lie_deadline_at else None, + } + _emit_realtime_session_event(session.code, "lie_submitted", event_payload) + + return JsonResponse( + { + "lie": { + "id": lie_answer.id, + "player_id": player.id, + "question_id": round_question.id, + }, + "progress": { + "submitted_count": submitted_count, + "players_count": players_count, + }, + }, + status=201, + ) diff --git a/partyhub/urls.py b/partyhub/urls.py index dbc31a6..27abbcd 100644 --- a/partyhub/urls.py +++ b/partyhub/urls.py @@ -11,4 +11,5 @@ urlpatterns = [ path("admin/", admin.site.urls), path("healthz", health, name="healthz"), path("lobby/", include("lobby.urls")), + path("game/", include("fupogfakta.urls")), ]