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/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")), ]