diff --git a/app/app.py b/app/app.py index 9578a29..1496a53 100644 --- a/app/app.py +++ b/app/app.py @@ -74,14 +74,14 @@ def serve_service_worker(): response.headers["Service-Worker-Allowed"] = "/" return response - @app.errorhandler(Exception) - def handle_exception(e): - # Log the real error with stack trace - app.logger.error(f"Unhandled exception: {str(e)}", exc_info=True) - # Return generic message to user - return jsonify({ - "error": "An unexpected error occurred" - }), 500 + # @app.errorhandler(Exception) + # def handle_exception(e): + # # Log the real error with stack trace + # app.logger.error(f"Unhandled exception: {str(e)}", exc_info=True) + # # Return generic message to user + # return jsonify({ + # "error": "An unexpected error occurred" + # }), 500 return app diff --git a/app/auth/routes.py b/app/auth/routes.py index 055db8a..c89da8d 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -125,7 +125,7 @@ async def login(): login_user(user, remember=remember) next_page = request.args.get('next') if not next_page or not is_safe_url(next_page): - next_page = url_for('main.index') + next_page = url_for('index') flash("Successfully logged in", "success") return redirect(next_page) else: @@ -185,14 +185,15 @@ def logout(): @auth_bp.route("/settings", methods=["GET", "POST"]) @login_required -def settings(): +@async_route +async def settings(): try: if request.method == "POST": # Handle form submission form_data = request.form file = request.files.get("profile_picture") - success = user_manager.update_user_settings( + success = await user_manager.update_user_settings( current_user.get_id(), form_data, file diff --git a/app/scout/routes.py b/app/scout/routes.py index 6673514..7e50547 100644 --- a/app/scout/routes.py +++ b/app/scout/routes.py @@ -6,6 +6,8 @@ from app.scout.scouting_utils import ScoutingManager from .TBA import TBAInterface from bson import ObjectId +from gridfs import GridFS +import base64 scouting_bp = Blueprint("scouting", __name__) scouting_manager = None @@ -104,120 +106,171 @@ def delete_scouting_data(id): def compare_page(): return render_template("compare.html") +def format_team_stats(stats): + """Format team stats with calculated totals""" + return { + "matches_played": stats.get("matches_played", 0), + "auto_coral_total": sum([ + stats.get("avg_auto_coral_level1", 0), + stats.get("avg_auto_coral_level2", 0), + stats.get("avg_auto_coral_level3", 0), + stats.get("avg_auto_coral_level4", 0) + ]), + "teleop_coral_total": sum([ + stats.get("avg_teleop_coral_level1", 0), + stats.get("avg_teleop_coral_level2", 0), + stats.get("avg_teleop_coral_level3", 0), + stats.get("avg_teleop_coral_level4", 0) + ]), + "auto_algae_total": sum([ + stats.get("avg_auto_algae_net", 0), + stats.get("avg_auto_algae_processor", 0) + ]), + "teleop_algae_total": sum([ + stats.get("avg_teleop_algae_net", 0), + stats.get("avg_teleop_algae_processor", 0) + ]), + "human_player": stats.get("avg_human_player", 0), + "climb_success_rate": stats.get("climb_success_rate", 0) * 100 + } + @scouting_bp.route("/api/compare") @login_required @async_route async def compare_teams(): - team1 = request.args.get("team1", "").strip() - team2 = request.args.get("team2", "").strip() - - if not team1 or not team2: - return jsonify({"error": "Both team numbers are required"}), 400 - try: - tba = TBAInterface() - teams_data = {} - - for team_num in [team1, team2]: - # Fetch TBA team info using async client - team_key = f"frc{team_num}" - url = f"{tba.base_url}/team/{team_key}" - - async with aiohttp.ClientSession(headers=tba.headers) as session: - async with session.get(url) as response: - if response.status != 200: - return jsonify({"error": f"Team {team_num} not found"}), 404 - team = await response.json() + # Get team numbers from query parameters + teams = [] + for i in range(1, 4): # Check for team1, team2, team3 + team_num = request.args.get(f'team{i}') + if team_num: + teams.append(team_num) - # Fetch all scouting data for this team from MongoDB - pipeline = [ - {"$match": {"team_number": int(team_num)}}, - { - "$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter", - } - }, - {"$unwind": "$scouter"}, - ] + if len(teams) < 2: + return jsonify({"error": "At least two team numbers are required"}), 400 - team_scouting_data = list(scouting_manager.db.team_data.aggregate(pipeline)) - - # Calculate statistics - auto_points = [entry["auto_points"] for entry in team_scouting_data] - teleop_points = [entry["teleop_points"] for entry in team_scouting_data] - endgame_points = [entry["endgame_points"] for entry in team_scouting_data] - total_points = [entry["total_points"] for entry in team_scouting_data] - - stats = { - "matches_played": len(team_scouting_data), - "avg_auto": (sum(auto_points) / len(auto_points) if auto_points else 0), - "avg_teleop": ( - sum(teleop_points) / len(teleop_points) if teleop_points else 0 - ), - "avg_endgame": ( - sum(endgame_points) / len(endgame_points) if endgame_points else 0 - ), - "avg_total": ( - sum(total_points) / len(total_points) if total_points else 0 - ), - "max_total": max(total_points, default=0), - "min_total": min(total_points, default=0), - } + teams_data = {} + tba = TBAInterface() - scouting_entries = [ - { - "event_code": entry["event_code"], - "match_number": entry["match_number"], - "coral_levels": [ - entry["coral_level1"], - entry["coral_level2"], - entry["coral_level3"], - entry["coral_level4"] - ], - "algae": { - "net": entry["algae_net"], - "processor": entry["algae_processor"], - "human_player": entry["human_player"] - }, - "climb": { - "type": entry["climb_type"], - "success": entry["climb_success"] + for team_num in teams: + try: + # Get team stats from database + pipeline = [ + {"$match": {"team_number": int(team_num)}}, + {"$group": { + "_id": "$team_number", + "matches_played": {"$sum": 1}, + "auto_coral_level1": {"$avg": {"$ifNull": ["$auto_coral_level1", 0]}}, + "auto_coral_level2": {"$avg": {"$ifNull": ["$auto_coral_level2", 0]}}, + "auto_coral_level3": {"$avg": {"$ifNull": ["$auto_coral_level3", 0]}}, + "auto_coral_level4": {"$avg": {"$ifNull": ["$auto_coral_level4", 0]}}, + "auto_algae_net": {"$avg": {"$ifNull": ["$auto_algae_net", 0]}}, + "auto_algae_processor": {"$avg": {"$ifNull": ["$auto_algae_processor", 0]}}, + "teleop_coral_level1": {"$avg": {"$ifNull": ["$teleop_coral_level1", 0]}}, + "teleop_coral_level2": {"$avg": {"$ifNull": ["$teleop_coral_level2", 0]}}, + "teleop_coral_level3": {"$avg": {"$ifNull": ["$teleop_coral_level3", 0]}}, + "teleop_coral_level4": {"$avg": {"$ifNull": ["$teleop_coral_level4", 0]}}, + "teleop_algae_net": {"$avg": {"$ifNull": ["$teleop_algae_net", 0]}}, + "teleop_algae_processor": {"$avg": {"$ifNull": ["$teleop_algae_processor", 0]}}, + "climb_success_rate": { + "$avg": {"$cond": [{"$eq": ["$climb_success", True]}, 100, 0]} + }, + "preferred_climb_type": {"$last": "$climb_type"} + }} + ] + + team_stats = list(scouting_manager.db.team_data.aggregate(pipeline)) + stats = team_stats[0] if team_stats else {} + + # Get team info from TBA using async HTTP client + team_key = f"frc{team_num}" + url = f"{tba.base_url}/team/{team_key}" + + async with aiohttp.ClientSession(headers=tba.headers) as session: + async with session.get(url) as response: + team_info = await response.json() if response.status == 200 else {} + + # Get auto path images from GridFS + fs = GridFS(scouting_manager.db) + auto_paths = [] + + # Find all auto paths for this team + for path_file in fs.find({ + "filename": {"$regex": f"^team_{team_num}_auto_path"}, + "metadata.type": "auto_path" + }).sort("metadata.match_number", 1): + try: + binary_data = path_file.read() + base64_data = base64.b64encode(binary_data).decode('utf-8') + auto_paths.append({ + "match_number": path_file.metadata.get("match_number", "Unknown"), + "image_data": f"data:image/png;base64,{base64_data}" + }) + except Exception as e: + current_app.logger.error(f"Error processing path file: {str(e)}") + continue + + teams_data[team_num] = { + "team_number": int(team_num), + "nickname": team_info.get("nickname", "Unknown"), + "school_name": team_info.get("school_name"), + "city": team_info.get("city"), + "state_prov": team_info.get("state_prov"), + "country": team_info.get("country"), + "stats": { + "matches_played": stats.get("matches_played", 0), + "auto_coral_level1": stats.get("auto_coral_level1", 0), + "auto_coral_level2": stats.get("auto_coral_level2", 0), + "auto_coral_level3": stats.get("auto_coral_level3", 0), + "auto_coral_level4": stats.get("auto_coral_level4", 0), + "auto_algae_net": stats.get("auto_algae_net", 0), + "auto_algae_processor": stats.get("auto_algae_processor", 0), + "teleop_coral_level1": stats.get("teleop_coral_level1", 0), + "teleop_coral_level2": stats.get("teleop_coral_level2", 0), + "teleop_coral_level3": stats.get("teleop_coral_level3", 0), + "teleop_coral_level4": stats.get("teleop_coral_level4", 0), + "teleop_algae_net": stats.get("teleop_algae_net", 0), + "teleop_algae_processor": stats.get("teleop_algae_processor", 0), + "climb_success_rate": stats.get("climb_success_rate", 0), + "preferred_climb_type": stats.get("preferred_climb_type", "none"), + "normalized_stats": { + "auto_scoring": ( + stats.get("auto_coral_level1", 0) + + stats.get("auto_coral_level2", 0) * 2 + + stats.get("auto_coral_level3", 0) * 3 + + stats.get("auto_coral_level4", 0) * 4 + + stats.get("auto_algae_net", 0) + + stats.get("auto_algae_processor", 0) + ) / 6, + "teleop_scoring": ( + stats.get("teleop_coral_level1", 0) + + stats.get("teleop_coral_level2", 0) * 2 + + stats.get("teleop_coral_level3", 0) * 3 + + stats.get("teleop_coral_level4", 0) * 4 + + stats.get("teleop_algae_net", 0) + + stats.get("teleop_algae_processor", 0) + ) / 6, + "climb_rating": stats.get("climb_success_rate", 0), + "defense_rating": stats.get("avg_defense", 0), + "human_player": stats.get("human_player", 0) + } }, - "total_points": entry["total_points"], - "notes": entry["notes"], - "scouter": entry["scouter"]["username"], + "auto_paths": auto_paths # Add the auto paths to the response } - for entry in team_scouting_data - ] - teams_data[team_num] = { - "team_number": team["team_number"], - "nickname": team["nickname"], - "school_name": team.get("school_name"), - "city": team.get("city"), - "state_prov": team.get("state_prov"), - "country": team.get("country"), - "stats": { - "matches_played": stats["matches_played"], - "avg_coral": stats["avg_coral"], - "avg_algae": stats["avg_algae"], - "climb_success_rate": stats["climb_success_rate"], - "defense_rating": stats["avg_defense"], - "total_points": stats["total_points"] - }, - "scouting_data": scouting_entries, - } + except Exception as team_error: + current_app.logger.error(f"Error processing team {team_num}: {str(team_error)}") + teams_data[team_num] = { + "team_number": int(team_num), + "error": str(team_error) + } return jsonify(teams_data) except Exception as e: - print(f"Error comparing teams: {e}") - return jsonify({"error": "Failed to fetch team data"}), 500 - + current_app.logger.error(f"Error comparing teams: {str(e)}", exc_info=True) + return jsonify({"error": str(e)}), 500 @scouting_bp.route("/search") @login_required diff --git a/app/static/js/compare.js b/app/static/js/compare.js index d870fb0..3d60f7b 100644 --- a/app/static/js/compare.js +++ b/app/static/js/compare.js @@ -1,9 +1,12 @@ // Constants const API_ENDPOINT = '/api/compare'; +const MIN_TEAMS = 2; +const MAX_TEAMS = 3; // DOM Elements const team1Input = document.getElementById('team1-input'); const team2Input = document.getElementById('team2-input'); +const team3Input = document.getElementById('team3-input'); const compareBtn = document.getElementById('compare-btn'); const comparisonResults = document.getElementById('comparison-results'); @@ -11,122 +14,338 @@ const comparisonResults = document.getElementById('comparison-results'); compareBtn.addEventListener('click', compareTeams); async function compareTeams() { - const team1 = team1Input.value.trim(); - const team2 = team2Input.value.trim(); + const teams = [ + team1Input.value.trim(), + team2Input.value.trim(), + team3Input.value.trim() + ].filter(team => team !== ''); - if (!team1 || !team2) { - alert('Please enter both team numbers'); + if (teams.length < MIN_TEAMS) { + alert(`Please enter at least ${MIN_TEAMS} team numbers`); return; } try { - const response = await fetch(`${API_ENDPOINT}?team1=${team1}&team2=${team2}`); + const queryString = teams + .map((team, index) => `team${index + 1}=${encodeURIComponent(team)}`) + .join('&'); + + const response = await fetch(`${API_ENDPOINT}?${queryString}`); + const data = await response.json(); + if (!response.ok) { - const data = await response.json(); throw new Error(data.error || 'Failed to fetch team data'); } - const teamsData = await response.json(); - displayComparisonResults(teamsData); + displayComparisonResults(data); } catch (error) { - alert(error.message); + console.error('Error comparing teams:', error); + alert(error.message || 'An error occurred while comparing teams'); } } function displayComparisonResults(teamsData) { + // Add debug logging + console.log('Teams Data:', teamsData); + comparisonResults.classList.remove('hidden'); - // Update team information for both teams - for (const [teamNum, teamData] of Object.entries(teamsData)) { - const teamPrefix = `team${teamNum === Object.keys(teamsData)[0] ? '1' : '2'}`; - const teamNumber = teamData.team_number; + // Get all teams' stats for comparison + const allStats = Object.values(teamsData).map(team => { + // Add null check for team.stats + if (!team || !team.stats) return {}; + return team.stats; + }); + + // Hide all team info containers first + for (let i = 1; i <= MAX_TEAMS; i++) { + const container = document.getElementById(`team${i}-info`); + if (container) { + container.classList.add('hidden'); + } + } + + // Display each team's data with highlighting + Object.entries(teamsData).forEach(([teamNum, teamData], index) => { + // Add error handling for malformed team data + if (!teamData) { + console.error(`No data received for team ${teamNum}`); + return; + } + + const teamPrefix = `team${index + 1}`; + const container = document.getElementById(`${teamPrefix}-info`); - document.getElementById(`${teamPrefix}-header`).textContent = `Team ${teamNumber}`; - document.getElementById(`${teamPrefix}-history-header`).textContent = - `Team's ${teamNumber} Match History`; + if (!container) { + console.error(`Container not found for ${teamPrefix}`); + return; + } + + container.classList.remove('hidden'); - document.getElementById(`${teamPrefix}-number-name`).textContent = - `#${teamNumber} - ${teamData.nickname}`; + // Update basic team info with additional error checking + const header = document.getElementById(`${teamPrefix}-header`); + const numberName = document.getElementById(`${teamPrefix}-number-name`); + const locationEl = document.getElementById(`${teamPrefix}-location`); + const statsContainer = document.getElementById(`${teamPrefix}-stats`); - document.getElementById(`${teamPrefix}-location`).textContent = - formatLocation(teamData); + if (header) header.textContent = `Team ${teamData.team_number}`; + if (numberName) numberName.textContent = + `#${teamData.team_number}${teamData.nickname ? ` - ${teamData.nickname}` : ''}`; - const statsContainer = document.getElementById(`${teamPrefix}-stats`); - statsContainer.innerHTML = formatStats(teamData.stats); + // Only show location if we have any location data + const location = formatLocation(teamData); + if (locationEl) { + if (location) { + locationEl.textContent = location; + locationEl.classList.remove('hidden'); + } else { + locationEl.classList.add('hidden'); + } + } - const matchesContainer = document.getElementById(`${teamPrefix}-matches`); - matchesContainer.innerHTML = formatMatchHistory(teamData.scouting_data); + // Update stats with highlighting + if (statsContainer) { + statsContainer.innerHTML = formatStatsWithHighlighting(teamData.stats, allStats); + } + }); + + // Adjust containers based on number of teams + const teamCount = Object.keys(teamsData).length; + const cardsContainer = document.getElementById('team-cards-container'); + const autoPathsContainer = document.getElementById('auto-paths-container'); + + if (cardsContainer) { + if (teamCount === 2) { + cardsContainer.className = 'grid grid-cols-1 md:grid-cols-2 gap-6'; + } else if (teamCount === 3) { + cardsContainer.className = 'grid grid-cols-1 md:grid-cols-3 gap-6'; + } + } + + if (autoPathsContainer) { + if (teamCount === 2) { + autoPathsContainer.className = 'grid grid-cols-1 md:grid-cols-2 gap-6'; + } else if (teamCount === 3) { + autoPathsContainer.className = 'grid grid-cols-1 md:grid-cols-3 gap-6'; + } } - // Update radar charts - updateRadarCharts(teamsData); + // Display auto paths for each team + Object.entries(teamsData).forEach(([teamNum, teamData], index) => { + console.log(`Drawing auto paths for team ${teamNum}:`, teamData.auto_paths); + displayAutoPath(teamNum, teamData.auto_paths, index + 1); + }); + + // Update radar chart + updateRadarChart(teamsData); } function formatLocation(teamData) { const parts = []; - if (teamData.school_name) { - parts.push(teamData.school_name); - } - if (teamData.city) { - parts.push(teamData.city); - } - if (teamData.state_prov) { - parts.push(teamData.state_prov); - } - if (teamData.country && teamData.country !== 'USA') { - parts.push(teamData.country); - } + if (teamData.city) parts.push(teamData.city); + if (teamData.state_prov) parts.push(teamData.state_prov); + if (teamData.country && teamData.country !== 'USA') parts.push(teamData.country); return parts.join(', '); } -function formatStats(stats) { +function formatStatsWithHighlighting(stats, allStats) { + // Add default values if stats is undefined + stats = stats || {}; + + const statComparisons = { + // Auto Period + 'Auto Coral Scoring': { + 'Level 1': calculateStatRanking(stats.auto_coral_level1 || 0, allStats.map(s => s?.auto_coral_level1 || 0)), + 'Level 2': calculateStatRanking(stats.auto_coral_level2 || 0, allStats.map(s => s?.auto_coral_level2 || 0)), + 'Level 3': calculateStatRanking(stats.auto_coral_level3 || 0, allStats.map(s => s?.auto_coral_level3 || 0)), + 'Level 4': calculateStatRanking(stats.auto_coral_level4 || 0, allStats.map(s => s?.auto_coral_level4 || 0)) + }, + 'Auto Algae Scoring': { + 'Net': calculateStatRanking(stats.auto_algae_net || 0, allStats.map(s => s?.auto_algae_net || 0)), + 'Processor': calculateStatRanking(stats.auto_algae_processor || 0, allStats.map(s => s?.auto_algae_processor || 0)) + }, + + // Teleop Period + 'Teleop Coral Scoring': { + 'Level 1': calculateStatRanking(stats.teleop_coral_level1 || 0, allStats.map(s => s?.teleop_coral_level1 || 0)), + 'Level 2': calculateStatRanking(stats.teleop_coral_level2 || 0, allStats.map(s => s?.teleop_coral_level2 || 0)), + 'Level 3': calculateStatRanking(stats.teleop_coral_level3 || 0, allStats.map(s => s?.teleop_coral_level3 || 0)), + 'Level 4': calculateStatRanking(stats.teleop_coral_level4 || 0, allStats.map(s => s?.teleop_coral_level4 || 0)) + }, + 'Teleop Algae Scoring': { + 'Net': calculateStatRanking(stats.teleop_algae_net || 0, allStats.map(s => s?.teleop_algae_net || 0)), + 'Processor': calculateStatRanking(stats.teleop_algae_processor || 0, allStats.map(s => s?.teleop_algae_processor || 0)) + }, + + // Endgame + 'Climb Success Rate': calculateStatRanking(stats.climb_success_rate || 0, allStats.map(s => s?.climb_success_rate || 0)), + 'Preferred Climb Type': stats.preferred_climb_type || 'none' + }; + + // Add null checks for value formatting return ` -
No auto paths available for this team.
+ `; + return; + } + + // Create canvas for each path + pathData.forEach((path, index) => { + const canvasWrapper = document.createElement('div'); + canvasWrapper.className = 'mb-4'; + + const matchLabel = document.createElement('p'); + matchLabel.className = 'text-sm text-gray-600 mb-2'; + matchLabel.textContent = `Match ${path.match_number}`; + + const canvas = document.createElement('canvas'); + canvas.width = 400; + canvas.height = 300; + canvas.className = 'border rounded-lg bg-white w-full'; + + canvasWrapper.appendChild(matchLabel); + canvasWrapper.appendChild(canvas); + container.appendChild(canvasWrapper); + + // Draw the path + const ctx = canvas.getContext('2d'); + const img = new Image(); + img.onload = () => { + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + }; + img.src = path.image_data; + }); +} + +function updateRadarChart(teamsData) { + const ctx = document.getElementById('radar-chart-combined'); + + // Destroy existing chart if it exists + if (window.radarChart) { + window.radarChart.destroy(); + } + + const datasets = Object.entries(teamsData).map(([teamNum, teamData], index) => { + const colors = ['rgba(37, 99, 235, 0.2)', 'rgba(220, 38, 38, 0.2)', 'rgba(5, 150, 105, 0.2)']; + const borderColors = ['rgb(37, 99, 235)', 'rgb(220, 38, 38)', 'rgb(5, 150, 105)']; + + // Scale values to be out of 20 instead of 100 + const stats = teamData.stats.normalized_stats; + return { + label: `Team ${teamNum}`, + data: [ + Math.min(20, stats.auto_scoring), + Math.min(20, stats.teleop_scoring), + Math.min(20, stats.climb_rating / 5), + Math.min(20, stats.defense_rating * 4), + Math.min(20, stats.human_player * 4) + ], + backgroundColor: colors[index], + borderColor: borderColors[index], + borderWidth: 2 + }; + }); + + window.radarChart = new Chart(ctx, { + type: 'radar', + data: { + labels: [ + 'Auto Scoring', + 'Teleop Scoring', + 'Climb Success', + 'Defense Rating', + 'Human Player Rating' + ], + datasets: datasets + }, + options: { + scales: { + r: { + beginAtZero: true, + max: 20, + ticks: { + stepSize: 4 + } + } + } + } + }); } \ No newline at end of file diff --git a/app/team/routes.py b/app/team/routes.py index 70f4670..f89ed24 100644 --- a/app/team/routes.py +++ b/app/team/routes.py @@ -21,6 +21,7 @@ from io import BytesIO import asyncio from PIL import Image +from bson import ObjectId ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'} @@ -399,11 +400,12 @@ async def delete_team(team_number): @team_bp.route("/team/Compare statistics and scouting data between two teams
+Compare statistics and scouting data between teams
Match | -Auto | -Teleop | -Endgame | -Total | -
---|
Match | -Auto | -Teleop | -Endgame | -Total | -
---|