Big visual overhaul docker compsoe file etc
Some checks failed
CI / test-and-quality (push) Failing after 4m4s

This commit is contained in:
Asger Geel Weirsøe
2026-03-23 14:11:30 +01:00
parent d86941fef8
commit a81bc1250c
92 changed files with 11584 additions and 1686 deletions

View File

@@ -1,3 +1,25 @@
from django.contrib import admin
# Register your models here.
from .models import PhaseVoiceLine, QuestionVoiceLine
@admin.register(PhaseVoiceLine)
class PhaseVoiceLineAdmin(admin.ModelAdmin):
list_display = ("game_key", "cue_key", "locale", "has_audio", "is_active")
list_filter = ("game_key", "cue_key", "locale", "is_active")
search_fields = ("text",)
@admin.display(boolean=True, description="Audio")
def has_audio(self, obj: PhaseVoiceLine) -> bool:
return bool(obj.audio_file)
@admin.register(QuestionVoiceLine)
class QuestionVoiceLineAdmin(admin.ModelAdmin):
list_display = ("question", "cue_key", "locale", "has_audio", "is_active")
list_filter = ("cue_key", "locale", "is_active", "question__category")
search_fields = ("question__prompt", "text")
@admin.display(boolean=True, description="Audio")
def has_audio(self, obj: QuestionVoiceLine) -> bool:
return bool(obj.audio_file)

View File

@@ -0,0 +1,48 @@
# Generated by Django 6.0.2 on 2026-03-18 13:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('fupogfakta', '0008_questionlie'),
]
operations = [
migrations.CreateModel(
name='PhaseVoiceLine',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('game_key', models.CharField(default='fupogfakta', max_length=64)),
('cue_key', models.CharField(choices=[('intro', 'Intro'), ('lobby', 'Lobby'), ('lie', 'Lie'), ('guess', 'Guess'), ('reveal', 'Reveal'), ('scoreboard', 'Scoreboard'), ('finished', 'Finished')], max_length=32)),
('locale', models.CharField(default='en', max_length=12)),
('text', models.TextField()),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['game_key', 'cue_key', 'locale'],
'unique_together': {('game_key', 'cue_key', 'locale')},
},
),
migrations.CreateModel(
name='QuestionVoiceLine',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cue_key', models.CharField(choices=[('question_prompt', 'Question prompt'), ('question_reveal', 'Question reveal')], max_length=32)),
('locale', models.CharField(default='en', max_length=12)),
('text', models.TextField()),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='voice_lines', to='fupogfakta.question')),
],
options={
'ordering': ['question_id', 'cue_key', 'locale'],
'unique_together': {('question', 'cue_key', 'locale')},
},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.2 on 2026-03-18 13:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('voice', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='phasevoiceline',
name='audio_file',
field=models.FileField(blank=True, null=True, upload_to='voice/phase/'),
),
migrations.AddField(
model_name='questionvoiceline',
name='audio_file',
field=models.FileField(blank=True, null=True, upload_to='voice/question/'),
),
]

View File

@@ -1,3 +1,48 @@
from django.db import models
# Create your models here.
class PhaseVoiceLine(models.Model):
class CueKey(models.TextChoices):
INTRO = "intro", "Intro"
LOBBY = "lobby", "Lobby"
LIE = "lie", "Lie"
GUESS = "guess", "Guess"
REVEAL = "reveal", "Reveal"
SCOREBOARD = "scoreboard", "Scoreboard"
FINISHED = "finished", "Finished"
game_key = models.CharField(max_length=64, default="fupogfakta")
cue_key = models.CharField(max_length=32, choices=CueKey.choices)
locale = models.CharField(max_length=12, default="en")
text = models.TextField()
audio_file = models.FileField(upload_to="voice/phase/", blank=True, null=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["game_key", "cue_key", "locale"]
unique_together = (("game_key", "cue_key", "locale"),)
def __str__(self):
return f"{self.game_key}:{self.cue_key}:{self.locale}"
class QuestionVoiceLine(models.Model):
class CueKey(models.TextChoices):
QUESTION_PROMPT = "question_prompt", "Question prompt"
QUESTION_REVEAL = "question_reveal", "Question reveal"
question = models.ForeignKey("fupogfakta.Question", on_delete=models.CASCADE, related_name="voice_lines")
cue_key = models.CharField(max_length=32, choices=CueKey.choices)
locale = models.CharField(max_length=12, default="en")
text = models.TextField()
audio_file = models.FileField(upload_to="voice/question/", blank=True, null=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["question_id", "cue_key", "locale"]
unique_together = (("question", "cue_key", "locale"),)
def __str__(self):
return f"{self.question_id}:{self.cue_key}:{self.locale}"

193
voice/services.py Normal file
View File

@@ -0,0 +1,193 @@
from __future__ import annotations
from typing import Any
from fupogfakta.models import GameSession, RoundQuestion
from lobby.i18n import i18n_locale_config
from .models import PhaseVoiceLine, QuestionVoiceLine
VOICE_GAME_KEY = "fupogfakta"
DEFAULT_PHASE_LINES: dict[str, dict[str, str]] = {
"en": {
"intro": (
"Welcome to Fup og Fakta. Invent a believable lie, spot the real answer, "
"and score points when other players believe your bluff."
),
"lobby": "Players are joining the session. Get ready to start the round.",
"lie": "The question is live. Players, write one believable lie before time runs out.",
"guess": "The answers are mixed. Pick the answer you believe is true.",
"reveal": "Time to reveal the lies, the guesses, and the correct answer.",
"scoreboard": "Here comes the scoreboard for this round.",
"finished": "The game is finished. Here is the final result.",
},
"da": {
"intro": (
"Velkommen til Fup og Fakta. Find på en troværdig løgn, gennemsku det rigtige svar, "
"og få point når andre hopper på dit bluff."
),
"lobby": "Spillerne er ved at joine sessionen. Gør klar til at starte runden.",
"lie": "Spørgsmålet er live. Spillere, skriv en troværdig løgn før tiden løber ud.",
"guess": "Svarene er blandet. Vælg det svar du tror er rigtigt.",
"reveal": "Nu afslører vi løgnene, gættene og det rigtige svar.",
"scoreboard": "Her kommer scoreboardet for denne runde.",
"finished": "Spillet er slut. Her er det endelige resultat.",
},
}
def _default_question_prompt(locale: str, prompt: str) -> str:
if locale == "da":
return f"Spørgsmålet lyder: {prompt}"
return f"The question is: {prompt}"
def _default_question_reveal(locale: str, correct_answer: str) -> str:
if locale == "da":
return f"Det rigtige svar er: {correct_answer}"
return f"The correct answer is: {correct_answer}"
def _supported_locales() -> tuple[str, tuple[str, ...]]:
return i18n_locale_config()
def _default_phase_line(cue_key: str, locale: str) -> str:
default_locale, _locales = _supported_locales()
localized_lines = DEFAULT_PHASE_LINES.get(locale) or DEFAULT_PHASE_LINES.get(default_locale) or {}
return localized_lines.get(cue_key, cue_key)
def _resolve_audio_url(audio_field: Any) -> str | None:
if not audio_field:
return None
try:
return str(audio_field.url)
except ValueError:
return None
def _resolve_phase_content(*, cue_key: str, locale: str) -> tuple[str, str | None, str]:
custom = (
PhaseVoiceLine.objects.filter(
game_key=VOICE_GAME_KEY,
cue_key=cue_key,
locale=locale,
is_active=True,
)
.first()
)
if custom:
return custom.text, _resolve_audio_url(custom.audio_file), "custom"
return _default_phase_line(cue_key, locale), None, "default"
def _resolve_question_content(
*,
cue_key: str,
locale: str,
round_question: RoundQuestion,
) -> tuple[str, str | None, str]:
custom = (
QuestionVoiceLine.objects.filter(
question=round_question.question,
cue_key=cue_key,
locale=locale,
is_active=True,
)
.first()
)
if custom:
return custom.text, _resolve_audio_url(custom.audio_file), "custom"
if cue_key == QuestionVoiceLine.CueKey.QUESTION_REVEAL:
return _default_question_reveal(locale, round_question.correct_answer), None, "default"
return _default_question_prompt(locale, round_question.question.prompt), None, "default"
def _build_cue_payload(
*,
cue_key: str,
text_by_locale: dict[str, str],
audio_urls: dict[str, str],
source: str,
) -> dict[str, Any]:
return {
"cue": cue_key,
"translations": text_by_locale,
"audio_urls": audio_urls,
"source": source,
}
def resolve_session_voice_cues(
session: GameSession,
*,
current_round_question: RoundQuestion | None,
) -> dict[str, Any]:
default_locale, supported_locales = _supported_locales()
def collect_phase(cue_key: str) -> dict[str, Any]:
translations: dict[str, str] = {}
audio_urls: dict[str, str] = {}
source = "default"
for locale in supported_locales:
text, audio_url, line_source = _resolve_phase_content(cue_key=cue_key, locale=locale)
translations[locale] = text
if audio_url:
audio_urls[locale] = audio_url
if line_source == "custom":
source = "custom"
return _build_cue_payload(
cue_key=cue_key,
text_by_locale=translations,
audio_urls=audio_urls,
source=source,
)
def collect_question(cue_key: str) -> dict[str, Any] | None:
if current_round_question is None:
return None
translations: dict[str, str] = {}
audio_urls: dict[str, str] = {}
source = "default"
for locale in supported_locales:
text, audio_url, line_source = _resolve_question_content(
cue_key=cue_key,
locale=locale,
round_question=current_round_question,
)
translations[locale] = text
if audio_url:
audio_urls[locale] = audio_url
if line_source == "custom":
source = "custom"
return _build_cue_payload(
cue_key=cue_key,
text_by_locale=translations,
audio_urls=audio_urls,
source=source,
)
phase_cue_key = session.status if session.status in {
GameSession.Status.LOBBY,
GameSession.Status.LIE,
GameSession.Status.GUESS,
GameSession.Status.REVEAL,
GameSession.Status.SCOREBOARD,
GameSession.Status.FINISHED,
} else GameSession.Status.LOBBY
return {
"default_locale": default_locale,
"intro": collect_phase(PhaseVoiceLine.CueKey.INTRO),
"phase": collect_phase(phase_cue_key),
"question_prompt": collect_question(QuestionVoiceLine.CueKey.QUESTION_PROMPT)
if session.status in {GameSession.Status.LIE, GameSession.Status.GUESS}
else None,
"question_reveal": collect_question(QuestionVoiceLine.CueKey.QUESTION_REVEAL)
if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}
else None,
}

View File

@@ -1,3 +1,90 @@
from django.test import TestCase
import tempfile
# Create your tests here.
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from fupogfakta.models import Category, GameSession, Question, RoundQuestion
from voice.models import PhaseVoiceLine, QuestionVoiceLine
from voice.services import resolve_session_voice_cues
User = get_user_model()
class VoiceCueResolutionTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="voice_host", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="VOICE1", status=GameSession.Status.LIE)
self.category = Category.objects.create(name="Voice", slug="voice", is_active=True)
self.question = Question.objects.create(
category=self.category,
prompt="Which city is the capital of Denmark?",
correct_answer="Copenhagen",
is_active=True,
)
self.round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
def test_resolve_session_voice_cues_builds_default_multilocale_payload(self):
payload = resolve_session_voice_cues(self.session, current_round_question=self.round_question)
self.assertEqual(payload["default_locale"], "en")
self.assertEqual(payload["intro"]["cue"], "intro")
self.assertIn("Welcome to Fup og Fakta", payload["intro"]["translations"]["en"])
self.assertIn("Velkommen til Fup og Fakta", payload["intro"]["translations"]["da"])
self.assertEqual(payload["intro"]["audio_urls"], {})
self.assertEqual(payload["phase"]["cue"], GameSession.Status.LIE)
self.assertIn(self.question.prompt, payload["question_prompt"]["translations"]["en"])
self.assertIsNone(payload["question_reveal"])
def test_custom_phase_and_question_voice_lines_override_defaults(self):
PhaseVoiceLine.objects.create(
game_key="fupogfakta",
cue_key=PhaseVoiceLine.CueKey.INTRO,
locale="da",
text="Special dansk intro.",
)
QuestionVoiceLine.objects.create(
question=self.question,
cue_key=QuestionVoiceLine.CueKey.QUESTION_PROMPT,
locale="en",
text="Custom English prompt line.",
)
payload = resolve_session_voice_cues(self.session, current_round_question=self.round_question)
self.assertEqual(payload["intro"]["source"], "custom")
self.assertEqual(payload["intro"]["translations"]["da"], "Special dansk intro.")
self.assertEqual(payload["question_prompt"]["source"], "custom")
self.assertEqual(payload["question_prompt"]["translations"]["en"], "Custom English prompt line.")
def test_custom_audio_files_are_exposed_per_locale(self):
with tempfile.TemporaryDirectory() as media_root:
with override_settings(MEDIA_ROOT=media_root):
PhaseVoiceLine.objects.create(
game_key="fupogfakta",
cue_key=PhaseVoiceLine.CueKey.INTRO,
locale="en",
text="English intro with audio.",
audio_file=SimpleUploadedFile("intro-en.mp3", b"fake-mp3-content", content_type="audio/mpeg"),
)
QuestionVoiceLine.objects.create(
question=self.question,
cue_key=QuestionVoiceLine.CueKey.QUESTION_PROMPT,
locale="da",
text="Dansk sporgsmal med lyd.",
audio_file=SimpleUploadedFile("question-da.mp3", b"fake-mp3-content", content_type="audio/mpeg"),
)
payload = resolve_session_voice_cues(self.session, current_round_question=self.round_question)
self.assertEqual(payload["intro"]["source"], "custom")
self.assertIn("/media/voice/phase/", payload["intro"]["audio_urls"]["en"])
self.assertTrue(payload["intro"]["audio_urls"]["en"].endswith("intro-en.mp3"))
self.assertEqual(payload["question_prompt"]["source"], "custom")
self.assertIn("/media/voice/question/", payload["question_prompt"]["audio_urls"]["da"])
self.assertTrue(payload["question_prompt"]["audio_urls"]["da"].endswith("question-da.mp3"))

View File

@@ -1,3 +1 @@
from django.shortcuts import render
# Create your views here.
"""HTTP views for the voice app."""