Compare commits

..

2 Commits

Author SHA1 Message Date
ryan
02fcbad9ba Add migrations troubleshooting guide 2026-01-24 10:19:11 -05:00
ryan
d4345a4e09 Adding completed_at and winners to g ames 2026-01-24 09:57:25 -05:00
7 changed files with 322 additions and 27 deletions

147
MIGRATIONS.md Normal file
View File

@@ -0,0 +1,147 @@
# Database Migrations Guide
This document covers the proper workflow for database migrations and how to fix common issues.
## Migration Workflow Checklist
When making model changes, **always follow this order**:
### 1. Make model changes
Edit `backend/models.py` with your changes.
### 2. Create the migration
```bash
# Local development
uv run flask --app backend.app:create_app db migrate -m "Description of changes"
# Docker development
docker compose exec backend uv run flask --app backend.app:create_app db migrate -m "Description of changes"
```
### 3. Review the generated migration
Check `migrations/versions/` for the new file. Verify it does what you expect.
### 4. Apply locally and test
```bash
uv run flask --app backend.app:create_app db upgrade
```
### 5. Commit BOTH the model AND migration together
```bash
git add backend/models.py migrations/versions/
git commit -m "Add field_name to Model"
```
**CRITICAL: Never commit model changes without the corresponding migration file!**
### 6. Deploy and run migrations on production
```bash
# Rebuild to include new migration file
docker compose -f docker-compose.production.yml up --build -d
# Apply the migration
docker compose -f docker-compose.production.yml exec backend uv run flask --app backend.app:create_app db upgrade
```
## Common Issues and Fixes
### Error: "Can't locate revision identified by 'xxxxx'"
**Cause:** The database references a migration that doesn't exist in the codebase (migration was applied but never committed).
**Fix:**
```bash
# 1. Check what revision the DB thinks it's at
docker compose -f docker-compose.production.yml exec backend python -c "
import sqlite3
conn = sqlite3.connect('backend/instance/trivia.db')
print(conn.execute('SELECT * FROM alembic_version').fetchall())
"
# 2. Find the latest valid migration in your codebase
ls migrations/versions/
# 3. Force the DB to point to a valid migration
docker compose -f docker-compose.production.yml exec backend python -c "
import sqlite3
conn = sqlite3.connect('backend/instance/trivia.db')
conn.execute(\"UPDATE alembic_version SET version_num = 'YOUR_VALID_REVISION'\")
conn.commit()
print('Done')
"
# 4. Manually add any missing columns
docker compose -f docker-compose.production.yml exec backend python -c "
import sqlite3
conn = sqlite3.connect('backend/instance/trivia.db')
conn.execute('ALTER TABLE tablename ADD COLUMN columnname TYPE')
conn.commit()
print('Column added')
"
```
### Error: "table has no column named X"
**Cause:** Model has columns that don't exist in the database.
**Fix:** Add the columns manually:
```bash
docker compose -f docker-compose.production.yml exec backend python -c "
import sqlite3
conn = sqlite3.connect('backend/instance/trivia.db')
cursor = conn.execute('PRAGMA table_info(tablename)')
print('Current columns:', [col[1] for col in cursor.fetchall()])
"
# Then add missing columns:
docker compose -f docker-compose.production.yml exec backend python -c "
import sqlite3
conn = sqlite3.connect('backend/instance/trivia.db')
conn.execute('ALTER TABLE games ADD COLUMN completed_at DATETIME')
conn.execute('ALTER TABLE games ADD COLUMN winners JSON')
conn.commit()
print('Done')
"
```
### Checking current migration state
```bash
# What migration does the DB think it's at?
docker compose -f docker-compose.production.yml exec backend uv run flask --app backend.app:create_app db current
# What's the latest migration in the codebase?
docker compose -f docker-compose.production.yml exec backend uv run flask --app backend.app:create_app db heads
# Show migration history
docker compose -f docker-compose.production.yml exec backend uv run flask --app backend.app:create_app db history
```
### Nuclear option: Reset migrations (destroys all data!)
Only use if you can recreate all data:
```bash
# Delete database
docker compose -f docker-compose.production.yml exec backend rm -f backend/instance/trivia.db
# Recreate from scratch
docker compose -f docker-compose.production.yml exec backend uv run flask --app backend.app:create_app db upgrade
```
## SQLite Column Type Reference
When adding columns manually, use these SQLite types:
- `INTEGER` - for integers, booleans
- `TEXT` or `VARCHAR(N)` - for strings
- `DATETIME` - for dates/times
- `JSON` - for JSON data
- `FLOAT` or `REAL` - for decimals
## Pre-deployment Checklist
Before deploying model changes:
- [ ] Migration file exists in `migrations/versions/`
- [ ] Migration file is committed to git
- [ ] Migration tested locally with `db upgrade` and `db downgrade`
- [ ] Model changes and migration are in the same commit

View File

@@ -114,6 +114,8 @@ class Game(db.Model):
current_question_index = db.Column(db.Integer, default=0) # Track current question current_question_index = db.Column(db.Integer, default=0) # Track current question
is_template = db.Column(db.Boolean, default=False) # Mark as reusable template is_template = db.Column(db.Boolean, default=False) # Mark as reusable template
current_turn_team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) # Track whose turn it is current_turn_team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) # Track whose turn it is
completed_at = db.Column(db.DateTime, nullable=True) # When game ended
winners = db.Column(db.JSON, nullable=True) # [{"team_name": str, "score": int}, ...]
# Relationships # Relationships
teams = db.relationship('Team', back_populates='game', cascade='all, delete-orphan', teams = db.relationship('Team', back_populates='game', cascade='all, delete-orphan',
@@ -146,7 +148,9 @@ class Game(db.Model):
'total_questions': len(self.game_questions), 'total_questions': len(self.game_questions),
'is_template': self.is_template, 'is_template': self.is_template,
'current_turn_team_id': self.current_turn_team_id, 'current_turn_team_id': self.current_turn_team_id,
'current_turn_team_name': self.current_turn_team.name if self.current_turn_team else None 'current_turn_team_name': self.current_turn_team.name if self.current_turn_team else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
'winners': self.winners
} }
if include_questions: if include_questions:
@@ -194,7 +198,7 @@ class Team(db.Model):
phone_a_friend_count = db.Column(db.Integer, default=5) # Number of phone-a-friend lifelines phone_a_friend_count = db.Column(db.Integer, default=5) # Number of phone-a-friend lifelines
# Relationships # Relationships
game = db.relationship('Game', back_populates='teams') game = db.relationship('Game', back_populates='teams', foreign_keys=[game_id])
scores = db.relationship('Score', back_populates='team', cascade='all, delete-orphan') scores = db.relationship('Score', back_populates='team', cascade='all, delete-orphan')
@property @property

View File

@@ -4,6 +4,16 @@ from backend.models import db, Team
bp = Blueprint('teams', __name__, url_prefix='/api/teams') bp = Blueprint('teams', __name__, url_prefix='/api/teams')
@bp.route('/past-names', methods=['GET'])
def get_past_team_names():
"""Get distinct team names from past games"""
past_names = db.session.query(Team.name)\
.distinct()\
.order_by(Team.name)\
.all()
return jsonify([name[0] for name in past_names]), 200
@bp.route('/<int:team_id>', methods=['DELETE']) @bp.route('/<int:team_id>', methods=['DELETE'])
def delete_team(team_id): def delete_team(team_id):
"""Delete a team""" """Delete a team"""

View File

@@ -203,20 +203,33 @@ def reset_timer(game, socketio_instance):
def end_game(game, socketio_instance): def end_game(game, socketio_instance):
"""End/deactivate a game""" """End/deactivate a game and record winners"""
from datetime import datetime
# Calculate winners (team(s) with highest score)
if game.teams:
team_scores = [(team.name, team.total_score) for team in game.teams]
if team_scores:
max_score = max(score for _, score in team_scores)
winners = [
{"team_name": name, "score": score}
for name, score in team_scores
if score == max_score
]
game.winners = winners
game.is_active = False game.is_active = False
game.completed_at = datetime.utcnow()
db.session.commit() db.session.commit()
# Emit game_ended event to all rooms # Emit game_ended event with winners
socketio_instance.emit('game_ended', { end_data = {
'game_id': game.id, 'game_id': game.id,
'game_name': game.name 'game_name': game.name,
}, room=f'game_{game.id}_contestant') 'winners': game.winners
}
socketio_instance.emit('game_ended', { socketio_instance.emit('game_ended', end_data, room=f'game_{game.id}_contestant')
'game_id': game.id, socketio_instance.emit('game_ended', end_data, room=f'game_{game.id}_admin')
'game_name': game.name
}, room=f'game_{game.id}_admin')
def restart_game(game, socketio_instance): def restart_game(game, socketio_instance):

View File

@@ -20,6 +20,8 @@ export default function ContestantView() {
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [currentTurnTeamId, setCurrentTurnTeamId] = useState(null); const [currentTurnTeamId, setCurrentTurnTeamId] = useState(null);
const [currentTurnTeamName, setCurrentTurnTeamName] = useState(null); const [currentTurnTeamName, setCurrentTurnTeamName] = useState(null);
const [questionIndex, setQuestionIndex] = useState(0);
const [totalQuestions, setTotalQuestions] = useState(0);
useEffect(() => { useEffect(() => {
// Load initial game state // Load initial game state
@@ -100,11 +102,14 @@ export default function ContestantView() {
console.log("Game started:", data); console.log("Game started:", data);
setGameName(data.game_name); setGameName(data.game_name);
setGameStartTime(new Date()); setGameStartTime(new Date());
setTotalQuestions(data.total_questions);
}); });
socket.on("question_changed", (data) => { socket.on("question_changed", (data) => {
console.log("Question changed:", data); console.log("Question changed:", data);
setCurrentQuestion(data.question); setCurrentQuestion(data.question);
setQuestionIndex(data.question_index);
setTotalQuestions(data.total_questions);
setShowAnswer(false); // Hide answer when question changes setShowAnswer(false); // Hide answer when question changes
setAnswer(""); setAnswer("");
setTimerSeconds(30); setTimerSeconds(30);
@@ -339,6 +344,17 @@ export default function ContestantView() {
/> />
</div> </div>
{totalQuestions > 0 && (
<div
style={{
fontSize: "1.5rem",
color: "#999",
marginBottom: "1rem",
}}
>
Question {questionIndex + 1} of {totalQuestions}
</div>
)}
{currentQuestion.category && ( {currentQuestion.category && (
<div <div
style={{ style={{

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { questionsAPI, gamesAPI } from "../../services/api"; import { questionsAPI, gamesAPI, teamsAPI } from "../../services/api";
import AdminNavbar from "../common/AdminNavbar"; import AdminNavbar from "../common/AdminNavbar";
export default function GameSetupView() { export default function GameSetupView() {
@@ -13,11 +13,24 @@ export default function GameSetupView() {
const [showTemplateModal, setShowTemplateModal] = useState(false); const [showTemplateModal, setShowTemplateModal] = useState(false);
const [templates, setTemplates] = useState([]); const [templates, setTemplates] = useState([]);
const [randomSelections, setRandomSelections] = useState({}); const [randomSelections, setRandomSelections] = useState({});
const [pastTeamNames, setPastTeamNames] = useState([]);
const [showTeamSuggestions, setShowTeamSuggestions] = useState(false);
const teamInputRef = useRef(null);
useEffect(() => { useEffect(() => {
loadQuestions(); loadQuestions();
loadPastTeamNames();
}, []); }, []);
const loadPastTeamNames = async () => {
try {
const response = await teamsAPI.getPastNames();
setPastTeamNames(response.data);
} catch (error) {
console.error("Error loading past team names:", error);
}
};
const loadQuestions = async () => { const loadQuestions = async () => {
try { try {
const response = await questionsAPI.getAll(); const response = await questionsAPI.getAll();
@@ -100,10 +113,12 @@ export default function GameSetupView() {
setSelectedQuestions((prev) => prev.filter((id) => id !== questionId)); setSelectedQuestions((prev) => prev.filter((id) => id !== questionId));
}; };
const addTeam = () => { const addTeam = (name = newTeamName) => {
if (newTeamName.trim()) { const teamName = name.trim();
setTeams([...teams, newTeamName.trim()]); if (teamName && !teams.includes(teamName)) {
setTeams([...teams, teamName]);
setNewTeamName(""); setNewTeamName("");
setShowTeamSuggestions(false);
} }
}; };
@@ -111,6 +126,18 @@ export default function GameSetupView() {
setTeams(teams.filter((_, i) => i !== index)); setTeams(teams.filter((_, i) => i !== index));
}; };
// Filter past team names based on input and exclude already added teams
const filteredSuggestions = pastTeamNames.filter(
(name) =>
name.toLowerCase().includes(newTeamName.toLowerCase()) &&
!teams.includes(name)
);
// Get quick-add suggestions (past names not yet added to this game)
const quickAddSuggestions = pastTeamNames
.filter((name) => !teams.includes(name))
.slice(0, 8);
const updateRandomSelection = (category, count) => { const updateRandomSelection = (category, count) => {
setRandomSelections((prev) => ({ setRandomSelections((prev) => ({
...prev, ...prev,
@@ -589,20 +616,97 @@ export default function GameSetupView() {
4. Add Teams 4. Add Teams
</h2> </h2>
<div <div
style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }} style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem", position: "relative" }}
> >
<div style={{ flex: 1, position: "relative" }}>
<input <input
ref={teamInputRef}
type="text" type="text"
value={newTeamName} value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)} onChange={(e) => {
setNewTeamName(e.target.value);
setShowTeamSuggestions(e.target.value.length > 0);
}}
onFocus={() => setShowTeamSuggestions(newTeamName.length > 0)}
onBlur={() => {
// Delay hiding to allow click on suggestion
setTimeout(() => setShowTeamSuggestions(false), 200);
}}
onKeyPress={(e) => e.key === "Enter" && addTeam()} onKeyPress={(e) => e.key === "Enter" && addTeam()}
placeholder="Enter team name" placeholder="Enter team name"
style={{ padding: "0.5rem", flex: 1 }} style={{ padding: "0.5rem", width: "100%", boxSizing: "border-box" }}
/> />
<button onClick={addTeam} style={{ padding: "0.5rem 1rem" }}> {showTeamSuggestions && filteredSuggestions.length > 0 && (
<div
style={{
position: "absolute",
top: "100%",
left: 0,
right: 0,
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
zIndex: 10,
maxHeight: "200px",
overflowY: "auto",
}}
>
{filteredSuggestions.slice(0, 8).map((name) => (
<div
key={name}
onClick={() => addTeam(name)}
style={{
padding: "0.5rem 0.75rem",
cursor: "pointer",
borderBottom: "1px solid #eee",
}}
onMouseEnter={(e) => (e.target.style.background = "#f5f5f5")}
onMouseLeave={(e) => (e.target.style.background = "white")}
>
{name}
</div>
))}
</div>
)}
</div>
<button onClick={() => addTeam()} style={{ padding: "0.5rem 1rem" }}>
Add Team Add Team
</button> </button>
</div> </div>
{quickAddSuggestions.length > 0 && (
<div style={{ marginBottom: "1rem" }}>
<p style={{ margin: "0 0 0.5rem 0", fontSize: "0.9rem", color: "#666" }}>
Quick add from past games:
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{quickAddSuggestions.map((name) => (
<button
key={name}
onClick={() => addTeam(name)}
style={{
padding: "0.4rem 0.75rem",
background: "#f5f5f5",
border: "1px solid #ddd",
borderRadius: "16px",
cursor: "pointer",
fontSize: "0.9rem",
}}
onMouseEnter={(e) => {
e.target.style.background = "#e3f2fd";
e.target.style.borderColor = "#2196F3";
}}
onMouseLeave={(e) => {
e.target.style.background = "#f5f5f5";
e.target.style.borderColor = "#ddd";
}}
>
+ {name}
</button>
))}
</div>
</div>
)}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}> <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{teams.map((team, index) => ( {teams.map((team, index) => (
<div <div

View File

@@ -85,6 +85,7 @@ export const gamesAPI = {
// Teams API // Teams API
export const teamsAPI = { export const teamsAPI = {
delete: (id) => api.delete(`/teams/${id}`), delete: (id) => api.delete(`/teams/${id}`),
getPastNames: () => api.get("/teams/past-names"),
}; };
// Admin API // Admin API