Compare commits
24 Commits
fix/stagin
...
pr-153
| Author | SHA1 | Date | |
|---|---|---|---|
| 022782f824 | |||
| 64bff4efb3 | |||
| c58b1e8102 | |||
| 7aae8f3798 | |||
| 0c0d27cc52 | |||
| 164416e4a9 | |||
| b782f73f49 | |||
| 9d1e41ef3b | |||
| 046212d29a | |||
| 6fd57d1714 | |||
| c4ea5ca208 | |||
| ab08303fc3 | |||
| 8b6f115759 | |||
| c75189deb9 | |||
| 30c22d2f0c | |||
| 30e3f1c77f | |||
| abb656d50b | |||
| b1e89b135a | |||
| e9104cdc44 | |||
| 65ee2fc0db | |||
| 12fc12f955 | |||
| e8f13646f9 | |||
| a36221ae4b | |||
| fce18c1ee3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,6 +22,7 @@ media/
|
||||
.env
|
||||
.env.*
|
||||
!.env.test.example
|
||||
!.env.staging.example
|
||||
!.env.prod.example
|
||||
|
||||
# Editors/OS
|
||||
|
||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
43
docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md
Normal file
43
docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Staging gameplay smoke artifact (Issue #144)
|
||||
|
||||
Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke i #16/#90-sporet uden scope-udvidelse.
|
||||
|
||||
## Guardrails (MVP)
|
||||
- Hold scope inden for #16 (execution board) og #17 (scope guardrail).
|
||||
- Kun verifikation af eksisterende flow; ingen nye features/polish.
|
||||
|
||||
## Hvornår bruges artifacten
|
||||
- Efter staging-smoke af gameplay-flowet: lobby -> join -> start -> runde -> scoreboard -> next/final.
|
||||
- Resultatet logges i issue/PR-kommentar med denne skabelon.
|
||||
|
||||
## Evidence template (kopiér i PR/issue-kommentar)
|
||||
```markdown
|
||||
### Staging gameplay smoke evidence
|
||||
- Timestamp (UTC): <YYYY-MM-DD HH:MM>
|
||||
- Environment: staging
|
||||
- Commit/Head SHA: <sha>
|
||||
- Linked scope: #16 #17 #90 #129 #144
|
||||
|
||||
#### Setup
|
||||
- Host authenticated in Django admin: <yes/no>
|
||||
- Active category/questions present: <yes/no>
|
||||
- Participants: host + <N> players
|
||||
|
||||
#### Checks (PASS/FAIL)
|
||||
1. Lobby -> join -> start
|
||||
- Mixed-case + whitespace session code accepted: <pass/fail>
|
||||
2. One full round to scoreboard
|
||||
- submit lie -> mix -> submit guess -> calculate score -> show scoreboard: <pass/fail>
|
||||
3. Next-round + game-end sanity
|
||||
- next round transitions: <pass/fail>
|
||||
- final leaderboard visible: <pass/fail>
|
||||
|
||||
#### Evidence pointers
|
||||
- Command(s): `<exact command(s)>`
|
||||
- UI notes/screenshots/log refs: <short refs>
|
||||
- Result: <PASS/FAIL>
|
||||
- If FAIL: blocker issue link + shortest repro
|
||||
```
|
||||
|
||||
## Anti-stall minimum
|
||||
Hvis der ikke er ny kode at ændre, er denne artifact-skabelon den mindste gyldige leverance for at sikre ensartet, reviewbar smoke-evidens i staging.
|
||||
12
infra/env/.env.staging.example
vendored
Normal file
12
infra/env/.env.staging.example
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
DJANGO_SECRET_KEY=change-me-staging
|
||||
DJANGO_DEBUG=false
|
||||
DJANGO_ALLOWED_HOSTS=staging.party.weircon.dk
|
||||
DB_ENGINE=django.db.backends.mysql
|
||||
DB_NAME=wpp_staging
|
||||
DB_USER=wpp_staging
|
||||
DB_PASSWORD=change-me
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
TEST_DB_NAME=
|
||||
CHANNEL_REDIS_HOST=127.0.0.1
|
||||
CHANNEL_REDIS_PORT=6379
|
||||
@@ -4,15 +4,18 @@
|
||||
|
||||
## Databaser
|
||||
- `wpp_test`
|
||||
- `wpp_staging`
|
||||
- `wpp_prod`
|
||||
|
||||
## Brugere
|
||||
- `wpp_test_user` (least privilege på `wpp_test`)
|
||||
- `wpp_staging_user` (least privilege på `wpp_staging`)
|
||||
- `wpp_prod_user` (least privilege på `wpp_prod`)
|
||||
|
||||
## Secrets placering
|
||||
I Secrets-repo:
|
||||
- `wpp/wpp_test.env`
|
||||
- `wpp/wpp_staging.env`
|
||||
- `wpp/wpp_prod.env`
|
||||
|
||||
Forventede felter:
|
||||
|
||||
@@ -8,6 +8,7 @@ Staging-miljø for WPP i Proxmox LXC, så release-klar kode kan deployes og smok
|
||||
- App path: /opt/wpp-staging/app
|
||||
- Service: wpp-staging.service
|
||||
- Health endpoint: GET /healthz
|
||||
- Database: MySQL (staging må ikke bruge SQLite, issue #133)
|
||||
|
||||
## Verifikation
|
||||
Kør fra devops-shell med Proxmox-adgang:
|
||||
@@ -21,6 +22,12 @@ Forventet:
|
||||
- service er active
|
||||
- healthz returnerer JSON med ok=true
|
||||
|
||||
Smoke-suite skriver nu et gameplay-artifact som JSON under `/opt/wpp-staging/app/artifacts/smoke/` (kan overrides via `ARTIFACT_DIR`/`ARTIFACT_FILE`).
|
||||
|
||||
Efter deploy validerer scriptet, at `DB_ENGINE` ikke er `django.db.backends.sqlite3` før migrations køres.
|
||||
|
||||
Deploy-scriptet bruger en release-candidate mappe og promoverer først til `/opt/wpp-staging/app` efter succesfuld `migrate`. Det reducerer schema/code drift ved afbrudte deploys (issue #130) og understøtter release-readiness gate (issue #90).
|
||||
|
||||
## Deploy (canonical execution context)
|
||||
Deploy skal altid køres via Proxmox host over SSH (ikke fra lokal coder-shell med direkte sudo pct).
|
||||
|
||||
|
||||
@@ -9,20 +9,88 @@ PROXMOX_HOST="${PROXMOX_HOST:-proxmox-lan}"
|
||||
echo "[deploy] host=${PROXMOX_HOST} CT_ID=${CT_ID} REF=${REF_NAME}"
|
||||
|
||||
echo "[deploy] extracting source + installing deps + migrate + restart"
|
||||
ssh "${PROXMOX_HOST}" "sudo -n /usr/sbin/pct exec ${CT_ID} -- bash -lc \"set -euo pipefail
|
||||
mkdir -p /opt/wpp-staging/releases/src
|
||||
cd /opt/wpp-staging/releases
|
||||
curl -fsSL \\\"${ARCHIVE_URL}\\\" -o app.tar.gz
|
||||
rm -rf src && mkdir src
|
||||
tar -xzf app.tar.gz -C src --strip-components=1
|
||||
rm -rf /opt/wpp-staging/app/*
|
||||
cp -a src/. /opt/wpp-staging/app/
|
||||
cd /opt/wpp-staging/app
|
||||
ssh "${PROXMOX_HOST}" sudo -n /usr/sbin/pct exec "${CT_ID}" -- bash -s -- "${ARCHIVE_URL}" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
ARCHIVE_URL="$1"
|
||||
RELEASES_DIR=/opt/wpp-staging/releases
|
||||
CANDIDATE_DIR="${RELEASES_DIR}/src"
|
||||
APP_DIR=/opt/wpp-staging/app
|
||||
|
||||
install -d -m 0755 -o wpp -g wpp "${RELEASES_DIR}" "${APP_DIR}"
|
||||
mkdir -p "${CANDIDATE_DIR}"
|
||||
cd "${RELEASES_DIR}"
|
||||
curl -fsSL "${ARCHIVE_URL}" -o app.tar.gz
|
||||
rm -rf "${CANDIDATE_DIR}" && mkdir -p "${CANDIDATE_DIR}"
|
||||
tar -xzf app.tar.gz -C "${CANDIDATE_DIR}" --strip-components=1
|
||||
|
||||
# Ensure deploy artifact copied as root does not leave candidate tree non-writable for wpp.
|
||||
chown -R wpp:wpp "${CANDIDATE_DIR}"
|
||||
# Staging must not run on SQLite (issue #133). Remove bundled sqlite artifact from candidate.
|
||||
rm -f "${CANDIDATE_DIR}/db.sqlite3"
|
||||
cd "${CANDIDATE_DIR}"
|
||||
|
||||
# Load staging env before any manage.py call (issue #133 follow-up).
|
||||
ENV_LOADED=0
|
||||
for ENV_FILE in \
|
||||
/opt/wpp-staging/.env.staging \
|
||||
/opt/wpp-staging/.env \
|
||||
/opt/wpp-staging/env/wpp_staging.env \
|
||||
/opt/wpp-staging/secrets/wpp_staging.env
|
||||
do
|
||||
if [ -f "${ENV_FILE}" ]; then
|
||||
set -a
|
||||
. "${ENV_FILE}"
|
||||
set +a
|
||||
echo "[deploy] loaded staging env: ${ENV_FILE}"
|
||||
ENV_LOADED=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "${ENV_LOADED}" -ne 1 ]; then
|
||||
echo "[deploy] ERROR: no staging env file found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
runuser -u wpp -- python3 -m venv .venv
|
||||
runuser -u wpp -- .venv/bin/pip install -U pip >/dev/null
|
||||
runuser -u wpp -- .venv/bin/pip install -r requirements.txt >/dev/null
|
||||
runuser -u wpp -- .venv/bin/python manage.py shell -c "from django.conf import settings; import sys; engine = settings.DATABASES['default']['ENGINE']; print(f'DB_ENGINE={engine}'); sys.exit(0 if engine != 'django.db.backends.sqlite3' else 1)"
|
||||
runuser -u wpp -- .venv/bin/python manage.py migrate --noinput
|
||||
|
||||
# Promote candidate only after migrations succeed (issue #130): avoid code/schema drift after failed deploy.
|
||||
# Use find instead of rm "${APP_DIR}"/* to reliably remove dotfiles between deploys.
|
||||
find "${APP_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
||||
cp -a "${CANDIDATE_DIR}"/. "${APP_DIR}/"
|
||||
chown -R wpp:wpp "${APP_DIR}"
|
||||
runuser -u wpp -- test -w "${APP_DIR}"
|
||||
|
||||
systemctl restart wpp-staging.service
|
||||
curl -fsS http://127.0.0.1:8000/healthz\""
|
||||
|
||||
HEALTH_URL="http://127.0.0.1:8000/healthz"
|
||||
MAX_ATTEMPTS=7
|
||||
SLEEP_SECONDS=1
|
||||
ATTEMPT=1
|
||||
|
||||
until curl -fsS "${HEALTH_URL}" >/dev/null; do
|
||||
if [ "${ATTEMPT}" -ge "${MAX_ATTEMPTS}" ]; then
|
||||
echo "[deploy] ERROR: health check failed after ${MAX_ATTEMPTS} attempts: ${HEALTH_URL}" >&2
|
||||
echo "[deploy] service status (wpp-staging.service):" >&2
|
||||
systemctl --no-pager --full status wpp-staging.service >&2 || true
|
||||
echo "[deploy] recent service logs (wpp-staging.service):" >&2
|
||||
journalctl --no-pager -u wpp-staging.service -n 80 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[deploy] health check not ready (attempt ${ATTEMPT}/${MAX_ATTEMPTS}); retrying in ${SLEEP_SECONDS}s"
|
||||
sleep "${SLEEP_SECONDS}"
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
if [ "${SLEEP_SECONDS}" -lt 8 ]; then
|
||||
SLEEP_SECONDS=$((SLEEP_SECONDS * 2))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[deploy] health check passed: ${HEALTH_URL}"
|
||||
REMOTE
|
||||
|
||||
echo "[deploy] OK: staging deploy complete for CT ${CT_ID} (${REF_NAME})"
|
||||
|
||||
@@ -50,10 +50,30 @@ PY
|
||||
echo "[smoke] healthz check: ${BASE_URL}/healthz"
|
||||
curl -fsS "${BASE_URL}/healthz" >/dev/null || { SMOKE_FAIL_MESSAGE="healthz check failed" fail "healthz check failed"; }
|
||||
|
||||
echo "[smoke] gameplay flow via management command"
|
||||
(
|
||||
cd "${APP_DIR}"
|
||||
.venv/bin/python manage.py smoke_staging
|
||||
) || { SMOKE_FAIL_MESSAGE="manage.py smoke_staging failed" fail "manage.py smoke_staging failed"; }
|
||||
ENV_FILE="${ENV_FILE:-/etc/wpp/staging.env}"
|
||||
|
||||
run_manage() {
|
||||
local cmd="$1"
|
||||
(
|
||||
cd "${APP_DIR}"
|
||||
if [[ -f "${ENV_FILE}" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "${ENV_FILE}"
|
||||
set +a
|
||||
fi
|
||||
.venv/bin/python manage.py ${cmd}
|
||||
)
|
||||
}
|
||||
|
||||
echo "[smoke] migration consistency check"
|
||||
run_manage "migrate --check --noinput" || { SMOKE_FAIL_MESSAGE="schema drift: unapplied migrations in staging" fail "schema drift: unapplied migrations in staging"; }
|
||||
|
||||
ARTIFACT_DIR="${ARTIFACT_DIR:-${APP_DIR}/artifacts/smoke}"
|
||||
ARTIFACT_FILE="${ARTIFACT_FILE:-${ARTIFACT_DIR}/smoke-$(date -u +%Y%m%dT%H%M%SZ).json}"
|
||||
|
||||
echo "[smoke] gameplay flow via management command"
|
||||
run_manage "smoke_staging --artifact ${ARTIFACT_FILE}" || { SMOKE_FAIL_MESSAGE="manage.py smoke_staging failed" fail "manage.py smoke_staging failed"; }
|
||||
|
||||
echo "[smoke] artifact: ${ARTIFACT_FILE}"
|
||||
echo "[smoke] OK"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
@@ -10,6 +12,12 @@ from fupogfakta.models import Category, GameSession, Player, Question, RoundQues
|
||||
class Command(BaseCommand):
|
||||
help = "Run minimal staging smoke flow for lobby gameplay"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--artifact",
|
||||
help="Optional path to write smoke result artifact as JSON",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
GameSession.objects.all().delete()
|
||||
Player.objects.all().delete()
|
||||
@@ -72,7 +80,13 @@ class Command(BaseCommand):
|
||||
nick = player["nickname"]
|
||||
lie_res = Client().post(
|
||||
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
|
||||
data=json.dumps({"player_id": player["id"], "text": f"Lie from {nick}"}),
|
||||
data=json.dumps(
|
||||
{
|
||||
"player_id": player["id"],
|
||||
"session_token": player["session_token"],
|
||||
"text": f"Lie from {nick}",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
if lie_res.status_code != 201:
|
||||
@@ -94,7 +108,13 @@ class Command(BaseCommand):
|
||||
selected = next((a for a in answers if a.get("player_id") != player["id"]), answers[0])
|
||||
guess_res = Client().post(
|
||||
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
|
||||
data=json.dumps({"player_id": player["id"], "selected_text": selected["text"]}),
|
||||
data=json.dumps(
|
||||
{
|
||||
"player_id": player["id"],
|
||||
"session_token": player["session_token"],
|
||||
"selected_text": selected["text"],
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
if guess_res.status_code != 201:
|
||||
@@ -115,4 +135,30 @@ class Command(BaseCommand):
|
||||
if finish_res.status_code != 200:
|
||||
raise CommandError(f"finish_game failed: {finish_res.status_code}")
|
||||
|
||||
artifact_path = options.get("artifact")
|
||||
if artifact_path:
|
||||
artifact = {
|
||||
"ok": True,
|
||||
"command": "smoke_staging",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"session_code": code,
|
||||
"players": [player["nickname"] for player in players],
|
||||
"round_question_id": round_question_id,
|
||||
"steps": [
|
||||
"create_session",
|
||||
"join_players",
|
||||
"start_round",
|
||||
"show_question",
|
||||
"submit_lies",
|
||||
"mix_answers",
|
||||
"submit_guesses",
|
||||
"calculate_scores",
|
||||
"reveal_scoreboard",
|
||||
"finish_game",
|
||||
],
|
||||
}
|
||||
output_path = Path(artifact_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Smoke flow OK for session {code}"))
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
#joinStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
|
||||
#contextLockHint { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
|
||||
#phaseStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
|
||||
#connectionBanner { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid #b91c1c; background: #fee2e2; color: #7f1d1d; display: none; }
|
||||
#connectionBanner button { margin-left: 8px; border: 1px solid #991b1b; background: #fff; color: #7f1d1d; border-radius: 6px; padding: 4px 8px; cursor: pointer; }
|
||||
#connectionBanner button[disabled] { opacity: 0.55; cursor: not-allowed; }
|
||||
#lieStatus.locked { color: #0a5f2d; font-weight: 600; }
|
||||
#guessStatus.locked { color: #0a5f2d; font-weight: 600; }
|
||||
</style>
|
||||
@@ -38,6 +41,7 @@
|
||||
<p id="playerLastRefreshStatus">Session-data ikke opdateret endnu.</p>
|
||||
<p id="phaseStatus">Fase: ukendt (opdatér session-status).</p>
|
||||
<p id="playerErrorHint">Ingen fejl.</p>
|
||||
<div id="connectionBanner">Forbindelsen til serveren blev afbrudt.<button id="connectionRetryBtn" type="button" onclick="retryConnection()">Prøv igen</button></div>
|
||||
<pre id="out">Klar.</pre>
|
||||
<script>
|
||||
var availableAnswers=[];
|
||||
@@ -53,6 +57,8 @@ var playerAutoRefreshTimer=null;
|
||||
var sessionDetailInFlight=false;
|
||||
var playerLastRefreshAtLabel="";
|
||||
var playerLastRefreshFailed=false;
|
||||
var connectionLost=false;
|
||||
var connectionRetryInFlight=false;
|
||||
function code(){return document.getElementById("code").value.trim().toUpperCase();}
|
||||
function pid(){return document.getElementById("playerId").value.trim();}
|
||||
function rq(){return document.getElementById("roundQuestionId").value.trim();}
|
||||
@@ -75,11 +81,13 @@ function markPlayerSessionRefresh(status){if(status>=200&&status<300){playerLast
|
||||
function updatePlayerLastRefreshStatus(){var el=document.getElementById("playerLastRefreshStatus");if(!el){return;}if(!playerLastRefreshAtLabel){el.textContent=playerLastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(playerLastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+playerLastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+playerLastRefreshAtLabel+".";}
|
||||
function updatePlayerAutoRefreshUi(){var btn=document.getElementById("playerAutoRefreshToggleBtn");var hint=document.getElementById("playerAutoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(playerAutoRefreshEnabled?"ON":"OFF");btn.disabled=sessionDetailInFlight||joinInFlight||!code();}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(joinInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv join.";return;}if(!code()){hint.textContent="Auto-refresh kræver sessionkode.";return;}if(!playerAutoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for spillerstatus.";}
|
||||
function stopPlayerAutoRefresh(reason){playerAutoRefreshEnabled=false;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);playerAutoRefreshTimer=null;}if(reason){var hint=document.getElementById("playerAutoRefreshHint");if(hint){hint.textContent=reason;}}updatePlayerAutoRefreshUi();savePlayerContext();}
|
||||
function startPlayerAutoRefresh(){if(!code()){updatePlayerAutoRefreshUi();return;}playerAutoRefreshEnabled=true;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);}playerAutoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail();},10000);updatePlayerAutoRefreshUi();savePlayerContext();}
|
||||
function startPlayerAutoRefresh(){if(!code()){updatePlayerAutoRefreshUi();return;}playerAutoRefreshEnabled=true;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);}playerAutoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail().catch(function(){});},10000);updatePlayerAutoRefreshUi();savePlayerContext();}
|
||||
function togglePlayerAutoRefresh(){if(joinInFlight){updatePlayerAutoRefreshUi();return;}if(playerAutoRefreshEnabled){stopPlayerAutoRefresh();return;}startPlayerAutoRefresh();}
|
||||
function updatePlayerErrorHint(status,data){var el=document.getElementById("playerErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
|
||||
function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionRefreshHint");var submitInFlight=lieSubmitInFlight||guessSubmitInFlight;if(btn){btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code();}if(!hint){updatePlayerAutoRefreshUi();return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";updatePlayerAutoRefreshUi();return;}if(joinInFlight){hint.textContent="Session-opdatering er låst mens join kører.";updatePlayerAutoRefreshUi();return;}if(submitInFlight){hint.textContent="Session-opdatering er låst mens submit kører.";updatePlayerAutoRefreshUi();return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updatePlayerAutoRefreshUi();return;}hint.textContent="Session-opdatering klar.";updatePlayerAutoRefreshUi();}
|
||||
function updateJoinState(){var btn=document.getElementById("joinBtn");var status=document.getElementById("joinStatus");var joined=!!(pid()&&document.getElementById("sessionToken").value.trim());var canJoin=canAttemptJoin();if(btn){btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin;}if(!status){updateContextLockState();return;}if(joinInFlight){status.textContent="Joiner…";updateContextLockState();return;}if(sessionDetailInFlight&&!joined){status.textContent="Afvent aktiv session-opdatering før join.";updateContextLockState();return;}if(joined){status.textContent="Join gennemført.";updateContextLockState();return;}if(!canJoin){status.textContent="Udfyld kode og nickname for at join.";updateContextLockState();return;}status.textContent="Klar til join.";updateContextLockState();}
|
||||
function updateConnectionBanner(){var banner=document.getElementById("connectionBanner");var retryBtn=document.getElementById("connectionRetryBtn");if(!banner||!retryBtn){return;}banner.style.display=connectionLost?"block":"none";retryBtn.disabled=connectionRetryInFlight||sessionDetailInFlight||joinInFlight||!code();}
|
||||
function setConnectionLost(isLost){connectionLost=!!isLost;updateConnectionBanner();}
|
||||
function updatePlayerErrorHint(status,data){var el=document.getElementById("playerErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";setConnectionLost(false);return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
|
||||
function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionRefreshHint");var submitInFlight=lieSubmitInFlight||guessSubmitInFlight;if(btn){btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code();}if(!hint){updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(joinInFlight){hint.textContent="Session-opdatering er låst mens join kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(submitInFlight){hint.textContent="Session-opdatering er låst mens submit kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}hint.textContent="Session-opdatering klar.";updatePlayerAutoRefreshUi();updateConnectionBanner();}
|
||||
function updateJoinState(){var btn=document.getElementById("joinBtn");var status=document.getElementById("joinStatus");var joined=!!(pid()&&document.getElementById("sessionToken").value.trim());var canJoin=canAttemptJoin();if(btn){btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin;}if(!status){updateContextLockState();updateConnectionBanner();return;}if(joinInFlight){status.textContent="Joiner…";updateContextLockState();updateConnectionBanner();return;}if(sessionDetailInFlight&&!joined){status.textContent="Afvent aktiv session-opdatering før join.";updateContextLockState();updateConnectionBanner();return;}if(joined){status.textContent="Join gennemført.";updateContextLockState();updateConnectionBanner();return;}if(!canJoin){status.textContent="Udfyld kode og nickname for at join.";updateContextLockState();updateConnectionBanner();return;}status.textContent="Klar til join.";updateContextLockState();updateConnectionBanner();}
|
||||
|
||||
function guessStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppGuess",c,p,q].join(":");}
|
||||
function lieStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppLie",c,p,q].join(":");}
|
||||
@@ -94,9 +102,10 @@ function updateGuessSubmitState(){var selected=document.getElementById("guessTex
|
||||
function setGuess(text,submitted){document.getElementById("guessText").value=text||"";if(typeof submitted==="boolean"){guessSubmitted=submitted;}var buttons=document.querySelectorAll("#answerOptions button");buttons.forEach(function(btn){btn.classList.toggle("active",btn.dataset.answer===text);});updateGuessSubmitState();
|
||||
updateJoinState();}
|
||||
function renderAnswerOptions(roundQuestion){var wrap=document.getElementById("answerOptions");wrap.innerHTML="";availableAnswers=[];guessSubmitted=false;setGuess("",false);lieSubmitted=false;setLieState("",false);if(!roundQuestion||!Array.isArray(roundQuestion.answers)){updateGuessSubmitState();updateLieSubmitState();return;}roundQuestion.answers.forEach(function(item){if(!item||!item.text){return;}availableAnswers.push(item.text);var btn=document.createElement("button");btn.type="button";btn.dataset.answer=item.text;btn.textContent=item.text;btn.onclick=function(){if(guessSubmitted){return;}setGuess(item.text,false);persistGuessState(item.text,false);};wrap.appendChild(btn);});var saved=loadGuessState();if(saved&&availableAnswers.indexOf(saved.selected_text)!==-1){setGuess(saved.selected_text,!!saved.submitted);}updateGuessSubmitState();}
|
||||
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markPlayerSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.player&&d.player.id){document.getElementById("playerId").value=d.player.id;}if(d.player&&d.player.session_token){document.getElementById("sessionToken").value=d.player.session_token;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question){renderAnswerOptions(d.round_question);var savedLie=loadLieState();if(savedLie){setLieState(savedLie.text||"",!!savedLie.submitted);}}else{document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);}if(d.guess&&d.guess.round_question_id){document.getElementById("roundQuestionId").value=d.guess.round_question_id;setGuess(d.guess.selected_text||"",true);persistGuessState(d.guess.selected_text||"",true);}updateRoundContextHint();updatePlayerErrorHint(r.status,d);updatePhaseStatus();updateLieSubmitState();updateGuessSubmitState();if(currentSessionStatus==="finished"&&playerAutoRefreshEnabled){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updatePlayerAutoRefreshUi();}savePlayerContext();return d;}
|
||||
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.body=JSON.stringify(payload);}try{var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markPlayerSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.player&&d.player.id){document.getElementById("playerId").value=d.player.id;}if(d.player&&d.player.session_token){document.getElementById("sessionToken").value=d.player.session_token;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question){renderAnswerOptions(d.round_question);var savedLie=loadLieState();if(savedLie){setLieState(savedLie.text||"",!!savedLie.submitted);}}else{document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);}if(d.guess&&d.guess.round_question_id){document.getElementById("roundQuestionId").value=d.guess.round_question_id;setGuess(d.guess.selected_text||"",true);persistGuessState(d.guess.selected_text||"",true);}updateRoundContextHint();updatePlayerErrorHint(r.status,d);updatePhaseStatus();updateLieSubmitState();updateGuessSubmitState();if(currentSessionStatus==="finished"&&playerAutoRefreshEnabled){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updatePlayerAutoRefreshUi();}savePlayerContext();return d;}catch(err){setConnectionLost(true);if((method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path)){markPlayerSessionRefresh(0);}document.getElementById("out").textContent=JSON.stringify({status:0,data:{error:"connection_lost",detail:"Kunne ikke kontakte serveren."}},null,2);document.getElementById("playerErrorHint").textContent="Fejl: Mistede forbindelsen til serveren. Prøv igen.";updateSessionDetailState();throw err;}}
|
||||
function joinSession(){if(joinInFlight){return Promise.resolve({error:"join_in_flight"});}if(!canAttemptJoin()){updateJoinState();return Promise.resolve({error:"missing_join_input"});}if(pid()&&document.getElementById("sessionToken").value.trim()){updateJoinState();return Promise.resolve({error:"already_joined_client"});}joinInFlight=true;updateJoinState();updatePlayerAutoRefreshUi();return api("/lobby/sessions/join","POST",{code:code(),nickname:document.getElementById("nickname").value.trim()}).then(function(d){joinInFlight=false;if(d&&d.player&&d.player.id){updateJoinState();updatePlayerAutoRefreshUi();return d;}updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede – prøv igen.";return d;}).catch(function(err){joinInFlight=false;updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede – prøv igen.";throw err;});}
|
||||
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
|
||||
function retryConnection(){if(connectionRetryInFlight||!code()){updateConnectionBanner();return Promise.resolve({error:"retry_unavailable"});}connectionRetryInFlight=true;updateConnectionBanner();return sessionDetail().then(function(result){setConnectionLost(false);return result;}).finally(function(){connectionRetryInFlight=false;updateConnectionBanner();});}
|
||||
function submitLie(){if(lieSubmitted){return Promise.resolve({error:"lie_already_submitted_client"});}if(lieSubmitInFlight){return Promise.resolve({error:"lie_submit_in_flight"});}if(!hasSubmitContext()){updateLieSubmitState();return Promise.resolve({error:"missing_submit_context"});}var text=(document.getElementById("lieText").value||"").trim();if(!text){updateLieSubmitState();return Promise.resolve({error:"empty_lie_text"});}lieSubmitInFlight=true;updateLieSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/lies/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,text:text}).then(function(d){if(d&&d.lie&&d.lie.id){lieSubmitted=true;persistLieState(text,true);}return d;}).finally(function(){lieSubmitInFlight=false;updateLieSubmitState();});}
|
||||
document.getElementById("lieText").addEventListener("input",function(){if(!lieSubmitted){updateLieSubmitState();persistLieState(document.getElementById("lieText").value,false);}});updateLieSubmitState();
|
||||
function submitGuess(){if(guessSubmitted){return Promise.resolve({error:"guess_already_submitted_client"});}if(guessSubmitInFlight){return Promise.resolve({error:"guess_submit_in_flight"});}if(!hasSubmitContext()){updateGuessSubmitState();document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Join først for at aktivere gæt"}},null,2);return Promise.resolve({error:"missing_submit_context"});}var selected=document.getElementById("guessText").value;if(availableAnswers.indexOf(selected)===-1){document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Vælg et af de viste svarmuligheder"}},null,2);return Promise.resolve({error:"invalid_client_guess"});}guessSubmitInFlight=true;updateGuessSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/guesses/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,selected_text:selected}).finally(function(){guessSubmitInFlight=false;updateGuessSubmitState();});}
|
||||
@@ -107,6 +116,7 @@ updateJoinState();
|
||||
updatePlayerAutoRefreshUi();
|
||||
updatePlayerLastRefreshStatus();
|
||||
updateRoundContextHint();
|
||||
if(restorePlayerContext()){if(playerAutoRefreshEnabled){startPlayerAutoRefresh();}sessionDetail();}else{savePlayerContext();}
|
||||
updateConnectionBanner();
|
||||
if(restorePlayerContext()){if(playerAutoRefreshEnabled){startPlayerAutoRefresh();}sessionDetail().catch(function(){});}else{savePlayerContext();}
|
||||
</script>
|
||||
</body></html>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import json
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
@@ -59,6 +63,28 @@ class LobbyFlowTests(TestCase):
|
||||
self.assertTrue(body["player"]["session_token"])
|
||||
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
|
||||
|
||||
def test_player_can_join_with_trimmed_code(self):
|
||||
session = GameSession.objects.create(host=self.host, code="ABCD23")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:join_session"),
|
||||
data={"code": " abcd23 ", "nickname": "Luna"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
|
||||
|
||||
def test_join_rejects_code_empty_after_trim(self):
|
||||
response = self.client.post(
|
||||
reverse("lobby:join_session"),
|
||||
data={"code": " ", "nickname": "Luna"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Session code is required")
|
||||
|
||||
def test_join_rejects_duplicate_nickname_case_insensitive(self):
|
||||
session = GameSession.objects.create(host=self.host, code="QWER12")
|
||||
Player.objects.create(session=session, nickname="Luna")
|
||||
@@ -130,6 +156,21 @@ class StartRoundTests(TestCase):
|
||||
round_config = RoundConfig.objects.get(session=self.session, number=1)
|
||||
self.assertEqual(round_config.category, self.category)
|
||||
|
||||
def test_host_start_round_uses_normalized_session_code_from_path(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": " abcd23 "}),
|
||||
data={"category_slug": self.category.slug},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.session.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||
round_config = RoundConfig.objects.get(session=self.session, number=1)
|
||||
self.assertEqual(round_config.category, self.category)
|
||||
|
||||
def test_start_round_requires_host(self):
|
||||
self.client.login(username="other", password="secret123")
|
||||
|
||||
@@ -825,6 +866,19 @@ class UiScreenTests(TestCase):
|
||||
self.assertContains(response, "Round question-id er låst mens session-opdatering kører.")
|
||||
self.assertContains(response, "Kategori er låst mens session-opdatering kører.")
|
||||
self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==")
|
||||
self.assertContains(response, "hostShellRouteFromPath")
|
||||
self.assertContains(response, "syncHostShellRoute")
|
||||
self.assertContains(response, "Deep-link route guard: omdirigeret")
|
||||
|
||||
def test_host_screen_deeplink_requires_login(self):
|
||||
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_host_screen_deeplink_renders_for_logged_in_user(self):
|
||||
self.client.login(username="host_ui", password="secret123")
|
||||
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Host panel")
|
||||
|
||||
def test_player_screen_is_public(self):
|
||||
response = self.client.get(reverse("lobby:player_screen"))
|
||||
@@ -857,6 +911,11 @@ class UiScreenTests(TestCase):
|
||||
self.assertContains(response, "Udfyld kode og nickname for at join.")
|
||||
self.assertContains(response, "Afvent aktiv session-opdatering før join.")
|
||||
self.assertContains(response, "btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin")
|
||||
self.assertContains(response, "id=\"connectionBanner\"")
|
||||
self.assertContains(response, "id=\"connectionRetryBtn\"")
|
||||
self.assertContains(response, "retryConnection")
|
||||
self.assertContains(response, "setConnectionLost")
|
||||
self.assertContains(response, "connection_lost")
|
||||
self.assertContains(response, "id=\"contextLockHint\"")
|
||||
self.assertContains(response, "updateContextLockState")
|
||||
self.assertContains(response, "Spillerkontekst er låst efter join.")
|
||||
@@ -925,3 +984,40 @@ class SessionDetailRoundQuestionTests(TestCase):
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["round_question"]["id"], round_question.id)
|
||||
self.assertEqual(payload["round_question"]["prompt"], self.question.prompt)
|
||||
|
||||
|
||||
class SmokeStagingCommandTests(TestCase):
|
||||
def test_smoke_staging_command_runs_full_flow(self):
|
||||
call_command("smoke_staging")
|
||||
|
||||
session = GameSession.objects.latest("created_at")
|
||||
self.assertEqual(session.status, GameSession.Status.FINISHED)
|
||||
self.assertEqual(Player.objects.filter(session=session).count(), 3)
|
||||
|
||||
def test_smoke_staging_writes_artifact_when_requested(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
artifact_path = Path(tmp_dir) / "smoke.json"
|
||||
call_command("smoke_staging", artifact=str(artifact_path))
|
||||
|
||||
self.assertTrue(artifact_path.exists())
|
||||
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertEqual(payload["command"], "smoke_staging")
|
||||
self.assertEqual(payload["players"], ["P1", "P2", "P3"])
|
||||
self.assertIn("generated_at", payload)
|
||||
self.assertIn("session_code", payload)
|
||||
self.assertEqual(
|
||||
payload["steps"],
|
||||
[
|
||||
"create_session",
|
||||
"join_players",
|
||||
"start_round",
|
||||
"show_question",
|
||||
"submit_lies",
|
||||
"mix_answers",
|
||||
"submit_guesses",
|
||||
"calculate_scores",
|
||||
"reveal_scoreboard",
|
||||
"finish_game",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from fupogfakta.models import Category
|
||||
|
||||
|
||||
@login_required
|
||||
def host_screen(request):
|
||||
def host_screen(request, spa_path=None):
|
||||
categories = Category.objects.filter(is_active=True).order_by("name")
|
||||
return render(request, "lobby/host_screen.html", {"categories": categories})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ app_name = "lobby"
|
||||
|
||||
urlpatterns = [
|
||||
path("ui/host", ui_views.host_screen, name="host_screen"),
|
||||
path("ui/host/<path:spa_path>", ui_views.host_screen, name="host_screen_deeplink"),
|
||||
path("ui/player", ui_views.player_screen, name="player_screen"),
|
||||
path("sessions/create", views.create_session, name="create_session"),
|
||||
path("sessions/join", views.join_session, name="join_session"),
|
||||
|
||||
@@ -45,6 +45,10 @@ def _generate_session_code() -> str:
|
||||
return "".join(random.choices(SESSION_CODE_ALPHABET, k=SESSION_CODE_LENGTH))
|
||||
|
||||
|
||||
def _normalize_session_code(code: str) -> str:
|
||||
return code.strip().upper()
|
||||
|
||||
|
||||
def _create_unique_session_code() -> str:
|
||||
for _ in range(MAX_CODE_GENERATION_ATTEMPTS):
|
||||
code = _generate_session_code()
|
||||
@@ -77,7 +81,7 @@ def create_session(request: HttpRequest) -> JsonResponse:
|
||||
def join_session(request: HttpRequest) -> JsonResponse:
|
||||
payload = _json_body(request)
|
||||
|
||||
code = str(payload.get("code", "")).strip().upper()
|
||||
code = _normalize_session_code(str(payload.get("code", "")))
|
||||
nickname = str(payload.get("nickname", "")).strip()
|
||||
|
||||
if not code:
|
||||
@@ -118,7 +122,7 @@ def join_session(request: HttpRequest) -> JsonResponse:
|
||||
|
||||
@require_GET
|
||||
def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session_code = code.strip().upper()
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
@@ -175,7 +179,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
if not category_slug:
|
||||
return JsonResponse({"error": "category_slug is required"}, status=400)
|
||||
|
||||
session_code = code.strip().upper()
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
@@ -234,7 +238,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
@require_POST
|
||||
@login_required
|
||||
def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session_code = code.strip().upper()
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
@@ -294,7 +298,7 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
||||
@require_POST
|
||||
def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
|
||||
payload = _json_body(request)
|
||||
session_code = code.strip().upper()
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
player_id = payload.get("player_id")
|
||||
session_token = str(payload.get("session_token", "")).strip()
|
||||
@@ -367,7 +371,7 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
|
||||
@require_POST
|
||||
@login_required
|
||||
def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
|
||||
session_code = code.strip().upper()
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
@@ -437,7 +441,7 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
|
||||
@require_POST
|
||||
def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
|
||||
payload = _json_body(request)
|
||||
session_code = code.strip().upper()
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
player_id = payload.get("player_id")
|
||||
session_token = str(payload.get("session_token", "")).strip()
|
||||
@@ -543,7 +547,7 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
|
||||
@require_GET
|
||||
@login_required
|
||||
def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session_code = code.strip().upper()
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
@@ -577,7 +581,7 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
|
||||
@require_POST
|
||||
@login_required
|
||||
def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session_code = code.strip().upper()
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
@@ -609,7 +613,7 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
@require_POST
|
||||
@login_required
|
||||
def finish_game(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session_code = code.strip().upper()
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
@@ -651,7 +655,7 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
|
||||
@require_POST
|
||||
@login_required
|
||||
def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
|
||||
session_code = code.strip().upper()
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
|
||||
Reference in New Issue
Block a user