Rename 'Available Outcomes' to 'Available Bets' in extraction page

- Add AvailableBetModel with is_active and sort_order fields
- Create Migration_023_AddAvailableBetsTable with default betting options
- Add API endpoints for CRUD operations on available bets
- Update extraction page UI with add/delete functionality
- Update JavaScript to load from available_bets table instead of hardcoded outcomes
- Add modal dialog for adding new bet options
- Add delete buttons to each bet item in the UI
parent 7f1c1602
...@@ -10,7 +10,8 @@ from .models import ( ...@@ -10,7 +10,8 @@ from .models import (
ConfigurationModel, ConfigurationModel,
ApiTokenModel, ApiTokenModel,
LogEntryModel, LogEntryModel,
TemplateModel TemplateModel,
AvailableBetModel
) )
from .migrations import DatabaseMigration, run_migrations from .migrations import DatabaseMigration, run_migrations
...@@ -23,6 +24,7 @@ __all__ = [ ...@@ -23,6 +24,7 @@ __all__ = [
'ApiTokenModel', 'ApiTokenModel',
'LogEntryModel', 'LogEntryModel',
'TemplateModel', 'TemplateModel',
'AvailableBetModel',
'DatabaseMigration', 'DatabaseMigration',
'run_migrations' 'run_migrations'
] ]
\ No newline at end of file
...@@ -1722,6 +1722,91 @@ class Migration_022_AddQRCodeConfiguration(DatabaseMigration): ...@@ -1722,6 +1722,91 @@ class Migration_022_AddQRCodeConfiguration(DatabaseMigration):
logger.error(f"Failed to remove QR code configuration: {e}") logger.error(f"Failed to remove QR code configuration: {e}")
return False return False
class Migration_023_AddAvailableBetsTable(DatabaseMigration):
"""Add available_bets table for managing betting options in extraction page"""
def __init__(self):
super().__init__("023", "Add available_bets table for managing betting options")
def up(self, db_manager) -> bool:
"""Create available_bets table with default betting options"""
try:
with db_manager.engine.connect() as conn:
# Create available_bets table
conn.execute(text("""
CREATE TABLE IF NOT EXISTS available_bets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bet_name VARCHAR(50) NOT NULL UNIQUE,
description VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE NOT NULL,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""))
# Create indexes for available_bets table
indexes = [
"CREATE INDEX IF NOT EXISTS ix_available_bets_bet_name ON available_bets(bet_name)",
"CREATE INDEX IF NOT EXISTS ix_available_bets_is_active ON available_bets(is_active)",
"CREATE INDEX IF NOT EXISTS ix_available_bets_sort_order ON available_bets(sort_order)",
]
for index_sql in indexes:
conn.execute(text(index_sql))
# Insert default betting options
default_bets = [
('DRAW', 'Draw outcome', 1),
('DKO', 'Double Knockout', 2),
('X1', 'Draw No Bet - Fighter 1', 3),
('X2', 'Draw No Bet - Fighter 2', 4),
('12', '1X2 betting (1, X, 2)', 5),
('WIN1', 'Fighter 1 wins', 6),
('WIN2', 'Fighter 2 wins', 7),
('RET1', 'Fighter 1 retires', 8),
('RET2', 'Fighter 2 retires', 9),
('PTS1', 'Fighter 1 wins by points', 10),
('PTS2', 'Fighter 2 wins by points', 11),
('OVER', 'Over time limit', 12),
('UNDER', 'Under time limit', 13),
('KO1', 'Fighter 1 wins by KO', 14),
('KO2', 'Fighter 2 wins by KO', 15),
]
for bet_name, description, sort_order in default_bets:
conn.execute(text("""
INSERT OR IGNORE INTO available_bets
(bet_name, description, is_active, sort_order, created_at, updated_at)
VALUES (:bet_name, :description, 1, :sort_order, datetime('now'), datetime('now'))
"""), {
'bet_name': bet_name,
'description': description,
'sort_order': sort_order
})
conn.commit()
logger.info("Available bets table created with default betting options")
return True
except Exception as e:
logger.error(f"Failed to create available_bets table: {e}")
return False
def down(self, db_manager) -> bool:
"""Drop available_bets table"""
try:
with db_manager.engine.connect() as conn:
conn.execute(text("DROP TABLE IF EXISTS available_bets"))
conn.commit()
logger.info("Available bets table dropped")
return True
except Exception as e:
logger.error(f"Failed to drop available_bets table: {e}")
return False
# Registry of all migrations in order # Registry of all migrations in order
MIGRATIONS: List[DatabaseMigration] = [ MIGRATIONS: List[DatabaseMigration] = [
Migration_001_InitialSchema(), Migration_001_InitialSchema(),
...@@ -1746,6 +1831,7 @@ MIGRATIONS: List[DatabaseMigration] = [ ...@@ -1746,6 +1831,7 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_020_FixBetDetailsForeignKey(), Migration_020_FixBetDetailsForeignKey(),
Migration_021_AddBarcodeConfiguration(), Migration_021_AddBarcodeConfiguration(),
Migration_022_AddQRCodeConfiguration(), Migration_022_AddQRCodeConfiguration(),
Migration_023_AddAvailableBetsTable(),
] ]
......
...@@ -762,3 +762,22 @@ class BetDetailModel(BaseModel): ...@@ -762,3 +762,22 @@ class BetDetailModel(BaseModel):
def __repr__(self): def __repr__(self):
return f'<BetDetail {self.outcome}={self.amount} ({self.result}) for Match {self.match_id}>' return f'<BetDetail {self.outcome}={self.amount} ({self.result}) for Match {self.match_id}>'
class AvailableBetModel(BaseModel):
"""Available betting options for extraction system"""
__tablename__ = 'available_bets'
__table_args__ = (
Index('ix_available_bets_bet_name', 'bet_name'),
Index('ix_available_bets_is_active', 'is_active'),
Index('ix_available_bets_sort_order', 'sort_order'),
UniqueConstraint('bet_name', name='uq_available_bets_bet_name'),
)
bet_name = Column(String(50), nullable=False, unique=True, comment='Bet option name (e.g., WIN1, DRAW, X)')
description = Column(String(255), comment='Description of the bet option')
is_active = Column(Boolean, default=True, nullable=False, comment='Whether this bet option is active')
sort_order = Column(Integer, default=0, comment='Sort order for display')
def __repr__(self):
return f'<AvailableBet {self.bet_name}: {self.description}>'
...@@ -3416,6 +3416,121 @@ def save_results_config(): ...@@ -3416,6 +3416,121 @@ def save_results_config():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
# Available Bets API routes
@api_bp.route('/extraction/available-bets')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_available_bets():
"""Get all available bets for extraction"""
try:
from ..database.models import AvailableBetModel
session = api_bp.db_manager.get_session()
try:
# Get all available bets
bets = session.query(AvailableBetModel).order_by(AvailableBetModel.bet_name.asc()).all()
bets_data = [bet.to_dict() for bet in bets]
return jsonify({
"success": True,
"bets": bets_data,
"total": len(bets_data)
})
finally:
session.close()
except Exception as e:
logger.error(f"API get available bets error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/extraction/available-bets/add', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def add_available_bet():
"""Add a new available bet"""
try:
from ..database.models import AvailableBetModel
data = request.get_json() or {}
bet_name = data.get('bet_name', '').strip().upper()
description = data.get('description', '').strip()
if not bet_name:
return jsonify({"error": "bet_name is required"}), 400
if len(bet_name) > 50:
return jsonify({"error": "bet_name must be 50 characters or less"}), 400
session = api_bp.db_manager.get_session()
try:
# Check if bet already exists
existing_bet = session.query(AvailableBetModel).filter_by(bet_name=bet_name).first()
if existing_bet:
return jsonify({"error": f"Bet '{bet_name}' already exists"}), 400
# Create new bet
new_bet = AvailableBetModel(
bet_name=bet_name,
description=description or f"Bet option: {bet_name}"
)
session.add(new_bet)
session.commit()
logger.info(f"Added available bet: {bet_name}")
return jsonify({
"success": True,
"message": f"Bet '{bet_name}' added successfully",
"bet": new_bet.to_dict()
})
finally:
session.close()
except Exception as e:
logger.error(f"API add available bet error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/extraction/available-bets/delete', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def delete_available_bet():
"""Delete an available bet"""
try:
from ..database.models import AvailableBetModel
data = request.get_json() or {}
bet_name = data.get('bet_name', '').strip().upper()
if not bet_name:
return jsonify({"error": "bet_name is required"}), 400
session = api_bp.db_manager.get_session()
try:
# Find the bet
bet = session.query(AvailableBetModel).filter_by(bet_name=bet_name).first()
if not bet:
return jsonify({"error": f"Bet '{bet_name}' not found"}), 404
# Delete the bet
session.delete(bet)
session.commit()
logger.info(f"Deleted available bet: {bet_name}")
return jsonify({
"success": True,
"message": f"Bet '{bet_name}' deleted successfully"
})
finally:
session.close()
except Exception as e:
logger.error(f"API delete available bet error: {e}")
return jsonify({"error": str(e)}), 500
# Redistribution CAP API routes (admin-only) # Redistribution CAP API routes (admin-only)
@api_bp.route('/extraction/redistribution-cap') @api_bp.route('/extraction/redistribution-cap')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required @api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
......
...@@ -113,18 +113,30 @@ ...@@ -113,18 +113,30 @@
<div class="row"> <div class="row">
<!-- Left Column: Available Outcomes and Results --> <!-- Left Column: Available Outcomes and Results -->
<div class="col-md-4"> <div class="col-md-4">
<!-- Available Outcomes --> <!-- Available Bets -->
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0"> <h5 class="card-title mb-0">
<i class="fas fa-grip-vertical me-2"></i>Available Outcomes <i class="fas fa-grip-vertical me-2"></i>Available Bets
</h5> </h5>
<small class="text-muted">Drag outcomes to UNDER/OVER or Results areas</small> <small class="text-muted">Drag bets to UNDER/OVER or Results areas</small>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- Add New Bet Form -->
<div class="mb-3">
<div class="input-group">
<input type="text" class="form-control" id="new-bet-name" placeholder="Enter new bet name (e.g., WIN1, DRAW)"
maxlength="50">
<button class="btn btn-outline-success" id="btn-add-bet">
<i class="fas fa-plus me-1"></i>Add Bet
</button>
</div>
<small class="text-muted">Add a new betting option to the available bets list</small>
</div>
<div id="outcomes-pool" class="outcomes-pool"> <div id="outcomes-pool" class="outcomes-pool">
<div class="text-center text-muted"> <div class="text-center text-muted">
<i class="fas fa-spinner fa-spin me-2"></i>Loading outcomes... <i class="fas fa-spinner fa-spin me-2"></i>Loading bets...
</div> </div>
</div> </div>
</div> </div>
...@@ -207,6 +219,35 @@ ...@@ -207,6 +219,35 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Add Bet Modal -->
<div class="modal fade" id="addBetModal" tabindex="-1" aria-labelledby="addBetModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addBetModalLabel">Add Available Bet</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="add-bet-form">
<div class="mb-3">
<label for="bet-name" class="form-label">Bet Name</label>
<input type="text" class="form-control" id="bet-name" placeholder="e.g., WIN1, DRAW, X" required maxlength="50">
<div class="form-text">Enter the bet option name (max 50 characters)</div>
</div>
<div class="mb-3">
<label for="bet-description" class="form-label">Description (Optional)</label>
<input type="text" class="form-control" id="bet-description" placeholder="Description of the bet option" maxlength="255">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="btn-save-bet">Add Bet</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
...@@ -522,6 +563,7 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -522,6 +563,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Event listeners // Event listeners
document.getElementById('btn-save-time-limit').addEventListener('click', saveTimeLimitConfig); document.getElementById('btn-save-time-limit').addEventListener('click', saveTimeLimitConfig);
document.getElementById('btn-clear-all').addEventListener('click', clearAllAssociations); document.getElementById('btn-clear-all').addEventListener('click', clearAllAssociations);
document.getElementById('btn-add-bet').addEventListener('click', addNewBet);
// Redistribution CAP listeners (admin only) // Redistribution CAP listeners (admin only)
if (isAdmin) { if (isAdmin) {
...@@ -532,20 +574,20 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -532,20 +574,20 @@ document.addEventListener('DOMContentLoaded', function() {
setupDragAndDrop(); setupDragAndDrop();
function loadAvailableOutcomes() { function loadAvailableOutcomes() {
fetch('/api/extraction/outcomes') fetch('/api/extraction/available-bets')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
availableOutcomes = data.outcomes; availableOutcomes = data.bets.map(bet => bet.bet_name);
displayOutcomesPool(); displayOutcomesPool();
createOutcomeColumns(); createOutcomeColumns();
} else { } else {
showError('outcomes-pool', 'Failed to load outcomes'); showError('outcomes-pool', 'Failed to load bets');
} }
}) })
.catch(error => { .catch(error => {
console.error('Error loading outcomes:', error); console.error('Error loading bets:', error);
showError('outcomes-pool', 'Error loading outcomes'); showError('outcomes-pool', 'Error loading bets');
}); });
} }
...@@ -558,7 +600,7 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -558,7 +600,7 @@ document.addEventListener('DOMContentLoaded', function() {
header.className = 'mb-3'; header.className = 'mb-3';
header.innerHTML = ` header.innerHTML = `
<h6 class="text-muted mb-2"> <h6 class="text-muted mb-2">
<i class="fas fa-hand-rock me-2"></i>Drag outcomes to associate them <i class="fas fa-hand-rock me-2"></i>Drag bets to associate them
</h6> </h6>
`; `;
pool.appendChild(header); pool.appendChild(header);
...@@ -566,10 +608,28 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -566,10 +608,28 @@ document.addEventListener('DOMContentLoaded', function() {
// Create outcome items // Create outcome items
availableOutcomes.forEach(outcome => { availableOutcomes.forEach(outcome => {
const outcomeElement = document.createElement('div'); const outcomeElement = document.createElement('div');
outcomeElement.className = 'outcome-item'; outcomeElement.className = 'outcome-item position-relative';
outcomeElement.draggable = true; outcomeElement.draggable = true;
outcomeElement.dataset.outcome = outcome; outcomeElement.dataset.outcome = outcome;
outcomeElement.textContent = outcome;
// Add the bet name
const betText = document.createElement('span');
betText.textContent = outcome;
outcomeElement.appendChild(betText);
// Add delete button
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-sm btn-outline-danger position-absolute';
deleteBtn.style.cssText = 'top: -8px; right: -8px; width: 20px; height: 20px; padding: 0; font-size: 10px; border-radius: 50%;';
deleteBtn.innerHTML = '×';
deleteBtn.title = 'Delete bet';
deleteBtn.onclick = (e) => {
e.stopPropagation();
if (confirm(`Are you sure you want to delete the bet "${outcome}"?`)) {
deleteBet(outcome);
}
};
outcomeElement.appendChild(deleteBtn);
// Mark UNDER/OVER outcomes // Mark UNDER/OVER outcomes
if (outcome === 'UNDER' || outcome === 'OVER') { if (outcome === 'UNDER' || outcome === 'OVER') {
...@@ -1182,6 +1242,83 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -1182,6 +1242,83 @@ document.addEventListener('DOMContentLoaded', function() {
`<div class="alert alert-danger">${message}</div>`; `<div class="alert alert-danger">${message}</div>`;
} }
// Available Bets CRUD functions
function addNewBet() {
const betNameInput = document.getElementById('new-bet-name');
const betName = betNameInput.value.trim().toUpperCase();
if (!betName) {
alert('Please enter a bet name');
return;
}
if (betName.length > 50) {
alert('Bet name must be 50 characters or less');
return;
}
// Check if bet already exists
if (availableOutcomes.includes(betName)) {
alert('This bet already exists');
return;
}
const addBtn = document.getElementById('btn-add-bet');
const originalText = addBtn.innerHTML;
addBtn.disabled = true;
addBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Adding...';
fetch('/api/extraction/available-bets/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bet_name: betName,
description: `Bet option: ${betName}`
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
betNameInput.value = '';
loadAvailableOutcomes(); // Refresh the list
alert('Bet added successfully!');
} else {
alert('Failed to add bet: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error adding bet:', error);
alert('Error adding bet');
})
.finally(() => {
addBtn.disabled = false;
addBtn.innerHTML = originalText;
});
}
function deleteBet(betName) {
fetch('/api/extraction/available-bets/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bet_name: betName
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
loadAvailableOutcomes(); // Refresh the list
alert('Bet deleted successfully!');
} else {
alert('Failed to delete bet: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error deleting bet:', error);
alert('Error deleting bet');
});
}
// Redistribution CAP functions (admin only) // Redistribution CAP functions (admin only)
function loadRedistributionCap() { function loadRedistributionCap() {
if (!isAdmin) return; if (!isAdmin) return;
...@@ -1233,6 +1370,79 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -1233,6 +1370,79 @@ document.addEventListener('DOMContentLoaded', function() {
saveBtn.innerHTML = originalText; saveBtn.innerHTML = originalText;
}); });
} }
// Available bets management functions
function addAvailableBet() {
const betName = document.getElementById('bet-name').value.trim().toUpperCase();
const betDescription = document.getElementById('bet-description').value.trim();
if (!betName) {
alert('Bet name is required');
return;
}
if (betName.length > 50) {
alert('Bet name must be 50 characters or less');
return;
}
fetch('/api/extraction/available-bets/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bet_name: betName,
description: betDescription
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Close modal and reset form
const modal = bootstrap.Modal.getInstance(document.getElementById('addBetModal'));
modal.hide();
document.getElementById('add-bet-form').reset();
// Reload available bets
loadAvailableOutcomes();
alert('Bet added successfully!');
} else {
alert('Failed to add bet: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error adding bet:', error);
alert('Error adding bet');
});
}
// Global function to delete available bet
window.deleteAvailableBet = function(betName) {
if (!confirm(`Are you sure you want to delete the bet "${betName}"?`)) {
return;
}
fetch('/api/extraction/available-bets/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bet_name: betName
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Reload available bets
loadAvailableOutcomes();
alert('Bet deleted successfully!');
} else {
alert('Failed to delete bet: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error deleting bet:', error);
alert('Error deleting bet');
});
};
}); });
</script> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment