Compare commits
2 Commits
feat/issue
...
1662d7d197
| Author | SHA1 | Date | |
|---|---|---|---|
| 1662d7d197 | |||
| 1c78b856fb |
@@ -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')},
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
10
fupogfakta/urls.py
Normal file
10
fupogfakta/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "fupogfakta"
|
||||
|
||||
urlpatterns = [
|
||||
path("sessions/<str:code>/lie-question/start", views.start_lie_question, name="start_lie_question"),
|
||||
path("sessions/<str:code>/lies", views.submit_lie, name="submit_lie"),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user