import json import random from datetime import timedelta from django.contrib.auth.decorators import login_required from django.db import IntegrityError, transaction from django.http import HttpRequest, JsonResponse from django.utils import timezone from django.views.decorators.http import require_GET, require_POST from fupogfakta.models import ( Category, GameSession, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ) SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" SESSION_CODE_LENGTH = 6 MAX_CODE_GENERATION_ATTEMPTS = 20 JOINABLE_STATUSES = { GameSession.Status.LOBBY, GameSession.Status.LIE, GameSession.Status.GUESS, GameSession.Status.REVEAL, } def _json_body(request: HttpRequest) -> dict: if not request.body: return {} try: return json.loads(request.body) except json.JSONDecodeError: return {} def _generate_session_code() -> str: return "".join(random.choices(SESSION_CODE_ALPHABET, k=SESSION_CODE_LENGTH)) def _create_unique_session_code() -> str: for _ in range(MAX_CODE_GENERATION_ATTEMPTS): code = _generate_session_code() if not GameSession.objects.filter(code=code).exists(): return code raise RuntimeError("Could not generate unique session code") @require_POST @login_required def create_session(request: HttpRequest) -> JsonResponse: code = _create_unique_session_code() session = GameSession.objects.create(host=request.user, code=code) return JsonResponse( { "session": { "code": session.code, "status": session.status, "host_id": session.host_id, "current_round": session.current_round, } }, status=201, ) @require_POST def join_session(request: HttpRequest) -> JsonResponse: payload = _json_body(request) code = str(payload.get("code", "")).strip().upper() nickname = str(payload.get("nickname", "")).strip() if not code: return JsonResponse({"error": "Session code is required"}, status=400) if len(nickname) < 2 or len(nickname) > 40: return JsonResponse({"error": "Nickname must be between 2 and 40 characters"}, status=400) try: session = GameSession.objects.get(code=code) except GameSession.DoesNotExist: return JsonResponse({"error": "Session not found"}, status=404) if session.status not in JOINABLE_STATUSES: return JsonResponse({"error": "Session is not joinable"}, status=400) if Player.objects.filter(session=session, nickname__iexact=nickname).exists(): return JsonResponse({"error": "Nickname already taken"}, status=409) player = Player.objects.create(session=session, nickname=nickname) return JsonResponse( { "player": { "id": player.id, "nickname": player.nickname, "score": player.score, }, "session": { "code": session.code, "status": session.status, }, }, status=201, ) @require_GET def session_detail(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) players = list( session.players.order_by("nickname").values( "id", "nickname", "score", "is_connected", ) ) return JsonResponse( { "session": { "code": session.code, "status": session.status, "host_id": session.host_id, "current_round": session.current_round, "players_count": len(players), }, "players": players, } ) @require_POST @login_required def start_round(request: HttpRequest, code: str) -> JsonResponse: payload = _json_body(request) category_slug = str(payload.get("category_slug", "")).strip() if not category_slug: return JsonResponse({"error": "category_slug is required"}, status=400) 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 round"}, status=403) if session.status != GameSession.Status.LOBBY: return JsonResponse({"error": "Round can only be started from lobby"}, status=400) try: category = Category.objects.get(slug=category_slug, is_active=True) except Category.DoesNotExist: return JsonResponse({"error": "Category not found"}, status=404) if not Question.objects.filter(category=category, is_active=True).exists(): return JsonResponse({"error": "Category has no active questions"}, status=400) with transaction.atomic(): session = GameSession.objects.select_for_update().get(pk=session.pk) if session.status != GameSession.Status.LOBBY: return JsonResponse({"error": "Round can only be started from lobby"}, status=400) round_config, created = RoundConfig.objects.get_or_create( session=session, number=session.current_round, defaults={"category": category}, ) if not created: return JsonResponse({"error": "Round already configured"}, status=409) session.status = GameSession.Status.LIE session.save(update_fields=["status"]) return JsonResponse( { "session": { "code": session.code, "status": session.status, "current_round": session.current_round, }, "round": { "number": round_config.number, "category": { "slug": round_config.category.slug, "name": round_config.category.name, }, }, }, 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, ) @require_POST @login_required def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> 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 mix answers"}, status=403) if session.status != GameSession.Status.LIE: return JsonResponse({"error": "Answers can only be mixed in lie phase"}, status=400) 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) lie_texts = list(round_question.lies.values_list("text", flat=True)) deduped_answers = [] seen = set() for text in [round_question.correct_answer, *lie_texts]: normalized = text.strip().casefold() if not normalized or normalized in seen: continue seen.add(normalized) deduped_answers.append(text.strip()) if len(deduped_answers) < 2: return JsonResponse({"error": "Not enough answers to mix"}, status=400) random.shuffle(deduped_answers) with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status != GameSession.Status.LIE: return JsonResponse({"error": "Answers can only be mixed in lie phase"}, status=400) locked_session.status = GameSession.Status.GUESS locked_session.save(update_fields=["status"]) return JsonResponse( { "session": { "code": session.code, "status": GameSession.Status.GUESS, "current_round": session.current_round, }, "round_question": { "id": round_question.id, "round_number": round_question.round_number, }, "answers": [{"text": text} for text in deduped_answers], } )