Big visual overhaul docker compsoe file etc
Some checks failed
CI / test-and-quality (push) Failing after 4m4s
Some checks failed
CI / test-and-quality (push) Failing after 4m4s
This commit is contained in:
@@ -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)
|
||||
|
||||
48
voice/migrations/0001_initial.py
Normal file
48
voice/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
23
voice/migrations/0002_phasevoiceline_audio_file_and_more.py
Normal file
23
voice/migrations/0002_phasevoiceline_audio_file_and_more.py
Normal 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/'),
|
||||
),
|
||||
]
|
||||
@@ -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
193
voice/services.py
Normal 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,
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
"""HTTP views for the voice app."""
|
||||
|
||||
Reference in New Issue
Block a user